mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
commit
983e8e27d9
143 changed files with 11215 additions and 23 deletions
37
.github/workflows/ci.yml
vendored
Normal file
37
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
name: CI Pipeline for S3-Portal
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
|
||||
SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }}
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build, Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 🧩 Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🛠 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: 🔍 Lint code
|
||||
run: npm run lint --workspaces=false
|
||||
|
||||
- name: 🏗 Build Next.js app
|
||||
run: npm run build
|
||||
9
app/admin/clients/[id]/billing/page.tsx
Normal file
9
app/admin/clients/[id]/billing/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Params } from "@/modules/shared/types/user"
|
||||
import { BillingPanel } from "@/modules/shared/ui/views/billing-panel"
|
||||
|
||||
const BillingAdminPage = async ({ params } : { params: Params }) => {
|
||||
const { id } = await params
|
||||
return <BillingPanel profileId={id} isAdmin />
|
||||
}
|
||||
|
||||
export default BillingAdminPage
|
||||
18
app/admin/clients/[id]/data/page.tsx
Normal file
18
app/admin/clients/[id]/data/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { getStammdaten } from "@/modules/shared/queries/stammdaten"
|
||||
import { Params } from "@/modules/shared/types/user"
|
||||
import { StammdatenPanel } from "@/modules/shared/ui/views/stammdaten-panel"
|
||||
|
||||
const DataAdminPage = async ({ params }: { params: Params }) => {
|
||||
const { id } = await params
|
||||
const stammdaten = await getStammdaten(id)
|
||||
|
||||
return (
|
||||
<StammdatenPanel
|
||||
profileId={id}
|
||||
client={stammdaten?.client ?? null}
|
||||
paymentDetails={stammdaten?.paymentDetails ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataAdminPage
|
||||
28
app/admin/clients/[id]/layout.tsx
Normal file
28
app/admin/clients/[id]/layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { notFound, redirect } from "next/navigation";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { Params } from "@/modules/shared/types/user";
|
||||
|
||||
export default async function ClientDetailLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Params;
|
||||
}) {
|
||||
const session = await verifySession();
|
||||
if (!session.isAdmin) redirect("/client/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
const { data: client } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("id", id)
|
||||
.eq("role", "client")
|
||||
.single();
|
||||
|
||||
if (!client) notFound();
|
||||
|
||||
return children;
|
||||
}
|
||||
22
app/admin/clients/[id]/page.tsx
Normal file
22
app/admin/clients/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { Params } from "@/modules/shared/types/user";
|
||||
import InfoPanel from "@/modules/shared/ui/views/info-panel";
|
||||
|
||||
|
||||
const ClientDetailPage = async ({ params }: { params: Params }) => {
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
const { data: client } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, name, email")
|
||||
.eq("id", id)
|
||||
.eq("role", "client")
|
||||
.single();
|
||||
|
||||
if (!client) notFound();
|
||||
|
||||
return <InfoPanel profileId={client.id} isAdmin />
|
||||
}
|
||||
|
||||
export default ClientDetailPage
|
||||
9
app/admin/clients/[id]/passwords/page.tsx
Normal file
9
app/admin/clients/[id]/passwords/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Params } from "@/modules/shared/types/user"
|
||||
import { CredentialsPanel } from "@/modules/shared/ui/views/credentials-panel"
|
||||
|
||||
const PasswordAdminPage = async ({ params } : { params: Params }) => {
|
||||
const { id } = await params
|
||||
return <CredentialsPanel profileId={id} isAdmin />
|
||||
}
|
||||
|
||||
export default PasswordAdminPage
|
||||
20
app/admin/clients/[id]/storage/page.tsx
Normal file
20
app/admin/clients/[id]/storage/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Params } from "@/modules/shared/types/user"
|
||||
import { DocumentsPanel } from "@/modules/shared/ui/views/documents-panel"
|
||||
|
||||
type SearchParams = Promise<{ folder?: string | string[] }>
|
||||
|
||||
const StorageAdminPage = async ({ params, searchParams }: { params: Params; searchParams: SearchParams }) => {
|
||||
const { id } = await params
|
||||
const { folder } = await searchParams
|
||||
|
||||
return (
|
||||
<DocumentsPanel
|
||||
profileId={id}
|
||||
isAdmin
|
||||
basePath={`/admin/clients/${id}/storage`}
|
||||
folderId={typeof folder === "string" ? folder : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default StorageAdminPage
|
||||
12
app/admin/clients/page.tsx
Normal file
12
app/admin/clients/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import ClientListViewPage from "@/modules/admin/ui/views/client-list-view";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Clients = async () => {
|
||||
const session = await verifySession();
|
||||
if (!session.isAdmin) redirect("/client/dashboard");
|
||||
|
||||
return <ClientListViewPage/>
|
||||
}
|
||||
|
||||
export default Clients
|
||||
12
app/admin/dashboard/page.tsx
Normal file
12
app/admin/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import DashboardView from "@/modules/admin/ui/views/dashboard-view";
|
||||
|
||||
const AdminDashboard = async () => {
|
||||
const session = await verifySession();
|
||||
if (!session.isAdmin) redirect("/client/dashboard");
|
||||
|
||||
return <DashboardView name={session.name} />
|
||||
}
|
||||
|
||||
export default AdminDashboard
|
||||
25
app/admin/layout.tsx
Normal file
25
app/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
|
||||
import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog";
|
||||
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await verifySession()
|
||||
const cookieStore = await cookies()
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
{!session.isAdmin && !session.hasAcceptedTerms && <TermsDialog/>}
|
||||
<AppSidebar session={session}/>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center border-b border-border p-3 md:hidden">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
<main className="flex-1 p-4 md:p-8">{children}</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
12
app/admin/settings/page.tsx
Normal file
12
app/admin/settings/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { redirect } from "next/navigation"
|
||||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import SettingsView from "@/modules/admin/ui/views/settings-view"
|
||||
|
||||
const AdminSettings = async () => {
|
||||
const session = await verifySession()
|
||||
if (!session.isAdmin) redirect("/client/dashboard")
|
||||
|
||||
return <SettingsView name={session.name} email={session.email} />
|
||||
}
|
||||
|
||||
export default AdminSettings
|
||||
50
app/api/invoices/pdf/route.ts
Normal file
50
app/api/invoices/pdf/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import * as z from "zod"
|
||||
import { zipSync } from "fflate"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { getSession } from "@/lib/supabase/dal"
|
||||
import { getInvoicesForPdf } from "@/modules/shared/queries/invoices"
|
||||
import { invoiceFileName, renderInvoicePdf } from "@/modules/shared/pdf/render-invoice"
|
||||
|
||||
const IdsSchema = z.array(z.string().uuid()).min(1).max(100)
|
||||
|
||||
// Zugriff regelt RLS: Clients bekommen nur eigene, nicht stornierte Rechnungen zurück.
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await getSession()
|
||||
if (!user) return Response.json({ error: "Nicht angemeldet." }, { status: 401 })
|
||||
|
||||
const parsed = IdsSchema.safeParse(request.nextUrl.searchParams.getAll("id"))
|
||||
if (!parsed.success) return Response.json({ error: "Ungültige Auswahl." }, { status: 400 })
|
||||
|
||||
const requestedIds = [...new Set(parsed.data)]
|
||||
const invoices = await getInvoicesForPdf(requestedIds)
|
||||
if (invoices.length === 0) return Response.json({ error: "Keine Rechnungen gefunden." }, { status: 404 })
|
||||
// RLS filtert unerlaubte/stornierte Rechnungen stillschweigend raus – dann lieber gar nichts liefern als ein unvollständiges ZIP.
|
||||
if (invoices.length !== requestedIds.length) {
|
||||
return Response.json({ error: "Nicht alle ausgewählten Rechnungen sind verfügbar." }, { status: 404 })
|
||||
}
|
||||
|
||||
if (invoices.length === 1) {
|
||||
const pdf = await renderInvoicePdf(invoices[0])
|
||||
const disposition = request.nextUrl.searchParams.has("inline") ? "inline" : "attachment"
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `${disposition}; filename="${invoiceFileName(invoices[0].invoiceNumber)}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
for (const invoice of invoices) {
|
||||
files[invoiceFileName(invoice.invoiceNumber)] = new Uint8Array(await renderInvoicePdf(invoice))
|
||||
}
|
||||
|
||||
return new Response(zipSync(files, { level: 0 }), {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": "attachment; filename=\"Rechnungen.zip\"",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
41
app/api/invoices/preview/route.ts
Normal file
41
app/api/invoices/preview/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import * as z from "zod"
|
||||
import { getProfile } from "@/lib/supabase/dal"
|
||||
import { InvoiceDraftSchema, firstIssueMessage } from "@/modules/shared/schemas/invoice"
|
||||
import { renderInvoicePdf } from "@/modules/shared/pdf/render-invoice"
|
||||
|
||||
const PreviewSchema = z.object({
|
||||
draft: InvoiceDraftSchema,
|
||||
invoiceNumber: z.string().trim().min(1).max(40),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const profile = await getProfile()
|
||||
if (!profile) return Response.json({ error: "Nicht angemeldet." }, { status: 401 })
|
||||
if (profile.role !== "admin") return Response.json({ error: "Keine Berechtigung." }, { status: 403 })
|
||||
|
||||
const parsed = PreviewSchema.safeParse(await request.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: firstIssueMessage(parsed.error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const { draft, invoiceNumber } = parsed.data
|
||||
const pdf = await renderInvoicePdf({
|
||||
invoiceNumber,
|
||||
invoiceDate: draft.invoiceDate,
|
||||
dueDate: draft.dueDate,
|
||||
companyName: draft.companyName,
|
||||
billingStreet: draft.billingStreet,
|
||||
billingPostalCode: draft.billingPostalCode,
|
||||
billingCity: draft.billingCity,
|
||||
billingCountry: draft.billingCountry,
|
||||
items: draft.items,
|
||||
})
|
||||
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": "inline; filename=\"Vorschau.pdf\"",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
9
app/client/billing/page.tsx
Normal file
9
app/client/billing/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import { BillingPanel } from "@/modules/shared/ui/views/billing-panel";
|
||||
|
||||
const BillingPage = async () => {
|
||||
const session = await verifySession();
|
||||
return <BillingPanel profileId={session.userId} isAdmin={false}/>
|
||||
}
|
||||
|
||||
export default BillingPage
|
||||
9
app/client/dashboard/page.tsx
Normal file
9
app/client/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import InfoPanel from "@/modules/shared/ui/views/info-panel"
|
||||
|
||||
const ClientDashboard = async () => {
|
||||
const session = await verifySession()
|
||||
return <InfoPanel profileId={session.userId} isAdmin={false} />
|
||||
}
|
||||
|
||||
export default ClientDashboard
|
||||
18
app/client/data/page.tsx
Normal file
18
app/client/data/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import { getStammdaten } from "@/modules/shared/queries/stammdaten"
|
||||
import { StammdatenPanel } from "@/modules/shared/ui/views/stammdaten-panel"
|
||||
|
||||
const DataPage = async () => {
|
||||
const session = await verifySession()
|
||||
const stammdaten = await getStammdaten(session.userId)
|
||||
|
||||
return (
|
||||
<StammdatenPanel
|
||||
profileId={session.userId}
|
||||
client={stammdaten?.client ?? null}
|
||||
paymentDetails={stammdaten?.paymentDetails ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataPage
|
||||
28
app/client/layout.tsx
Normal file
28
app/client/layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
|
||||
import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog";
|
||||
|
||||
|
||||
export default async function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await verifySession()
|
||||
const cookieStore = await cookies()
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
|
||||
|
||||
if (!session.isAdmin && !session.hasAcceptedTerms) {
|
||||
return <TermsDialog />
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar session={session}/>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center border-b border-border p-3 md:hidden">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
<main className="flex-1 p-4 md:p-8">{children}</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
9
app/client/passwords/page.tsx
Normal file
9
app/client/passwords/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import { CredentialsPanel } from "@/modules/shared/ui/views/credentials-panel"
|
||||
|
||||
const PasswordPage = async () => {
|
||||
const session = await verifySession()
|
||||
return <CredentialsPanel profileId={session.userId} isAdmin={false} />
|
||||
}
|
||||
|
||||
export default PasswordPage
|
||||
20
app/client/storage/page.tsx
Normal file
20
app/client/storage/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import { DocumentsPanel } from "@/modules/shared/ui/views/documents-panel"
|
||||
|
||||
type SearchParams = Promise<{ folder?: string | string[] }>
|
||||
|
||||
const StoragePage = async ({ searchParams }: { searchParams: SearchParams }) => {
|
||||
const session = await verifySession()
|
||||
const { folder } = await searchParams
|
||||
|
||||
return (
|
||||
<DocumentsPanel
|
||||
profileId={session.userId}
|
||||
isAdmin={false}
|
||||
basePath="/client/storage"
|
||||
folderId={typeof folder === "string" ? folder : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoragePage
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
}
|
||||
|
||||
:root {
|
||||
--background: var(--ink-0);
|
||||
--background: oklch(0.99 0 0);
|
||||
--foreground: var(--ink-950);
|
||||
--card: var(--ink-0);
|
||||
--card-foreground: var(--ink-950);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
|
|
@ -22,8 +23,11 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||
<html
|
||||
lang="en"
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import LoginPageView from "@/modules/loginPage/ui/views/login-view";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
);
|
||||
return <LoginPageView/>;
|
||||
}
|
||||
|
|
|
|||
12
app/terms/page.tsx
Normal file
12
app/terms/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
const TermsPage = () => {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-8">
|
||||
<h1 className="mb-4 text-2xl font-semibold">Nutzungsbedingungen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
TODO: Nutzungsbedingungen einfügen.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TermsPage
|
||||
15
components/theme-provider.tsx
Normal file
15
components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"use client"
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
108
components/ui/avatar.tsx
Normal file
108
components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||
import { cn } from "cn"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center rounded-full px-2.5 py-0.5 text-label uppercase tracking-[0.02em] whitespace-nowrap",
|
||||
"inline-flex w-fit shrink-0 items-center justify-center rounded-full px-2.5 py-0.5 text-label whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||
import { cn } from "cn"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color] duration-[var(--duration-fast)] ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center gap-2 cursor-pointer rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color] duration-[var(--duration-fast)] ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-primary text-primary-foreground hover:bg-accent-600",
|
||||
primary: "bg-primary text-primary-foreground hover:bg-accent-600 dark:hover:bg-ink-100",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground border-border hover:bg-ink-100 dark:hover:bg-ink-800",
|
||||
outline:
|
||||
|
|
@ -21,6 +21,7 @@ const buttonVariants = cva(
|
|||
md: "h-10 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
lg: "h-12 gap-2 px-4 text-base has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-10",
|
||||
"icon-sm": "size-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
21
components/ui/collapsible.tsx
Normal file
21
components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
160
components/ui/dialog.tsx
Normal file
160
components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
267
components/ui/dropdown-menu.tsx
Normal file
267
components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
import { cn } from "cn"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
238
components/ui/field.tsx
Normal file
238
components/ui/field.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
158
components/ui/input-group.tsx
Normal file
158
components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control] has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end":
|
||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset"
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
19
components/ui/label.tsx
Normal file
19
components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
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 }
|
||||
24
components/ui/separator.tsx
Normal file
24
components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
138
components/ui/sheet.tsx
Normal file
138
components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.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 }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
723
components/ui/sidebar.tsx
Normal file
723
components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
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,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
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}>
|
||||
<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 flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...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(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>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 hidden text-sidebar-foreground 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"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// 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="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{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-sm"
|
||||
className={cn(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(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"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",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[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(
|
||||
"relative flex w-full flex-1 flex-col bg-background 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("h-8 w-full bg-background 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("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 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,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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-0", 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 group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! 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 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_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({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
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(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border 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({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden 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 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "cn"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
17
components/ui/textarea.tsx
Normal file
17
components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
65
components/ui/tooltip.tsx
Normal file
65
components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
import { cn } from "cn"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
21
hooks/use-mobile.ts
Normal file
21
hooks/use-mobile.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
mql.addEventListener("change", callback)
|
||||
return () => mql.removeEventListener("change", callback)
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return window.innerWidth < MOBILE_BREAKPOINT
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return false
|
||||
}
|
||||
|
||||
export function useIsMobile() {
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
}
|
||||
29
lib/generate-password.ts
Normal file
29
lib/generate-password.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { randomInt } from "crypto"
|
||||
|
||||
const LOWERCASE = "abcdefghijkmnpqrstuvwxyz"
|
||||
const UPPERCASE = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
const DIGITS = "23456789"
|
||||
const SYMBOLS = "!@#$%&*"
|
||||
const ALL_CHARS = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS
|
||||
|
||||
export function generatePassword(length = 14) {
|
||||
const required = [
|
||||
LOWERCASE[randomInt(LOWERCASE.length)],
|
||||
UPPERCASE[randomInt(UPPERCASE.length)],
|
||||
DIGITS[randomInt(DIGITS.length)],
|
||||
SYMBOLS[randomInt(SYMBOLS.length)],
|
||||
]
|
||||
|
||||
const rest = Array.from({ length: length - required.length }, () =>
|
||||
ALL_CHARS[randomInt(ALL_CHARS.length)]
|
||||
)
|
||||
|
||||
const password = [...required, ...rest]
|
||||
|
||||
for (let i = password.length - 1; i > 0; i--) {
|
||||
const j = randomInt(i + 1)
|
||||
;[password[i], password[j]] = [password[j], password[i]]
|
||||
}
|
||||
|
||||
return password.join("")
|
||||
}
|
||||
8
lib/supabase/admin.ts
Normal file
8
lib/supabase/admin.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export function createAdminClient() {
|
||||
return createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SECRET_KEY!
|
||||
)
|
||||
}
|
||||
46
lib/supabase/dal.ts
Normal file
46
lib/supabase/dal.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import "server-only"
|
||||
import { cache } from "react"
|
||||
import { redirect } from "next/navigation"
|
||||
import { createClient } from "@/lib/supabase/server"
|
||||
|
||||
|
||||
export const getSession = cache(async () => {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
return user
|
||||
})
|
||||
|
||||
export const getProfile = cache(async () => {
|
||||
const user = await getSession()
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const supabase = await createClient()
|
||||
const { data } = await supabase
|
||||
.from("profiles")
|
||||
.select("role, name, email, terms_accepted_at")
|
||||
.eq("id", user.id)
|
||||
.single()
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
|
||||
export const verifySession = cache(async () => {
|
||||
const user = await getSession()
|
||||
if (!user) redirect("/")
|
||||
|
||||
const profile = await getProfile();
|
||||
if(!profile) redirect("/")
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
role: profile.role,
|
||||
isLoggedIn: true,
|
||||
isAdmin: profile.role === "admin",
|
||||
isClient: profile.role === "client",
|
||||
hasAcceptedTerms: profile.terms_accepted_at != null,
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
create type user_role as enum ('admin', 'client');
|
||||
|
||||
create table profiles (
|
||||
id uuid primary key references auth.users(id) on delete cascade,
|
||||
email text not null,
|
||||
name text not null,
|
||||
role user_role not null default 'client',
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
alter table profiles enable row level security;
|
||||
|
||||
create policy "Users can read their own profile"
|
||||
on profiles for select
|
||||
using (auth.uid() = id);
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
create or replace function public.handle_new_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
insert into public.profiles (id, email, name, role)
|
||||
values (
|
||||
new.id,
|
||||
new.email,
|
||||
coalesce(new.raw_user_meta_data->>'name', ''),
|
||||
'client'
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute function public.handle_new_user();
|
||||
543
lib/supabase/migrations/20260928092016_create_db_schema.sql
Normal file
543
lib/supabase/migrations/20260928092016_create_db_schema.sql
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
-- =====================================================================
|
||||
-- Client-Portal – Datenbankschema (Supabase / PostgreSQL 15+)
|
||||
-- Voraussetzung: public.profiles existiert bereits
|
||||
-- (id, email, name, role, created_at; id -> auth.users.id)
|
||||
-- Falls du bereits einen Trigger auf auth.users hast, VORHER droppen
|
||||
-- (siehe Abschnitt 3), sonst wird das Profil doppelt angelegt.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 0. Extensions & generische Helfer
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Vault wird für verschlüsselte Passwort-Ablage in Abschnitt 5 gebraucht.
|
||||
create extension if not exists supabase_vault;
|
||||
|
||||
create type public.payment_method as enum ('invoice', 'sepa_debit', 'card');
|
||||
create type public.invoice_status as enum ('pending', 'paid', 'cancelled'); -- "fällig" wird abgeleitet (siehe View)
|
||||
create type public.product_category as enum ('website', 'email', 'seo', 'other');
|
||||
create type public.product_status as enum ('online', 'maintenance', 'offline');
|
||||
create type public.task_status as enum ('in_progress', 'planned', 'done', 'cancelled');
|
||||
|
||||
create or replace function public.set_updated_at()
|
||||
returns trigger language plpgsql set search_path = '' as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1. Clients (Stammdaten)
|
||||
-- ---------------------------------------------------------------------
|
||||
create table public.clients (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
|
||||
-- Firma
|
||||
company_name text not null,
|
||||
legal_form text not null, -- GmbH, UG, GbR, e.K., ...
|
||||
industry text,
|
||||
vat_id text, -- USt-IdNr
|
||||
tax_number text, -- Steuernummer
|
||||
commercial_register_number text, -- optional
|
||||
logo_path text, -- Pfad im Storage-Bucket, keine öffentliche URL
|
||||
|
||||
-- Firmenadresse
|
||||
street text not null,
|
||||
postal_code text not null,
|
||||
city text not null,
|
||||
country char(2) not null default 'DE',
|
||||
|
||||
-- abweichende Rechnungsadresse (alles NULL = identisch mit Firmenadresse)
|
||||
billing_street text,
|
||||
billing_postal_code text,
|
||||
billing_city text,
|
||||
billing_country char(2),
|
||||
|
||||
-- Ansprechpartner
|
||||
contact_name text not null,
|
||||
contact_position text,
|
||||
contact_email text not null,
|
||||
contact_phone text,
|
||||
|
||||
created_by uuid default auth.uid(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
|
||||
constraint billing_address_all_or_nothing check (
|
||||
(billing_street is null and billing_postal_code is null and billing_city is null and billing_country is null)
|
||||
or
|
||||
(billing_street is not null and billing_postal_code is not null and billing_city is not null and billing_country is not null)
|
||||
)
|
||||
);
|
||||
|
||||
-- Zahlungsdaten separat: sensibler, eigene Rechte später einfach möglich
|
||||
create table public.client_payment_details (
|
||||
client_id uuid primary key references public.clients(id) on delete cascade,
|
||||
payment_method public.payment_method not null default 'invoice',
|
||||
account_holder text,
|
||||
iban text,
|
||||
bic text,
|
||||
sepa_mandate_ref text,
|
||||
sepa_mandate_date date,
|
||||
card_customer_id text, -- NUR Token/Customer-ID des Zahlungsanbieters (z. B. Stripe), NIE Kartennummern
|
||||
updated_at timestamptz not null default now(),
|
||||
|
||||
constraint iban_format check (iban is null or iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$'),
|
||||
constraint sepa_needs_iban check (
|
||||
payment_method <> 'sepa_debit' or (iban is not null and account_holder is not null)
|
||||
)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2. profiles erweitern (Login-User -> Client)
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.profiles
|
||||
add column if not exists client_id uuid references public.clients(id) on delete set null,
|
||||
add column if not exists updated_at timestamptz not null default now();
|
||||
|
||||
-- Admins gehören zu keinem Client
|
||||
alter table public.profiles
|
||||
add constraint profiles_admin_has_no_client
|
||||
check ((role = 'admin' and client_id is null) or role = 'client');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 3. Auth-Helfer + Trigger
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function public.is_admin()
|
||||
returns boolean language sql stable security definer set search_path = '' as $$
|
||||
select exists (select 1 from public.profiles where id = auth.uid() and role = 'admin');
|
||||
$$;
|
||||
|
||||
create or replace function public.current_client_id()
|
||||
returns uuid language sql stable security definer set search_path = '' as $$
|
||||
select client_id from public.profiles where id = auth.uid();
|
||||
$$;
|
||||
|
||||
-- Gehärteter Trigger: Rolle ist IMMER 'client'. Admins werden manuell per SQL gesetzt.
|
||||
-- client_id kommt aus raw_app_meta_data (nur per Service-Role/Admin-API setzbar),
|
||||
-- NICHT aus raw_user_meta_data (die kann ein User beim Signup selbst befüllen).
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
insert into public.profiles (id, email, name, role, client_id)
|
||||
values (
|
||||
new.id,
|
||||
new.email,
|
||||
coalesce(new.raw_user_meta_data->>'name', split_part(new.email, '@', 1)),
|
||||
'client',
|
||||
nullif(new.raw_app_meta_data->>'client_id', '')::uuid
|
||||
);
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
-- drop trigger if exists <dein_alter_trigger> on auth.users;
|
||||
drop trigger if exists on_auth_user_created on auth.users;
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute function public.handle_new_user();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4. Rechnungen
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Produkt-Vorlagen für Rechnungspositionen (nur Admin)
|
||||
create table public.product_templates (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
description text,
|
||||
unit_price numeric(12,2) not null check (unit_price >= 0), -- netto
|
||||
tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100),
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Lückenloser Nummernkreis pro Jahr (nur über next_invoice_number() nutzbar)
|
||||
create table public.invoice_counters (
|
||||
year int primary key,
|
||||
last_number int not null default 0
|
||||
);
|
||||
alter table public.invoice_counters enable row level security; -- bewusst keine Policies
|
||||
|
||||
create or replace function public.next_invoice_number()
|
||||
returns text language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
y int := extract(year from current_date)::int;
|
||||
n int;
|
||||
begin
|
||||
if not public.is_admin() then
|
||||
raise exception 'forbidden' using errcode = '42501';
|
||||
end if;
|
||||
insert into public.invoice_counters as c (year, last_number)
|
||||
values (y, 1)
|
||||
on conflict (year) do update set last_number = c.last_number + 1
|
||||
returning c.last_number into n;
|
||||
return format('RE-%s-%s', y, lpad(n::text, 4, '0'));
|
||||
end $$;
|
||||
|
||||
create table public.invoices (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
client_id uuid not null references public.clients(id) on delete restrict,
|
||||
invoice_number text not null unique default public.next_invoice_number(),
|
||||
invoice_date date not null default current_date,
|
||||
due_date date not null,
|
||||
status public.invoice_status not null default 'pending',
|
||||
paid_at date,
|
||||
|
||||
-- Snapshot der Kundendaten zum Rechnungszeitpunkt (werden per Trigger befüllt)
|
||||
company_name text not null,
|
||||
vat_id text,
|
||||
billing_street text not null,
|
||||
billing_postal_code text not null,
|
||||
billing_city text not null,
|
||||
billing_country char(2) not null,
|
||||
|
||||
notes text,
|
||||
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
|
||||
constraint due_after_invoice_date check (due_date >= invoice_date),
|
||||
constraint paid_has_date check (status <> 'paid' or paid_at is not null)
|
||||
);
|
||||
|
||||
create or replace function public.fill_invoice_snapshot()
|
||||
returns trigger language plpgsql set search_path = '' as $$
|
||||
declare c public.clients%rowtype;
|
||||
begin
|
||||
select * into c from public.clients where id = new.client_id;
|
||||
new.company_name := coalesce(new.company_name, c.company_name);
|
||||
new.vat_id := coalesce(new.vat_id, c.vat_id);
|
||||
new.billing_street := coalesce(new.billing_street, c.billing_street, c.street);
|
||||
new.billing_postal_code := coalesce(new.billing_postal_code, c.billing_postal_code, c.postal_code);
|
||||
new.billing_city := coalesce(new.billing_city, c.billing_city, c.city);
|
||||
new.billing_country := coalesce(new.billing_country, c.billing_country, c.country);
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
create trigger fill_invoice_snapshot
|
||||
before insert on public.invoices
|
||||
for each row execute function public.fill_invoice_snapshot();
|
||||
|
||||
create table public.invoice_items (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
invoice_id uuid not null references public.invoices(id) on delete cascade,
|
||||
position int not null,
|
||||
product_template_id uuid references public.product_templates(id) on delete set null,
|
||||
description text not null, -- Produktname (Snapshot aus Vorlage)
|
||||
quantity numeric(10,2) not null default 1 check (quantity > 0),
|
||||
unit_price numeric(12,2) not null, -- netto
|
||||
tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100),
|
||||
unique (invoice_id, position)
|
||||
);
|
||||
|
||||
-- Summen + abgeleiteter Status "Fällig" (kein Cronjob nötig)
|
||||
create view public.invoices_overview with (security_invoker = true) as
|
||||
select
|
||||
i.*,
|
||||
coalesce(t.net_total, 0) as net_total,
|
||||
coalesce(t.tax_total, 0) as tax_total,
|
||||
coalesce(t.net_total, 0) + coalesce(t.tax_total, 0) as gross_total,
|
||||
case when i.status = 'pending' and i.due_date < current_date
|
||||
then 'overdue' else i.status::text end as display_status
|
||||
from public.invoices i
|
||||
left join lateral (
|
||||
select sum(round(quantity * unit_price, 2)) as net_total,
|
||||
sum(round(quantity * unit_price * tax_rate / 100, 2)) as tax_total
|
||||
from public.invoice_items ii
|
||||
where ii.invoice_id = i.id
|
||||
) t on true;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5. Passwörter (Supabase Vault = verschlüsselt at rest)
|
||||
-- In der Tabelle liegt NIE das Passwort, nur die Vault-Referenz.
|
||||
-- ---------------------------------------------------------------------
|
||||
create table public.credentials (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
client_id uuid not null references public.clients(id) on delete cascade,
|
||||
service text not null, -- Vercel, Hostinger, ...
|
||||
username text not null, -- E-Mail / Username
|
||||
vault_secret_id uuid not null, -- -> vault.secrets
|
||||
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||
updated_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.credential_access_log (
|
||||
id bigint generated always as identity primary key,
|
||||
credential_id uuid not null, -- bewusst ohne FK: Log bleibt bei Löschung erhalten
|
||||
user_id uuid,
|
||||
action text not null default 'reveal',
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create or replace function public.create_credential(
|
||||
p_client_id uuid, p_service text, p_username text, p_password text
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare v_secret uuid; v_id uuid;
|
||||
begin
|
||||
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
|
||||
v_secret := vault.create_secret(p_password, 'cred_' || gen_random_uuid()::text, p_service);
|
||||
insert into public.credentials (client_id, service, username, vault_secret_id, updated_by)
|
||||
values (p_client_id, p_service, p_username, v_secret, auth.uid())
|
||||
returning id into v_id;
|
||||
return v_id;
|
||||
end $$;
|
||||
|
||||
-- p_password = NULL -> Passwort bleibt unverändert
|
||||
create or replace function public.update_credential(
|
||||
p_id uuid, p_service text, p_username text, p_password text default null
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
declare v_secret uuid;
|
||||
begin
|
||||
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
|
||||
update public.credentials
|
||||
set service = p_service, username = p_username, updated_by = auth.uid()
|
||||
where id = p_id
|
||||
returning vault_secret_id into v_secret;
|
||||
if not found then raise exception 'credential not found'; end if;
|
||||
if p_password is not null and p_password <> '' then
|
||||
perform vault.update_secret(v_secret, p_password);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- Klartext nur über diese Funktion, jeder Abruf wird protokolliert.
|
||||
-- Erlaubt: Admins + der zugehörige Client. (Client-Zugriff entfernen: "or v_client = ..." löschen)
|
||||
create or replace function public.reveal_credential(p_id uuid)
|
||||
returns text language plpgsql security definer set search_path = '' as $$
|
||||
declare v_client uuid; v_secret uuid; v_pw text;
|
||||
begin
|
||||
select client_id, vault_secret_id into v_client, v_secret
|
||||
from public.credentials where id = p_id;
|
||||
if not found then raise exception 'credential not found'; end if;
|
||||
if not (public.is_admin() or v_client = public.current_client_id()) then
|
||||
raise exception 'forbidden' using errcode = '42501';
|
||||
end if;
|
||||
select decrypted_secret into v_pw from vault.decrypted_secrets where id = v_secret;
|
||||
insert into public.credential_access_log (credential_id, user_id) values (p_id, auth.uid());
|
||||
return v_pw;
|
||||
end $$;
|
||||
|
||||
-- Vault-Eintrag aufräumen, wenn Credential (oder ganzer Client) gelöscht wird
|
||||
create or replace function public.delete_vault_secret()
|
||||
returns trigger language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
delete from vault.secrets where id = old.vault_secret_id;
|
||||
return old;
|
||||
end $$;
|
||||
|
||||
create trigger credentials_delete_vault_secret
|
||||
after delete on public.credentials
|
||||
for each row execute function public.delete_vault_secret();
|
||||
|
||||
revoke execute on function public.create_credential(uuid, text, text, text) from public, anon;
|
||||
revoke execute on function public.update_credential(uuid, text, text, text) from public, anon;
|
||||
revoke execute on function public.reveal_credential(uuid) from public, anon;
|
||||
revoke execute on function public.next_invoice_number() from public, anon;
|
||||
grant execute on function public.create_credential(uuid, text, text, text) to authenticated;
|
||||
grant execute on function public.update_credential(uuid, text, text, text) to authenticated;
|
||||
grant execute on function public.reveal_credential(uuid) to authenticated;
|
||||
grant execute on function public.next_invoice_number() to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 6. Dokumente (Metadaten in DB, Dateien in Storage)
|
||||
-- ---------------------------------------------------------------------
|
||||
create table public.folders (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
client_id uuid not null references public.clients(id) on delete cascade,
|
||||
parent_id uuid, -- NULL = oberste Ebene
|
||||
name text not null,
|
||||
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||
created_at timestamptz not null default now(),
|
||||
unique (id, client_id),
|
||||
-- Ordner nur löschbar, wenn leer (kein ON DELETE-Verhalten = NO ACTION)
|
||||
foreign key (parent_id, client_id) references public.folders (id, client_id),
|
||||
unique nulls not distinct (client_id, parent_id, name)
|
||||
);
|
||||
|
||||
create table public.documents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
client_id uuid not null references public.clients(id) on delete cascade,
|
||||
folder_id uuid, -- NULL = außerhalb von Ordnern
|
||||
name text not null, -- Originaldateiname
|
||||
storage_path text not null unique, -- {client_id}/{document_id}/{dateiname}
|
||||
mime_type text,
|
||||
size_bytes bigint check (size_bytes >= 0),
|
||||
uploaded_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||
created_at timestamptz not null default now(),
|
||||
foreign key (folder_id, client_id) references public.folders (id, client_id)
|
||||
);
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('client-documents', 'client-documents', false)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
create policy "client_documents_select" on storage.objects for select to authenticated
|
||||
using (bucket_id = 'client-documents'
|
||||
and ((select public.is_admin())
|
||||
or (storage.foldername(name))[1] = (select public.current_client_id())::text));
|
||||
|
||||
create policy "client_documents_insert" on storage.objects for insert to authenticated
|
||||
with check (bucket_id = 'client-documents'
|
||||
and ((select public.is_admin())
|
||||
or (storage.foldername(name))[1] = (select public.current_client_id())::text));
|
||||
|
||||
create policy "client_documents_delete" on storage.objects for delete to authenticated
|
||||
using (bucket_id = 'client-documents' and (select public.is_admin()));
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 7. Info: Produkte, Vorgänge, interne Notizen
|
||||
-- ---------------------------------------------------------------------
|
||||
create table public.client_products (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
client_id uuid not null references public.clients(id) on delete cascade,
|
||||
name text not null,
|
||||
description text,
|
||||
category public.product_category not null default 'website',
|
||||
url text,
|
||||
status public.product_status not null default 'online',
|
||||
uptime_30d numeric(5,2) check (uptime_30d between 0 and 100), -- z. B. 99.98 (aus Monitoring-Tool)
|
||||
uptime_updated_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (id, client_id)
|
||||
);
|
||||
|
||||
create table public.product_tasks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
client_id uuid not null references public.clients(id) on delete cascade,
|
||||
product_id uuid not null,
|
||||
name text not null,
|
||||
description text,
|
||||
status public.task_status not null default 'planned',
|
||||
since date not null default current_date, -- "seit 3 Tagen" (in_progress) bzw. "ab 6. Okt." (planned)
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (id, client_id),
|
||||
foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade
|
||||
);
|
||||
|
||||
-- Statuswechsel setzt "seit" auf heute, außer der Admin setzt das Datum im selben Update explizit
|
||||
create or replace function public.reset_task_since()
|
||||
returns trigger language plpgsql set search_path = '' as $$
|
||||
begin
|
||||
if new.status is distinct from old.status and new.since is not distinct from old.since then
|
||||
new.since := current_date;
|
||||
end if;
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
create trigger reset_task_since
|
||||
before update on public.product_tasks
|
||||
for each row execute function public.reset_task_since();
|
||||
|
||||
-- Eigene Tabelle, damit Clients Notizen nie sehen (RLS arbeitet zeilen-, nicht spaltenweise)
|
||||
create table public.internal_notes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
client_id uuid not null references public.clients(id) on delete cascade,
|
||||
product_id uuid,
|
||||
task_id uuid,
|
||||
body text not null,
|
||||
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||
created_at timestamptz not null default now(),
|
||||
foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade,
|
||||
foreign key (task_id, client_id) references public.product_tasks (id, client_id) on delete cascade
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 8. Indizes & updated_at-Trigger
|
||||
-- ---------------------------------------------------------------------
|
||||
create index on public.profiles (client_id);
|
||||
create index on public.invoices (client_id, invoice_date desc);
|
||||
create index on public.invoice_items (invoice_id);
|
||||
create index on public.credentials (client_id);
|
||||
create index on public.folders (client_id, parent_id);
|
||||
create index on public.documents (client_id, folder_id);
|
||||
create index on public.client_products (client_id);
|
||||
create index on public.product_tasks (product_id);
|
||||
create index on public.product_tasks (client_id);
|
||||
create index on public.internal_notes (client_id);
|
||||
|
||||
do $$
|
||||
declare t text;
|
||||
begin
|
||||
foreach t in array array['profiles','clients','client_payment_details','product_templates',
|
||||
'invoices','credentials','client_products','product_tasks'] loop
|
||||
execute format('create trigger set_updated_at before update on public.%I
|
||||
for each row execute function public.set_updated_at()', t);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 9. Row Level Security
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Ohne Policy = kein Zugriff. Alles nur für eingeloggte User (nie anon).
|
||||
|
||||
-- 9a) Admin darf alles auf diesen Tabellen
|
||||
do $$
|
||||
declare t text;
|
||||
begin
|
||||
foreach t in array array['clients','client_payment_details','product_templates','invoice_items',
|
||||
'folders','documents','client_products','product_tasks','internal_notes'] loop
|
||||
execute format('alter table public.%I enable row level security', t);
|
||||
execute format('create policy admin_all on public.%I for all to authenticated
|
||||
using ((select public.is_admin())) with check ((select public.is_admin()))', t);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- 9b) profiles: lesen = eigenes Profil oder Admin; ändern = nur Admin (Rolle nicht selbst änderbar)
|
||||
alter table public.profiles enable row level security;
|
||||
create policy profiles_select on public.profiles for select to authenticated
|
||||
using (id = (select auth.uid()) or (select public.is_admin()));
|
||||
create policy profiles_admin_update on public.profiles for update to authenticated
|
||||
using ((select public.is_admin())) with check ((select public.is_admin()));
|
||||
|
||||
-- 9c) Rechnungen: Admin darf lesen/anlegen/ändern, aber NICHT hart löschen (-> Status 'cancelled' = Storno)
|
||||
alter table public.invoices enable row level security;
|
||||
create policy admin_select on public.invoices for select to authenticated using ((select public.is_admin()));
|
||||
create policy admin_insert on public.invoices for insert to authenticated with check ((select public.is_admin()));
|
||||
create policy admin_update on public.invoices for update to authenticated
|
||||
using ((select public.is_admin())) with check ((select public.is_admin()));
|
||||
|
||||
-- 9d) Passwörter: Schreiben nur über die Funktionen (Abschnitt 5), Löschen nur Admin
|
||||
alter table public.credentials enable row level security;
|
||||
create policy credentials_select on public.credentials for select to authenticated
|
||||
using ((select public.is_admin()) or client_id = (select public.current_client_id()));
|
||||
create policy credentials_admin_delete on public.credentials for delete to authenticated
|
||||
using ((select public.is_admin()));
|
||||
|
||||
alter table public.credential_access_log enable row level security;
|
||||
create policy access_log_admin_select on public.credential_access_log for select to authenticated
|
||||
using ((select public.is_admin()));
|
||||
|
||||
-- 9e) Clients sehen nur ihre eigenen Daten
|
||||
create policy client_select on public.clients for select to authenticated
|
||||
using (id = (select public.current_client_id()));
|
||||
|
||||
create policy client_select on public.client_payment_details for select to authenticated
|
||||
using (client_id = (select public.current_client_id()));
|
||||
|
||||
create policy client_select on public.invoices for select to authenticated
|
||||
using (client_id = (select public.current_client_id()) and status <> 'cancelled');
|
||||
|
||||
create policy client_select on public.invoice_items for select to authenticated
|
||||
using (exists (select 1 from public.invoices i
|
||||
where i.id = invoice_id
|
||||
and i.client_id = (select public.current_client_id())
|
||||
and i.status <> 'cancelled'));
|
||||
|
||||
create policy client_select on public.folders for select to authenticated
|
||||
using (client_id = (select public.current_client_id()));
|
||||
|
||||
create policy client_select on public.documents for select to authenticated
|
||||
using (client_id = (select public.current_client_id()));
|
||||
|
||||
create policy client_insert on public.documents for insert to authenticated
|
||||
with check (client_id = (select public.current_client_id()) and uploaded_by = (select auth.uid()));
|
||||
|
||||
create policy client_select on public.client_products for select to authenticated
|
||||
using (client_id = (select public.current_client_id()));
|
||||
|
||||
create policy client_select on public.product_tasks for select to authenticated
|
||||
using (client_id = (select public.current_client_id()));
|
||||
|
||||
-- product_templates, internal_notes: für Clients bewusst keine Policy = unsichtbar
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
alter table public.profiles
|
||||
add column if not exists terms_accepted_at timestamptz;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
-- Legt einen neuen clients-Datensatz an und verknüpft ihn atomar mit profiles.client_id.
|
||||
-- Der "where client_id is null"-Guard verhindert, dass zwei parallele Submits
|
||||
-- jeweils einen eigenen clients-Datensatz erzeugen und verwaisen lassen.
|
||||
create or replace function public.create_client_and_link_profile(
|
||||
p_profile_id uuid,
|
||||
p_company_name text,
|
||||
p_legal_form text,
|
||||
p_industry text,
|
||||
p_vat_id text,
|
||||
p_tax_number text,
|
||||
p_commercial_register_number text,
|
||||
p_street text,
|
||||
p_postal_code text,
|
||||
p_city text,
|
||||
p_country char(2),
|
||||
p_billing_street text,
|
||||
p_billing_postal_code text,
|
||||
p_billing_city text,
|
||||
p_billing_country char(2),
|
||||
p_contact_name text,
|
||||
p_contact_position text,
|
||||
p_contact_email text,
|
||||
p_contact_phone text
|
||||
) returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_client_id uuid;
|
||||
v_updated_rows int;
|
||||
begin
|
||||
insert into public.clients (
|
||||
company_name, legal_form, industry, vat_id, tax_number, commercial_register_number,
|
||||
street, postal_code, city, country,
|
||||
billing_street, billing_postal_code, billing_city, billing_country,
|
||||
contact_name, contact_position, contact_email, contact_phone
|
||||
) values (
|
||||
p_company_name, p_legal_form, p_industry, p_vat_id, p_tax_number, p_commercial_register_number,
|
||||
p_street, p_postal_code, p_city, p_country,
|
||||
p_billing_street, p_billing_postal_code, p_billing_city, p_billing_country,
|
||||
p_contact_name, p_contact_position, p_contact_email, p_contact_phone
|
||||
)
|
||||
returning id into v_client_id;
|
||||
|
||||
update public.profiles
|
||||
set client_id = v_client_id
|
||||
where id = p_profile_id
|
||||
and client_id is null;
|
||||
|
||||
get diagnostics v_updated_rows = row_count;
|
||||
|
||||
if v_updated_rows = 0 then
|
||||
raise exception 'profile already linked to a client' using errcode = '23505';
|
||||
end if;
|
||||
|
||||
return v_client_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke execute on function public.create_client_and_link_profile(
|
||||
uuid, text, text, text, text, text, text, text, text, text, char(2),
|
||||
text, text, text, char(2), text, text, text, text
|
||||
) from public, anon;
|
||||
|
||||
grant execute on function public.create_client_and_link_profile(
|
||||
uuid, text, text, text, text, text, text, text, text, text, char(2),
|
||||
text, text, text, char(2), text, text, text, text
|
||||
) to authenticated;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
-- Clients dürfen eigene Credentials anlegen und bearbeiten (nicht löschen).
|
||||
-- Löschen bleibt exklusiv Admin (siehe RLS-Policy credentials_admin_delete).
|
||||
|
||||
create or replace function public.create_credential(
|
||||
p_client_id uuid, p_service text, p_username text, p_password text
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare v_secret uuid; v_id uuid;
|
||||
begin
|
||||
if not (public.is_admin() or p_client_id = public.current_client_id()) then
|
||||
raise exception 'forbidden' using errcode = '42501';
|
||||
end if;
|
||||
v_secret := vault.create_secret(p_password, 'cred_' || gen_random_uuid()::text, p_service);
|
||||
insert into public.credentials (client_id, service, username, vault_secret_id, updated_by)
|
||||
values (p_client_id, p_service, p_username, v_secret, auth.uid())
|
||||
returning id into v_id;
|
||||
return v_id;
|
||||
end $$;
|
||||
|
||||
-- p_password = NULL -> Passwort bleibt unverändert
|
||||
create or replace function public.update_credential(
|
||||
p_id uuid, p_service text, p_username text, p_password text default null
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
declare v_secret uuid; v_client uuid;
|
||||
begin
|
||||
select client_id, vault_secret_id into v_client, v_secret
|
||||
from public.credentials where id = p_id;
|
||||
if not found then raise exception 'credential not found'; end if;
|
||||
if not (public.is_admin() or v_client = public.current_client_id()) then
|
||||
raise exception 'forbidden' using errcode = '42501';
|
||||
end if;
|
||||
|
||||
update public.credentials
|
||||
set service = p_service, username = p_username, updated_by = auth.uid()
|
||||
where id = p_id;
|
||||
|
||||
if p_password is not null and p_password <> '' then
|
||||
perform vault.update_secret(v_secret, p_password);
|
||||
end if;
|
||||
end $$;
|
||||
73
lib/supabase/migrations/20260929150000_invoice_creation.sql
Normal file
73
lib/supabase/migrations/20260929150000_invoice_creation.sql
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
-- Zeigt die nächste Rechnungsnummer an, ohne den Zähler zu erhöhen (für Vorbelegung im Dialog).
|
||||
-- Die echte Nummer vergibt weiterhin next_invoice_number() beim Insert.
|
||||
create or replace function public.peek_next_invoice_number()
|
||||
returns text language plpgsql stable security definer set search_path = '' as $$
|
||||
declare
|
||||
y int := extract(year from current_date)::int;
|
||||
n int;
|
||||
begin
|
||||
if not public.is_admin() then
|
||||
raise exception 'forbidden' using errcode = '42501';
|
||||
end if;
|
||||
select coalesce(last_number, 0) + 1 into n from public.invoice_counters where year = y;
|
||||
return format('RE-%s-%s', y, lpad(coalesce(n, 1)::text, 4, '0'));
|
||||
end $$;
|
||||
|
||||
-- Rechnung + Positionen in einer Transaktion anlegen, damit bei Fehlern
|
||||
-- keine Rechnung ohne Positionen (und keine verbrauchte Nummer) übrig bleibt.
|
||||
-- security invoker: RLS (admin_insert / admin_all) greift.
|
||||
create or replace function public.create_invoice(
|
||||
p_client_id uuid,
|
||||
p_invoice_date date,
|
||||
p_due_date date,
|
||||
p_company_name text,
|
||||
p_billing_street text,
|
||||
p_billing_postal_code text,
|
||||
p_billing_city text,
|
||||
p_billing_country char(2),
|
||||
p_items jsonb
|
||||
) returns uuid
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_invoice_id uuid;
|
||||
begin
|
||||
if not public.is_admin() then
|
||||
raise exception 'forbidden' using errcode = '42501';
|
||||
end if;
|
||||
|
||||
if p_items is null or jsonb_array_length(p_items) = 0 then
|
||||
raise exception 'invoice needs at least one item' using errcode = '22023';
|
||||
end if;
|
||||
|
||||
insert into public.invoices (
|
||||
client_id, invoice_date, due_date,
|
||||
company_name, billing_street, billing_postal_code, billing_city, billing_country
|
||||
) values (
|
||||
p_client_id, p_invoice_date, p_due_date,
|
||||
p_company_name, p_billing_street, p_billing_postal_code, p_billing_city, p_billing_country
|
||||
)
|
||||
returning id into v_invoice_id;
|
||||
|
||||
insert into public.invoice_items (
|
||||
invoice_id, position, product_template_id, description, quantity, unit_price, tax_rate
|
||||
)
|
||||
select
|
||||
v_invoice_id,
|
||||
item.ordinality::int,
|
||||
nullif(item.value->>'product_template_id', '')::uuid,
|
||||
item.value->>'description',
|
||||
(item.value->>'quantity')::numeric,
|
||||
(item.value->>'unit_price')::numeric,
|
||||
(item.value->>'tax_rate')::numeric
|
||||
from jsonb_array_elements(p_items) with ordinality as item(value, ordinality);
|
||||
|
||||
return v_invoice_id;
|
||||
end $$;
|
||||
|
||||
revoke execute on function public.peek_next_invoice_number() from public, anon;
|
||||
revoke execute on function public.create_invoice(uuid, date, date, text, text, text, text, char(2), jsonb) from public, anon;
|
||||
grant execute on function public.peek_next_invoice_number() to authenticated;
|
||||
grant execute on function public.create_invoice(uuid, date, date, text, text, text, text, char(2), jsonb) to authenticated;
|
||||
37
lib/supabase/migrations/20260930100000_info_section.sql
Normal file
37
lib/supabase/migrations/20260930100000_info_section.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- Info-Sektion: Produktstatus, laufende Vorgänge, interne Notizen
|
||||
|
||||
-- Hosting als eigene Kategorie (neben Webseite, E-Mail, SEO)
|
||||
alter type public.product_category add value if not exists 'hosting';
|
||||
|
||||
-- Freitext zum Status, z. B. "Erreichbar, keine Störungen" oder "Wartung geplant am 12.10."
|
||||
alter table public.client_products
|
||||
add column if not exists status_message text;
|
||||
|
||||
-- Vorgänge müssen nicht zwingend an ein Produkt gebunden sein
|
||||
alter table public.product_tasks
|
||||
alter column product_id drop not null;
|
||||
|
||||
-- "zuletzt bearbeitet von ..." für interne Notizen
|
||||
alter table public.internal_notes
|
||||
add column if not exists updated_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||
add column if not exists updated_at timestamptz not null default now();
|
||||
|
||||
update public.internal_notes
|
||||
set updated_by = created_by, updated_at = created_at
|
||||
where updated_by is null;
|
||||
|
||||
create or replace function public.set_updated_by()
|
||||
returns trigger language plpgsql set search_path = '' as $$
|
||||
begin
|
||||
new.updated_by = auth.uid();
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
create trigger set_updated_at before update on public.internal_notes
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
create trigger set_updated_by before update on public.internal_notes
|
||||
for each row execute function public.set_updated_by();
|
||||
|
||||
create index if not exists internal_notes_client_updated_idx
|
||||
on public.internal_notes (client_id, updated_at desc);
|
||||
51
lib/supabase/migrations/20260930120000_product_checks.sql
Normal file
51
lib/supabase/migrations/20260930120000_product_checks.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- Erreichbarkeitsprüfungen für Produkte (manuell per Button + beim Seitenaufruf)
|
||||
create table public.product_checks (
|
||||
id bigint generated always as identity primary key,
|
||||
product_id uuid not null references public.client_products(id) on delete cascade,
|
||||
client_id uuid not null references public.clients(id) on delete cascade,
|
||||
checked_at timestamptz not null default now(),
|
||||
ok boolean not null,
|
||||
status_code int,
|
||||
response_ms int,
|
||||
error text
|
||||
);
|
||||
|
||||
create index on public.product_checks (product_id, checked_at desc);
|
||||
|
||||
-- Lesen: Admin alles, Client nur eigene. Schreiben bewusst ohne Policy:
|
||||
-- Ergebnisse schreibt nur der Server (Service Role), damit Clients ihre Uptime nicht fälschen können.
|
||||
alter table public.product_checks enable row level security;
|
||||
|
||||
create policy admin_select on public.product_checks for select to authenticated
|
||||
using ((select public.is_admin()));
|
||||
|
||||
create policy client_select on public.product_checks for select to authenticated
|
||||
using (client_id = (select public.current_client_id()));
|
||||
|
||||
-- Letzte Prüfung + Uptime der letzten 30 Tage je Produkt
|
||||
create view public.product_check_summary with (security_invoker = true) as
|
||||
select
|
||||
p.id as product_id,
|
||||
last.checked_at,
|
||||
last.ok,
|
||||
last.status_code,
|
||||
last.response_ms,
|
||||
last.error,
|
||||
stats.checks_30d,
|
||||
stats.uptime_30d
|
||||
from public.client_products p
|
||||
left join lateral (
|
||||
select c.checked_at, c.ok, c.status_code, c.response_ms, c.error
|
||||
from public.product_checks c
|
||||
where c.product_id = p.id
|
||||
order by c.checked_at desc
|
||||
limit 1
|
||||
) last on true
|
||||
left join lateral (
|
||||
select
|
||||
count(*)::int as checks_30d,
|
||||
round(100.0 * count(*) filter (where c.ok) / nullif(count(*), 0), 2) as uptime_30d
|
||||
from public.product_checks c
|
||||
where c.product_id = p.id
|
||||
and c.checked_at > now() - interval '30 days'
|
||||
) stats on true;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-- Dokumente: Upload-Grenzen und Absicherung des Storage-Pfads
|
||||
|
||||
-- Ein Dokument darf nur auf eine Datei im Storage-Ordner des eigenen Clients zeigen.
|
||||
-- Sonst könnte ein Client einen Datensatz auf den Pfad eines anderen Clients anlegen.
|
||||
alter table public.documents
|
||||
add constraint documents_storage_path_matches_client
|
||||
check (split_part(storage_path, '/', 1) = client_id::text);
|
||||
|
||||
-- Max. 50 MB pro Datei (Supabase-Standardlimit im Free-Plan)
|
||||
update storage.buckets
|
||||
set file_size_limit = 52428800
|
||||
where id = 'client-documents';
|
||||
37
lib/supabase/proxy.ts
Normal file
37
lib/supabase/proxy.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { createServerClient } from "@supabase/ssr"
|
||||
import { NextResponse, type NextRequest } from "next/server"
|
||||
|
||||
export function createClient(request: NextRequest) {
|
||||
let response = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
})
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value)
|
||||
)
|
||||
response = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
})
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return { supabase, response }
|
||||
}
|
||||
|
|
@ -1,8 +1,28 @@
|
|||
import { createClient } from "@supabase/supabase-js";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export function createAdminClient() {
|
||||
return createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SECRET_KEY!
|
||||
)
|
||||
}
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// Called from a Server Component that can't set cookies.
|
||||
// Safe to ignore because the proxy refreshes the session.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
56
modules/admin/actions/client.ts
Normal file
56
modules/admin/actions/client.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use server";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { generatePassword } from "@/lib/generate-password";
|
||||
import * as z from "zod";
|
||||
|
||||
|
||||
|
||||
const CreateClientSchema = z.object({
|
||||
name: z.string().min(3, {error: "Mindestens drei Zeichen eingeben"}),
|
||||
email: z.email({ error: "Bitte eine gültige Email eingeben." })
|
||||
})
|
||||
|
||||
export type CreateClientState =
|
||||
| { error: string }
|
||||
| { success: true; email: string; password: string }
|
||||
| undefined;
|
||||
|
||||
export async function createClient(
|
||||
_prevState: CreateClientState,
|
||||
formData: FormData
|
||||
): Promise<CreateClientState> {
|
||||
|
||||
const session = await verifySession();
|
||||
if (!session.isAdmin) {
|
||||
return { error: "Keine Berechtigung." }
|
||||
}
|
||||
|
||||
const validatedFields = CreateClientSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
email: formData.get("email")
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||
const message = fieldErrors.email?.[0] ?? fieldErrors.name?.[0] ?? "Bitte Name und Email ausfüllen.";
|
||||
return { error: message }
|
||||
}
|
||||
|
||||
const { name, email } = validatedFields.data;
|
||||
const password = generatePassword();
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { error } = await supabase.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { name },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { error: error.code === "email_exists" ? "Diese Email ist bereits vergeben." : "Client konnte nicht angelegt werden." }
|
||||
}
|
||||
|
||||
return { success: true, email, password }
|
||||
}
|
||||
90
modules/admin/actions/manage-client.ts
Normal file
90
modules/admin/actions/manage-client.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { generatePassword } from "@/lib/generate-password";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||
|
||||
export type ResetPasswordState = { error: string } | { success: true; password: string } | undefined;
|
||||
export type UpdateClientState = { error: string } | { success: true } | undefined;
|
||||
|
||||
const IdSchema = z.string().uuid();
|
||||
|
||||
// Nur Admins, und nur Konten mit Rolle "client" – Admin-Konten lassen sich hierüber nicht ändern.
|
||||
async function assertClientTarget(profileId: string) {
|
||||
const session = await verifySession();
|
||||
if (!session.isAdmin) return { error: "Keine Berechtigung." };
|
||||
if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data } = await supabase.from("profiles").select("role").eq("id", profileId).maybeSingle();
|
||||
if (data?.role !== "client") return { error: "Client nicht gefunden." };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const ResetSchema = z.discriminatedUnion("mode", [
|
||||
z.object({ mode: z.literal("generate") }),
|
||||
z.object({
|
||||
mode: z.literal("manual"),
|
||||
password: z.string().min(MIN_PASSWORD_LENGTH, { error: `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.` }).max(72),
|
||||
}),
|
||||
]);
|
||||
|
||||
export async function resetClientPassword(_prev: ResetPasswordState, formData: FormData): Promise<ResetPasswordState> {
|
||||
const profileId = String(formData.get("profileId") ?? "");
|
||||
const denied = await assertClientTarget(profileId);
|
||||
if (denied) return denied;
|
||||
|
||||
const parsed = ResetSchema.safeParse({
|
||||
mode: formData.get("mode"),
|
||||
password: formData.get("password") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
|
||||
const password = parsed.data.mode === "generate" ? generatePassword() : parsed.data.password;
|
||||
|
||||
const admin = createAdminClient();
|
||||
const { error } = await admin.auth.admin.updateUserById(profileId, { password });
|
||||
if (error) return { error: "Passwort konnte nicht geändert werden." };
|
||||
|
||||
return { success: true, password };
|
||||
}
|
||||
|
||||
const UpdateSchema = z.object({
|
||||
name: z.string().trim().min(3, { error: "Mindestens drei Zeichen eingeben." }),
|
||||
email: z.email({ error: "Bitte eine gültige Email eingeben." }),
|
||||
});
|
||||
|
||||
export async function updateClientAccount(_prev: UpdateClientState, formData: FormData): Promise<UpdateClientState> {
|
||||
const profileId = String(formData.get("profileId") ?? "");
|
||||
const denied = await assertClientTarget(profileId);
|
||||
if (denied) return denied;
|
||||
|
||||
const parsed = UpdateSchema.safeParse({ name: formData.get("name"), email: formData.get("email") });
|
||||
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
|
||||
// Login-Email lebt in auth.users; ohne email_confirm würde Supabase erst eine Bestätigungsmail verlangen.
|
||||
const admin = createAdminClient();
|
||||
const { error: authError } = await admin.auth.admin.updateUserById(profileId, {
|
||||
email,
|
||||
email_confirm: true,
|
||||
user_metadata: { name: parsed.data.name },
|
||||
});
|
||||
if (authError) {
|
||||
return { error: authError.code === "email_exists" ? "Diese Email ist bereits vergeben." : "Client konnte nicht aktualisiert werden." };
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from("profiles").update({ name: parsed.data.name, email }).eq("id", profileId);
|
||||
if (error) return { error: "Login-Email wurde geändert, das Profil aber nicht. Bitte erneut speichern." };
|
||||
|
||||
revalidatePath("/admin/clients");
|
||||
revalidatePath(`/admin/clients/${profileId}`, "layout");
|
||||
return { success: true };
|
||||
}
|
||||
155
modules/admin/actions/settings.ts
Normal file
155
modules/admin/actions/settings.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||
import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal";
|
||||
|
||||
export type SettingsState = { error: string } | { success: true; message?: string } | undefined;
|
||||
|
||||
const IdSchema = z.string().uuid();
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await verifySession();
|
||||
return session.isAdmin ? session : null;
|
||||
}
|
||||
|
||||
function firstIssue(error: z.ZodError) {
|
||||
return error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||
}
|
||||
|
||||
export async function updateOwnProfile(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return { error: "Keine Berechtigung." };
|
||||
|
||||
const name = z.string().trim().min(2, { error: "Mindestens zwei Zeichen eingeben." }).max(100).safeParse(formData.get("name"));
|
||||
if (!name.success) return { error: firstIssue(name.error) };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from("profiles").update({ name: name.data }).eq("id", session.userId);
|
||||
if (error) return { error: "Name konnte nicht gespeichert werden." };
|
||||
|
||||
const { error: authError } = await supabase.auth.updateUser({ data: { name: name.data } });
|
||||
|
||||
revalidatePath("/admin", "layout");
|
||||
if (authError) return { error: "Name konnte nicht vollständig gespeichert werden. Bitte erneut speichern." };
|
||||
return { success: true, message: "Name gespeichert." };
|
||||
}
|
||||
|
||||
const PasswordSchema = z
|
||||
.object({
|
||||
current: z.string().min(1, { error: "Bitte das aktuelle Passwort eingeben." }),
|
||||
next: z.string().min(MIN_PASSWORD_LENGTH, { error: `Das neue Passwort braucht mindestens ${MIN_PASSWORD_LENGTH} Zeichen.` }).max(72),
|
||||
confirm: z.string(),
|
||||
})
|
||||
.refine((data) => data.next === data.confirm, { path: ["confirm"], error: "Die Passwörter stimmen nicht überein." })
|
||||
.refine((data) => data.next !== data.current, { path: ["next"], error: "Das neue Passwort muss sich vom alten unterscheiden." });
|
||||
|
||||
export async function changeOwnPassword(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return { error: "Keine Berechtigung." };
|
||||
|
||||
const parsed = PasswordSchema.safeParse({
|
||||
current: formData.get("current"),
|
||||
next: formData.get("next"),
|
||||
confirm: formData.get("confirm"),
|
||||
});
|
||||
if (!parsed.success) return { error: firstIssue(parsed.error) };
|
||||
|
||||
// Aktuelles Passwort mit einem separaten Client prüfen, damit die Session des Admins unberührt bleibt.
|
||||
const verifier = createSupabaseClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } }
|
||||
);
|
||||
const { error: signInError } = await verifier.auth.signInWithPassword({ email: session.email, password: parsed.data.current });
|
||||
if (signInError) return { error: "Das aktuelle Passwort ist falsch." };
|
||||
await verifier.auth.signOut({ scope: "local" });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.updateUser({ password: parsed.data.next });
|
||||
if (error) return { error: "Passwort konnte nicht geändert werden." };
|
||||
|
||||
return { success: true, message: "Passwort geändert." };
|
||||
}
|
||||
|
||||
const TemplateSchema = z.object({
|
||||
name: z.string().trim().min(1, { error: "Name ist erforderlich." }).max(200),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500)
|
||||
.transform((value) => (value === "" ? null : value)),
|
||||
unitPrice: z.number({ error: "Bitte einen gültigen Preis eingeben." }).min(0).lt(1e10).multipleOf(0.01, { error: "Preis: maximal 2 Nachkommastellen." }),
|
||||
taxRate: z.number({ error: "Bitte eine gültige MwSt eingeben." }).min(0).max(100).multipleOf(0.01),
|
||||
});
|
||||
|
||||
function parseDecimal(value: FormDataEntryValue | null) {
|
||||
return parseDecimalInput(String(value ?? ""));
|
||||
}
|
||||
|
||||
export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
||||
if (!(await requireAdmin())) return { error: "Keine Berechtigung." };
|
||||
|
||||
const parsed = TemplateSchema.safeParse({
|
||||
name: formData.get("name") ?? "",
|
||||
description: formData.get("description") ?? "",
|
||||
unitPrice: parseDecimal(formData.get("unitPrice")),
|
||||
taxRate: parseDecimal(formData.get("taxRate")),
|
||||
});
|
||||
if (!parsed.success) return { error: firstIssue(parsed.error) };
|
||||
|
||||
const values = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
unit_price: parsed.data.unitPrice,
|
||||
tax_rate: parsed.data.taxRate,
|
||||
};
|
||||
|
||||
const id = String(formData.get("id") ?? "");
|
||||
const supabase = await createClient();
|
||||
|
||||
if (id === "") {
|
||||
const { error } = await supabase.from("product_templates").insert(values);
|
||||
if (error) return { error: "Vorlage konnte nicht gespeichert werden." };
|
||||
} else {
|
||||
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." };
|
||||
const { data, error } = await supabase.from("product_templates").update(values).eq("id", id).select("id");
|
||||
if (error || !data?.length) return { error: "Vorlage konnte nicht gespeichert werden." };
|
||||
}
|
||||
|
||||
revalidatePath("/admin/settings");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setProductTemplateActive(id: string, isActive: boolean): Promise<SettingsState> {
|
||||
if (!(await requireAdmin())) return { error: "Keine Berechtigung." };
|
||||
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("product_templates")
|
||||
.update({ is_active: Boolean(isActive) })
|
||||
.eq("id", id)
|
||||
.select("id");
|
||||
if (error || !data?.length) return { error: "Vorlage konnte nicht geändert werden." };
|
||||
|
||||
revalidatePath("/admin/settings");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Bestehende Rechnungen bleiben unverändert: invoice_items speichern Bezeichnung und Preis als Snapshot.
|
||||
export async function deleteProductTemplate(id: string): Promise<SettingsState> {
|
||||
if (!(await requireAdmin())) return { error: "Keine Berechtigung." };
|
||||
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.from("product_templates").delete().eq("id", id).select("id");
|
||||
if (error || !data?.length) return { error: "Vorlage konnte nicht gelöscht werden." };
|
||||
|
||||
revalidatePath("/admin/settings");
|
||||
return { success: true };
|
||||
}
|
||||
1
modules/admin/lib/constants.ts
Normal file
1
modules/admin/lib/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const MIN_PASSWORD_LENGTH = 10;
|
||||
15
modules/admin/queries/clients.ts
Normal file
15
modules/admin/queries/clients.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "server-only"
|
||||
import { createAdminClient } from "@/lib/supabase/admin"
|
||||
|
||||
export async function getClients() {
|
||||
const supabase = createAdminClient()
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, name, email")
|
||||
.eq("role", "client")
|
||||
.order("name")
|
||||
|
||||
if (error) throw new Error(`Failed to load clients: ${error.message}`)
|
||||
|
||||
return data
|
||||
}
|
||||
229
modules/admin/queries/dashboard.ts
Normal file
229
modules/admin/queries/dashboard.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import "server-only"
|
||||
import Decimal from "decimal.js"
|
||||
import { createClient } from "@/lib/supabase/server"
|
||||
import { berlinToday } from "@/modules/shared/lib/info-format"
|
||||
import { formatCurrency } from "@/modules/shared/lib/invoice-math"
|
||||
import { PRODUCT_STATUS_LABELS, type ProductStatus } from "@/modules/shared/schemas/info"
|
||||
|
||||
const RECENT_UPLOAD_ROWS = 100
|
||||
const MAX_UPLOAD_GROUPS = 8
|
||||
const MAX_TASKS = 8
|
||||
|
||||
export type AttentionItem = {
|
||||
key: string
|
||||
kind: "overdue" | "outage" | "maintenance" | "check" | "stammdaten" | "terms"
|
||||
title: string
|
||||
detail: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export type UploadGroup = {
|
||||
key: string
|
||||
clientName: string
|
||||
uploaderName: string
|
||||
folderName: string
|
||||
count: number
|
||||
latestAt: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export type DashboardTask = {
|
||||
id: string
|
||||
name: string
|
||||
clientName: string
|
||||
since: string
|
||||
status: "in_progress" | "planned"
|
||||
href: string
|
||||
}
|
||||
|
||||
export type DashboardData = {
|
||||
openCount: number
|
||||
openTotal: number
|
||||
overdueCount: number
|
||||
overdueTotal: number
|
||||
paidThisMonthTotal: number
|
||||
clientCount: number
|
||||
clientsWithIssues: number
|
||||
attention: AttentionItem[]
|
||||
uploads: UploadGroup[]
|
||||
tasks: DashboardTask[]
|
||||
}
|
||||
|
||||
function daysBetween(fromIso: string, toIso: string) {
|
||||
return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000)
|
||||
}
|
||||
|
||||
function sum(values: (number | string | null)[]) {
|
||||
return values.reduce<Decimal>((total, value) => total.plus(new Decimal(value ?? 0)), new Decimal(0)).toNumber()
|
||||
}
|
||||
|
||||
// Admins lesen per RLS (is_admin) alle Clients – daher reicht der normale Server-Client.
|
||||
export async function getDashboardData(): Promise<DashboardData> {
|
||||
const supabase = await createClient()
|
||||
const today = berlinToday()
|
||||
const currentMonth = today.slice(0, 7)
|
||||
|
||||
const [profilesRes, companiesRes, invoicesRes, productsRes, checksRes, tasksRes, documentsRes] = await Promise.all([
|
||||
supabase.from("profiles").select("id, name, client_id, terms_accepted_at").eq("role", "client").order("name"),
|
||||
supabase.from("clients").select("id, company_name"),
|
||||
supabase
|
||||
.from("invoices_overview")
|
||||
.select("id, client_id, invoice_number, status, display_status, gross_total, due_date, paid_at")
|
||||
.neq("status", "cancelled"),
|
||||
supabase.from("client_products").select("id, client_id, name, status, status_message"),
|
||||
supabase.from("product_check_summary").select("product_id, ok, error"),
|
||||
supabase
|
||||
.from("product_tasks")
|
||||
.select("id, client_id, name, status, since")
|
||||
.in("status", ["in_progress", "planned"])
|
||||
.order("status")
|
||||
.order("since")
|
||||
.limit(MAX_TASKS),
|
||||
supabase
|
||||
.from("documents")
|
||||
// !inner + Filter auf der Relation: nur Client-Uploads, bevor das Limit greift
|
||||
.select("id, client_id, folder_id, created_at, folders(name), uploader:profiles!documents_uploaded_by_fkey!inner(id, name, role)")
|
||||
.eq("uploader.role", "client")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(RECENT_UPLOAD_ROWS),
|
||||
])
|
||||
|
||||
for (const result of [profilesRes, companiesRes, invoicesRes, productsRes, checksRes, tasksRes, documentsRes]) {
|
||||
if (result.error) throw new Error("Dashboard konnte nicht geladen werden.")
|
||||
}
|
||||
|
||||
const profiles = profilesRes.data!
|
||||
const companyById = new Map(companiesRes.data!.map((row) => [row.id as string, row.company_name as string]))
|
||||
const profileByClient = new Map<string, { id: string; name: string }>()
|
||||
for (const profile of profiles) {
|
||||
if (profile.client_id && !profileByClient.has(profile.client_id)) {
|
||||
profileByClient.set(profile.client_id, { id: profile.id, name: profile.name })
|
||||
}
|
||||
}
|
||||
|
||||
const clientLabel = (clientId: string) =>
|
||||
companyById.get(clientId) ?? profileByClient.get(clientId)?.name ?? "Unbekannter Client"
|
||||
const clientBase = (clientId: string) => {
|
||||
const profile = profileByClient.get(clientId)
|
||||
return profile ? `/admin/clients/${profile.id}` : "/admin/clients"
|
||||
}
|
||||
|
||||
const attention: AttentionItem[] = []
|
||||
|
||||
// Rechnungen
|
||||
const invoices = invoicesRes.data!
|
||||
const open = invoices.filter((invoice) => invoice.status === "pending")
|
||||
const overdue = open
|
||||
.filter((invoice) => invoice.display_status === "overdue")
|
||||
.sort((a, b) => a.due_date.localeCompare(b.due_date))
|
||||
const paidThisMonth = invoices.filter((invoice) => invoice.status === "paid" && invoice.paid_at?.startsWith(currentMonth))
|
||||
|
||||
for (const invoice of overdue) {
|
||||
const days = daysBetween(invoice.due_date, today)
|
||||
attention.push({
|
||||
key: `invoice-${invoice.id}`,
|
||||
kind: "overdue",
|
||||
title: `${invoice.invoice_number} ist überfällig`,
|
||||
detail: `${clientLabel(invoice.client_id)} · ${formatCurrency(Number(invoice.gross_total))} · seit ${days} ${days === 1 ? "Tag" : "Tagen"}`,
|
||||
href: `${clientBase(invoice.client_id)}/billing`,
|
||||
})
|
||||
}
|
||||
|
||||
// Systeme
|
||||
const lastCheckByProduct = new Map(checksRes.data!.map((row) => [row.product_id as string, row]))
|
||||
const clientsWithIssues = new Set<string>()
|
||||
|
||||
for (const product of productsRes.data!) {
|
||||
const status = product.status as ProductStatus
|
||||
const lastCheck = lastCheckByProduct.get(product.id)
|
||||
const failedCheck = lastCheck?.ok === false
|
||||
|
||||
if (status === "offline" || failedCheck) clientsWithIssues.add(product.client_id)
|
||||
if (status === "online" && !failedCheck) continue
|
||||
|
||||
const kind: AttentionItem["kind"] = status === "offline" ? "outage" : status === "maintenance" ? "maintenance" : "check"
|
||||
attention.push({
|
||||
key: `product-${product.id}`,
|
||||
kind,
|
||||
title:
|
||||
kind === "check"
|
||||
? `${product.name}: letzte Prüfung fehlgeschlagen`
|
||||
: `${product.name}: ${PRODUCT_STATUS_LABELS[status]}`,
|
||||
detail: [clientLabel(product.client_id), kind === "check" ? lastCheck?.error : product.status_message]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
href: clientBase(product.client_id),
|
||||
})
|
||||
}
|
||||
|
||||
// Onboarding
|
||||
for (const profile of profiles) {
|
||||
if (!profile.client_id) {
|
||||
attention.push({
|
||||
key: `stammdaten-${profile.id}`,
|
||||
kind: "stammdaten",
|
||||
title: `${profile.name}: Stammdaten fehlen`,
|
||||
detail: "Ohne Stammdaten sind Rechnungen, Dokumente und Status nicht nutzbar.",
|
||||
href: `/admin/clients/${profile.id}/data`,
|
||||
})
|
||||
}
|
||||
if (!profile.terms_accepted_at) {
|
||||
attention.push({
|
||||
key: `terms-${profile.id}`,
|
||||
kind: "terms",
|
||||
title: `${profile.name}: AGB noch nicht akzeptiert`,
|
||||
detail: "Wird beim nächsten Login des Clients abgefragt.",
|
||||
href: `/admin/clients/${profile.id}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const priority: Record<AttentionItem["kind"], number> = { outage: 0, overdue: 1, check: 2, maintenance: 3, stammdaten: 4, terms: 5 }
|
||||
attention.sort((a, b) => priority[a.kind] - priority[b.kind])
|
||||
|
||||
// Uploads von Clients, zusammengefasst pro Person + Ordner
|
||||
const groups = new Map<string, UploadGroup>()
|
||||
for (const doc of documentsRes.data!) {
|
||||
const uploader = doc.uploader as unknown as { id: string; name: string; role: string } | null
|
||||
if (!uploader || uploader.role !== "client") continue
|
||||
|
||||
const key = `${uploader.id}-${doc.folder_id}`
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
continue
|
||||
}
|
||||
if (groups.size >= MAX_UPLOAD_GROUPS) continue
|
||||
|
||||
const folder = doc.folders as unknown as { name: string } | null
|
||||
groups.set(key, {
|
||||
key,
|
||||
clientName: clientLabel(doc.client_id),
|
||||
uploaderName: uploader.name,
|
||||
folderName: folder?.name ?? "Ohne Ordner",
|
||||
count: 1,
|
||||
latestAt: doc.created_at,
|
||||
href: `/admin/clients/${uploader.id}/storage${doc.folder_id ? `?folder=${doc.folder_id}` : ""}`,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
openCount: open.length,
|
||||
openTotal: sum(open.map((invoice) => invoice.gross_total)),
|
||||
overdueCount: overdue.length,
|
||||
overdueTotal: sum(overdue.map((invoice) => invoice.gross_total)),
|
||||
paidThisMonthTotal: sum(paidThisMonth.map((invoice) => invoice.gross_total)),
|
||||
clientCount: profiles.length,
|
||||
clientsWithIssues: clientsWithIssues.size,
|
||||
attention,
|
||||
uploads: [...groups.values()],
|
||||
tasks: tasksRes.data!.map((task) => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
clientName: clientLabel(task.client_id),
|
||||
since: task.since,
|
||||
status: task.status,
|
||||
href: clientBase(task.client_id),
|
||||
})),
|
||||
}
|
||||
}
|
||||
31
modules/admin/queries/settings.ts
Normal file
31
modules/admin/queries/settings.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import "server-only"
|
||||
import { createClient } from "@/lib/supabase/server"
|
||||
|
||||
export type AdminProductTemplate = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
unitPrice: number
|
||||
taxRate: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export async function getAllProductTemplates(): Promise<AdminProductTemplate[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("product_templates")
|
||||
.select("id, name, description, unit_price, tax_rate, is_active")
|
||||
.order("is_active", { ascending: false })
|
||||
.order("name")
|
||||
|
||||
if (error) throw new Error("Produktvorlagen konnten nicht geladen werden.")
|
||||
|
||||
return data.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
unitPrice: Number(row.unit_price),
|
||||
taxRate: Number(row.tax_rate),
|
||||
isActive: row.is_active,
|
||||
}))
|
||||
}
|
||||
87
modules/admin/ui/components/add-client-dialog.tsx
Normal file
87
modules/admin/ui/components/add-client-dialog.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Mail, PlusIcon, UserRound } from "lucide-react";
|
||||
import { createClient } from "@/modules/admin/actions/client";
|
||||
import { CopyField } from "./copy-field";
|
||||
|
||||
function AddClientDialogContent({ onDone }: { onDone: () => void }) {
|
||||
const [state, formAction, pending] = useActionState(createClient, undefined);
|
||||
const success = state && "success" in state && state.success;
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>Client angelegt 🎉</DialogTitle>
|
||||
<DialogDescription>
|
||||
Gib die Zugangsdaten an den Client weiter. Das Passwort wird nur jetzt angezeigt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<CopyField label="Email" value={state.email} />
|
||||
<CopyField label="Passwort" value={state.password} />
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" onClick={onDone}>Fertig</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>Neuen Clienten anlegen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={formAction}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="client-name"><UserRound className="w-4 h-4"/> Name <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input id="client-name" name="name" required className="focus-visible:ring-0"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="client-email"><Mail className="w-4 h-4"/> Email <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input id="client-email" name="email" type="email" required className="focus-visible:ring-0"/>
|
||||
</Field>
|
||||
{state && "error" in state && (
|
||||
<p className="text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Wird angelegt..." : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AddClientDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [instanceKey, setInstanceKey] = useState(0);
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setInstanceKey((key) => key + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger render={<Button className="cursor-pointer"/>}>
|
||||
<PlusIcon/>
|
||||
Add a new Client
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<AddClientDialogContent key={instanceKey} onDone={() => handleOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
169
modules/admin/ui/components/client-actions-menu.tsx
Normal file
169
modules/admin/ui/components/client-actions-menu.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { KeyRoundIcon, MoreHorizontalIcon, PencilIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { resetClientPassword, updateClientAccount } from "@/modules/admin/actions/manage-client";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||
import { CopyField } from "./copy-field";
|
||||
|
||||
type Client = { id: string; name: string; email: string };
|
||||
|
||||
function ResetPasswordContent({ client, onDone }: { client: Client; onDone: () => void }) {
|
||||
const [mode, setMode] = useState<"generate" | "manual">("generate");
|
||||
const [state, formAction, pending] = useActionState(resetClientPassword, undefined);
|
||||
|
||||
if (state && "success" in state) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>Passwort geändert</DialogTitle>
|
||||
<DialogDescription>
|
||||
Gib das neue Passwort an {client.name} weiter. Es wird nur jetzt angezeigt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<CopyField label="Email" value={client.email} />
|
||||
<CopyField label="Neues Passwort" value={state.password} />
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" onClick={onDone}>Fertig</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>Passwort zurücksetzen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Setzt das Passwort von <span className="font-medium text-foreground">{client.name}</span> neu.
|
||||
Das alte Passwort funktioniert danach nicht mehr.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="profileId" value={client.id} />
|
||||
<input type="hidden" name="mode" value={mode} />
|
||||
<FieldGroup>
|
||||
<div role="radiogroup" aria-label="Art des neuen Passworts" className="grid grid-cols-2 gap-2">
|
||||
{(["generate", "manual"] as const).map((value) => (
|
||||
<label
|
||||
key={value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-3 py-2 text-sm has-checked:border-primary has-checked:bg-surface"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="mode-choice"
|
||||
value={value}
|
||||
checked={mode === value}
|
||||
onChange={() => setMode(value)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{value === "generate" ? "Automatisch generieren" : "Selbst festlegen"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{mode === "manual" && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-password">Neues Passwort</FieldLabel>
|
||||
<Input
|
||||
id="new-password"
|
||||
name="password"
|
||||
type="text"
|
||||
autoComplete="new-password"
|
||||
minLength={MIN_PASSWORD_LENGTH}
|
||||
required
|
||||
className="font-mono focus-visible:ring-0"
|
||||
/>
|
||||
<FieldDescription>Mindestens {MIN_PASSWORD_LENGTH} Zeichen.</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Wird geändert..." : "Passwort zurücksetzen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditClientContent({ client, onDone }: { client: Client; onDone: () => void }) {
|
||||
async function run(prev: Parameters<typeof updateClientAccount>[0], formData: FormData) {
|
||||
const result = await updateClientAccount(prev, formData);
|
||||
if (result && "success" in result) onDone();
|
||||
return result;
|
||||
}
|
||||
|
||||
const [state, formAction, pending] = useActionState(run, undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>Client bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="profileId" value={client.id} />
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-client-name">Name</FieldLabel>
|
||||
<Input id="edit-client-name" name="name" defaultValue={client.name} required className="focus-visible:ring-0" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-client-email">Email</FieldLabel>
|
||||
<Input id="edit-client-email" name="email" type="email" defaultValue={client.email} required className="focus-visible:ring-0" />
|
||||
<FieldDescription>Das ist auch die Login-Email des Clients.</FieldDescription>
|
||||
</Field>
|
||||
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Wird gespeichert..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientActionsMenu({ client }: { client: Client }) {
|
||||
const [dialog, setDialog] = useState<"password" | "edit" | null>(null);
|
||||
const [instanceKey, setInstanceKey] = useState(0);
|
||||
|
||||
function close() {
|
||||
setDialog(null);
|
||||
setInstanceKey((key) => key + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Aktionen für ${client.name}`} />}>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onClick={() => setDialog("edit")}>
|
||||
<PencilIcon /> Bearbeiten
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDialog("password")}>
|
||||
<KeyRoundIcon /> Passwort zurücksetzen
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={dialog !== null} onOpenChange={(open) => !open && close()}>
|
||||
<DialogContent>
|
||||
{dialog === "password" && <ResetPasswordContent key={instanceKey} client={client} onDone={close} />}
|
||||
{dialog === "edit" && <EditClientContent key={instanceKey} client={client} onDone={close} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
127
modules/admin/ui/components/clients-table.tsx
Normal file
127
modules/admin/ui/components/clients-table.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRightIcon, Building2Icon, Search } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { AddClientDialog } from "./add-client-dialog";
|
||||
import { ClientActionsMenu } from "./client-actions-menu";
|
||||
|
||||
type Client = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
current: { label: "Current", variant: "warning" as const },
|
||||
done: { label: "Done", variant: "success" as const },
|
||||
upcoming: { label: "Upcoming", variant: "neutral" as const },
|
||||
};
|
||||
|
||||
// TODO: Status kommt aktuell nicht aus der DB, sobald Projekte existieren durch echten Wert ersetzen.
|
||||
function getPlaceholderStatus(index: number): keyof typeof STATUS_CONFIG {
|
||||
const statuses: (keyof typeof STATUS_CONFIG)[] = ["current", "done", "upcoming"];
|
||||
return statuses[index % statuses.length];
|
||||
}
|
||||
|
||||
export function ClientsTable({ clients }: { clients: Client[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredClients = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return clients;
|
||||
|
||||
return clients.filter(
|
||||
(client) =>
|
||||
client.name.toLowerCase().includes(normalizedQuery) ||
|
||||
client.email.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}, [clients, query]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<div className="flex flex-row items-center justify-between gap-2 w-full p-2">
|
||||
<InputGroup className="max-w-lg rounded-md focus-visible:ring-0">
|
||||
<InputGroupInput
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<AddClientDialog />
|
||||
</div>
|
||||
|
||||
{filteredClients.length === 0 ? (
|
||||
<p className="p-8 text-center text-muted-foreground">
|
||||
{clients.length === 0 ? "Noch keine Clients vorhanden." : "Keine Clients gefunden."}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredClients.map((client) => {
|
||||
const index = clients.indexOf(client);
|
||||
const status = STATUS_CONFIG[getPlaceholderStatus(index)];
|
||||
|
||||
return (
|
||||
<TableRow key={client.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="lg" className="shrink-0">
|
||||
<AvatarFallback>
|
||||
<Building2Icon className="size-4.5 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{client.name}</span>
|
||||
<span className="truncate text-small text-muted-foreground">{client.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<ClientActionsMenu client={client} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/admin/clients/${client.id}`} />}
|
||||
aria-label={`${client.name} öffnen`}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
modules/admin/ui/components/copy-field.tsx
Normal file
51
modules/admin/ui/components/copy-field.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client"
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
|
||||
export function CopyField({ label, value }: { label: string; value: string }) {
|
||||
const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setStatus("copied");
|
||||
} catch {
|
||||
inputRef.current?.select();
|
||||
setStatus("error");
|
||||
}
|
||||
setTimeout(() => setStatus("idle"), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input ref={inputRef} value={value} readOnly className="focus-visible:ring-0" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 cursor-pointer"
|
||||
onClick={handleCopy}
|
||||
aria-label={`${label} kopieren`}
|
||||
>
|
||||
{status === "copied" ? (
|
||||
<CheckIcon className="text-signal-success" />
|
||||
) : (
|
||||
<CopyIcon />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{status === "error" && (
|
||||
<p className="text-small text-destructive">
|
||||
Kopieren fehlgeschlagen — Text wurde markiert, bitte manuell kopieren.
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
292
modules/admin/ui/components/settings-forms.tsx
Normal file
292
modules/admin/ui/components/settings-forms.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"use client";
|
||||
|
||||
import { useActionState, useState, useTransition } from "react";
|
||||
import { EyeIcon, EyeOffIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { cn } from "cn";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
changeOwnPassword,
|
||||
deleteProductTemplate,
|
||||
saveProductTemplate,
|
||||
setProductTemplateActive,
|
||||
updateOwnProfile,
|
||||
type SettingsState,
|
||||
} from "@/modules/admin/actions/settings";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||
import type { AdminProductTemplate } from "@/modules/admin/queries/settings";
|
||||
import { formatCurrency } from "@/modules/shared/lib/invoice-math";
|
||||
|
||||
function StateMessage({ state }: { state: SettingsState }) {
|
||||
if (!state) return null;
|
||||
if ("error" in state) return <p className="text-sm text-destructive">{state.error}</p>;
|
||||
return state.message ? <p className="text-sm text-signal-success" aria-live="polite">{state.message}</p> : null;
|
||||
}
|
||||
|
||||
export function ProfileForm({ name, email }: { name: string; email: string }) {
|
||||
const [state, formAction, pending] = useActionState(updateOwnProfile, undefined);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
<FieldGroup>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-name">Name</FieldLabel>
|
||||
<Input id="settings-name" name="name" defaultValue={name} required className="focus-visible:ring-0" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-email">Email</FieldLabel>
|
||||
<Input id="settings-email" value={email} disabled readOnly />
|
||||
<FieldDescription>Die Login-Email kann nur in Supabase geändert werden.</FieldDescription>
|
||||
</Field>
|
||||
</div>
|
||||
<StateMessage state={state} />
|
||||
</FieldGroup>
|
||||
<div>
|
||||
<Button type="submit" disabled={pending}>{pending ? "Wird gespeichert..." : "Speichern"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordInput({ id, name, label, autoComplete }: { id: string; name: string; label: string; autoComplete: string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={id}
|
||||
name={name}
|
||||
type={visible ? "text" : "password"}
|
||||
autoComplete={autoComplete}
|
||||
required
|
||||
className="pr-10 focus-visible:ring-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-1 right-1"
|
||||
onClick={() => setVisible((value) => !value)}
|
||||
aria-label={visible ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
>
|
||||
{visible ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordForm() {
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
|
||||
async function run(prev: SettingsState, formData: FormData) {
|
||||
const result = await changeOwnPassword(prev, formData);
|
||||
if (result && "success" in result) setFormKey((key) => key + 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
const [state, formAction, pending] = useActionState(run, undefined);
|
||||
|
||||
return (
|
||||
<form key={formKey} action={formAction} className="flex flex-col gap-4">
|
||||
<FieldGroup>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<PasswordInput id="current-password" name="current" label="Aktuelles Passwort" autoComplete="current-password" />
|
||||
<PasswordInput id="next-password" name="next" label="Neues Passwort" autoComplete="new-password" />
|
||||
<PasswordInput id="confirm-password" name="confirm" label="Neues Passwort wiederholen" autoComplete="new-password" />
|
||||
</div>
|
||||
<FieldDescription>Mindestens {MIN_PASSWORD_LENGTH} Zeichen.</FieldDescription>
|
||||
<StateMessage state={state} />
|
||||
</FieldGroup>
|
||||
<div>
|
||||
<Button type="submit" disabled={pending}>{pending ? "Wird geändert..." : "Passwort ändern"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateForm({ template, onDone }: { template?: AdminProductTemplate; onDone: () => void }) {
|
||||
async function run(prev: SettingsState, formData: FormData) {
|
||||
const result = await saveProductTemplate(prev, formData);
|
||||
if (result && "success" in result) onDone();
|
||||
return result;
|
||||
}
|
||||
|
||||
const [state, formAction, pending] = useActionState(run, undefined);
|
||||
const toInput = (value: number) => value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false });
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="id" value={template?.id ?? ""} />
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="template-name">Bezeichnung <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input id="template-name" name="name" defaultValue={template?.name ?? ""} placeholder="z. B. Website Wartung (monatlich)" required />
|
||||
<FieldDescription>Erscheint so als Position auf der Rechnung.</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="template-description">Interne Beschreibung</FieldLabel>
|
||||
<Input id="template-description" name="description" defaultValue={template?.description ?? ""} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="template-price">Preis netto (€) <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input id="template-price" name="unitPrice" inputMode="decimal" defaultValue={template ? toInput(template.unitPrice) : ""} placeholder="50,00" required />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="template-tax">MwSt (%)</FieldLabel>
|
||||
<Input id="template-tax" name="taxRate" inputMode="decimal" defaultValue={template ? toInput(template.taxRate) : "0"} required />
|
||||
</Field>
|
||||
</div>
|
||||
<StateMessage state={state} />
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="submit" disabled={pending}>{pending ? "Wird gespeichert..." : "Speichern"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateDialog({ template }: { template?: AdminProductTemplate }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [instanceKey, setInstanceKey] = useState(0);
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setInstanceKey((key) => key + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
template ? (
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`${template.name} bearbeiten`} />
|
||||
) : (
|
||||
<Button variant="outline" size="sm" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{template ? <PencilIcon className="size-4" /> : <><PlusIcon className="size-4" /> Neue Vorlage</>}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>{template ? "Vorlage bearbeiten" : "Neue Vorlage"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TemplateForm key={instanceKey} template={template} onDone={() => handleOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteTemplateDialog({ template }: { template: AdminProductTemplate }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function handleDelete() {
|
||||
startTransition(async () => {
|
||||
const result = await deleteProductTemplate(template.id);
|
||||
if (result && "error" in result) setError(result.error);
|
||||
else setOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`${template.name} löschen`} />}>
|
||||
<Trash2Icon className="size-4" />
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>Vorlage löschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{template.name}</span> wird gelöscht. Bereits erstellte
|
||||
Rechnungen bleiben unverändert. Zum Ausblenden reicht auch „Deaktivieren“.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>Abbrechen</Button>
|
||||
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
|
||||
{pending ? "Wird gelöscht..." : "Löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveToggle({ template }: { template: AdminProductTemplate }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleToggle() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await setProductTemplateActive(template.id, !template.isActive);
|
||||
if (result && "error" in result) setError(result.error);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{error && <span className="text-small text-destructive" role="alert">{error}</span>}
|
||||
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={handleToggle}>
|
||||
{template.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductTemplatesSection({ templates }: { templates: AdminProductTemplate[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<TemplateDialog />
|
||||
</div>
|
||||
{templates.length === 0 ? (
|
||||
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">
|
||||
Noch keine Vorlagen. Vorlagen lassen sich beim Erstellen einer Rechnung als Position auswählen.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
|
||||
{templates.map((template) => (
|
||||
<li key={template.id} className={cn("flex flex-wrap items-center gap-3 px-4 py-3", !template.isActive && "opacity-60")}>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
{template.name}
|
||||
{!template.isActive && <Badge>Inaktiv</Badge>}
|
||||
</span>
|
||||
{template.description && <span className="text-small text-muted-foreground">{template.description}</span>}
|
||||
</div>
|
||||
<span className="tabular-nums">{formatCurrency(template.unitPrice)}</span>
|
||||
<span className="w-20 text-right text-small text-muted-foreground tabular-nums">
|
||||
{template.taxRate.toLocaleString("de-DE")} % MwSt
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<ActiveToggle template={template} />
|
||||
<TemplateDialog template={template} />
|
||||
<DeleteTemplateDialog template={template} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
modules/admin/ui/views/client-list-view.tsx
Normal file
15
modules/admin/ui/views/client-list-view.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { ClientsTable } from "../components/clients-table"
|
||||
import { getClients } from "../../queries/clients"
|
||||
|
||||
const ClientListViewPage = async () => {
|
||||
const clients = await getClients()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<h1 className="text-3xl font-semibold">Clients</h1>
|
||||
<ClientsTable clients={clients} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClientListViewPage
|
||||
202
modules/admin/ui/views/dashboard-view.tsx
Normal file
202
modules/admin/ui/views/dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import Link from "next/link"
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
ClipboardListIcon,
|
||||
FileWarningIcon,
|
||||
ReceiptIcon,
|
||||
ScaleIcon,
|
||||
ServerCrashIcon,
|
||||
UploadIcon,
|
||||
WrenchIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { getDashboardData, type AttentionItem } from "@/modules/admin/queries/dashboard"
|
||||
import { formatCurrency } from "@/modules/shared/lib/invoice-math"
|
||||
import { berlinToday, formatDateTime, formatTaskTiming } from "@/modules/shared/lib/info-format"
|
||||
import { TASK_STATUS_LABELS } from "@/modules/shared/schemas/info"
|
||||
|
||||
const ATTENTION_STYLES: Record<AttentionItem["kind"], { icon: LucideIcon; className: string }> = {
|
||||
outage: { icon: ServerCrashIcon, className: "text-signal-error bg-signal-error/10" },
|
||||
overdue: { icon: ReceiptIcon, className: "text-signal-error bg-signal-error/10" },
|
||||
check: { icon: AlertTriangleIcon, className: "text-signal-warning bg-signal-warning/10" },
|
||||
maintenance: { icon: WrenchIcon, className: "text-signal-warning bg-signal-warning/10" },
|
||||
stammdaten: { icon: FileWarningIcon, className: "text-ink-600 bg-ink-100 dark:text-ink-300 dark:bg-ink-800" },
|
||||
terms: { icon: ScaleIcon, className: "text-ink-600 bg-ink-100 dark:text-ink-300 dark:bg-ink-800" },
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
tone = "default",
|
||||
href,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
detail?: string
|
||||
tone?: "default" | "danger" | "success"
|
||||
href?: string
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="text-small text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-2xl font-semibold tabular-nums",
|
||||
tone === "danger" && "text-signal-error",
|
||||
tone === "success" && "text-signal-success"
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{detail && <span className="text-small text-muted-foreground">{detail}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
const className = "flex flex-col gap-1 rounded-lg border border-border p-4"
|
||||
return href ? (
|
||||
<Link href={href} className={cn(className, "transition-colors hover:bg-surface")}>{content}</Link>
|
||||
) : (
|
||||
<div className={className}>{content}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="flex items-center gap-2 text-xl font-semibold">
|
||||
{title}
|
||||
{count !== undefined && count > 0 && <Badge>{count}</Badge>}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHint({ icon: Icon = CheckCircle2Icon, children }: { icon?: LucideIcon; children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-border bg-surface/50 p-4 text-muted-foreground">
|
||||
<Icon className="size-4 shrink-0" /> {children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkRow({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<li>
|
||||
<Link href={href} className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface">
|
||||
{children}
|
||||
<ArrowRightIcon className="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const DashboardView = async ({ name }: { name: string }) => {
|
||||
const data = await getDashboardData()
|
||||
const today = berlinToday()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-semibold">Hallo {name.split(" ")[0]}</h1>
|
||||
<p className="text-accent-400">Alles Wichtige über alle Clients auf einen Blick.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label="Offene Rechnungen" value={formatCurrency(data.openTotal)} detail={`${data.openCount} ${data.openCount === 1 ? "Rechnung" : "Rechnungen"}`} />
|
||||
<StatCard
|
||||
label="Davon überfällig"
|
||||
value={formatCurrency(data.overdueTotal)}
|
||||
detail={`${data.overdueCount} ${data.overdueCount === 1 ? "Rechnung" : "Rechnungen"}`}
|
||||
tone={data.overdueCount > 0 ? "danger" : "default"}
|
||||
/>
|
||||
<StatCard label="Bezahlt diesen Monat" value={formatCurrency(data.paidThisMonthTotal)} tone={data.paidThisMonthTotal > 0 ? "success" : "default"} />
|
||||
<StatCard
|
||||
label="Clients"
|
||||
value={String(data.clientCount)}
|
||||
detail={data.clientsWithIssues > 0 ? `${data.clientsWithIssues} mit Störung` : "Keine Störungen"}
|
||||
tone={data.clientsWithIssues > 0 ? "danger" : "default"}
|
||||
href="/admin/clients"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Section title="Braucht Aufmerksamkeit" count={data.attention.length}>
|
||||
{data.attention.length === 0 ? (
|
||||
<EmptyHint>Alles im grünen Bereich.</EmptyHint>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{data.attention.map((item) => {
|
||||
const style = ATTENTION_STYLES[item.kind]
|
||||
return (
|
||||
<LinkRow key={item.key} href={item.href}>
|
||||
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md", style.className)}>
|
||||
<style.icon className="size-4" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate font-medium">{item.title}</span>
|
||||
{item.detail && <span className="truncate text-small text-muted-foreground">{item.detail}</span>}
|
||||
</span>
|
||||
</LinkRow>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<Section title="Neueste Uploads von Clients">
|
||||
{data.uploads.length === 0 ? (
|
||||
<EmptyHint icon={UploadIcon}>Noch keine Uploads von Clients.</EmptyHint>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{data.uploads.map((upload) => (
|
||||
<LinkRow key={upload.key} href={upload.href}>
|
||||
<UploadIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">
|
||||
<span className="font-medium">{upload.clientName}</span> hat {upload.count}{" "}
|
||||
{upload.count === 1 ? "Datei" : "Dateien"} in „{upload.folderName}“ hochgeladen
|
||||
</span>
|
||||
<span className="truncate text-small text-muted-foreground">
|
||||
{upload.uploaderName} · zuletzt {formatDateTime(upload.latestAt)}
|
||||
</span>
|
||||
</span>
|
||||
</LinkRow>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Laufende Vorgänge">
|
||||
{data.tasks.length === 0 ? (
|
||||
<EmptyHint icon={ClipboardListIcon}>Keine offenen Vorgänge.</EmptyHint>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{data.tasks.map((task) => (
|
||||
<LinkRow key={task.id} href={task.href}>
|
||||
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20">
|
||||
{TASK_STATUS_LABELS[task.status]}
|
||||
</Badge>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate font-medium">{task.name}</span>
|
||||
<span className="truncate text-small text-muted-foreground">{task.clientName}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-small text-muted-foreground">
|
||||
{formatTaskTiming(task.status, task.since, today)}
|
||||
</span>
|
||||
</LinkRow>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardView
|
||||
38
modules/admin/ui/views/settings-view.tsx
Normal file
38
modules/admin/ui/views/settings-view.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { getAllProductTemplates } from "@/modules/admin/queries/settings"
|
||||
import { PasswordForm, ProductTemplatesSection, ProfileForm } from "@/modules/admin/ui/components/settings-forms"
|
||||
|
||||
function Section({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-lg border border-border p-4 md:p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
<p className="text-small text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsView = async ({ name, email }: { name: string; email: string }) => {
|
||||
const templates = await getAllProductTemplates()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full max-w-4xl">
|
||||
<h1 className="text-3xl font-semibold">Einstellungen</h1>
|
||||
|
||||
<Section title="Profil" description="So wirst du im Portal angezeigt, z. B. bei „zuletzt bearbeitet von“.">
|
||||
<ProfileForm name={name} email={email} />
|
||||
</Section>
|
||||
|
||||
<Section title="Passwort" description="Ändere das Passwort für deinen Admin-Zugang.">
|
||||
<PasswordForm />
|
||||
</Section>
|
||||
|
||||
<Section title="Produktvorlagen" description="Vorlagen für Rechnungspositionen. Inaktive Vorlagen erscheinen nicht mehr im Rechnungsdialog.">
|
||||
<ProductTemplatesSection templates={templates} />
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsView
|
||||
59
modules/loginPage/actions/auth.ts
Normal file
59
modules/loginPage/actions/auth.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import * as z from "zod";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export type AuthState = { error: string } | undefined;
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.email({ error: "Bitte eine gültige Email eingeben." }),
|
||||
password: z.string().min(1, { error: "Passwort ist erforderlich." }),
|
||||
});
|
||||
|
||||
export async function login(
|
||||
_prevState: AuthState,
|
||||
formData: FormData
|
||||
): Promise<AuthState> {
|
||||
|
||||
const validatedFields = LoginSchema.safeParse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||
const message = fieldErrors.email?.[0] ?? fieldErrors.password?.[0] ?? "Bitte Email und Passwort ausfüllen.";
|
||||
return { error: message }
|
||||
}
|
||||
|
||||
const { email, password } = validatedFields.data;
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data: signInData, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { error: "Email oder Passwort ist falsch" }
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("role")
|
||||
.eq("id", signInData.user.id)
|
||||
.single()
|
||||
|
||||
if (profileError || !profile) {
|
||||
return { error: "Profil konnte nicht geladen werden. Bitte kontaktiere den Support." }
|
||||
}
|
||||
|
||||
redirect(profile.role === "admin" ? "/admin/dashboard" : "/client/dashboard")
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
redirect("/");
|
||||
}
|
||||
310
modules/loginPage/ui/components/stamdaten-form.tsx
Normal file
310
modules/loginPage/ui/components/stamdaten-form.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"use client"
|
||||
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { saveStammdaten, StammdatenState } from "@/modules/shared/actions/stammdaten";
|
||||
import {
|
||||
Building2,
|
||||
CreditCard,
|
||||
FileText,
|
||||
MapPin,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { LEGAL_FORMS } from "@/modules/shared/data/stammdaten-data";
|
||||
|
||||
type Client = {
|
||||
company_name: string;
|
||||
legal_form: string;
|
||||
industry: string | null;
|
||||
vat_id: string | null;
|
||||
tax_number: string | null;
|
||||
commercial_register_number: string | null;
|
||||
street: string;
|
||||
postal_code: string;
|
||||
city: string;
|
||||
country: string;
|
||||
billing_street: string | null;
|
||||
billing_postal_code: string | null;
|
||||
billing_city: string | null;
|
||||
billing_country: string | null;
|
||||
contact_name: string;
|
||||
contact_position: string | null;
|
||||
contact_email: string;
|
||||
contact_phone: string | null;
|
||||
} | null;
|
||||
|
||||
type PaymentDetails = {
|
||||
payment_method: "invoice" | "sepa_debit" | "card";
|
||||
account_holder: string | null;
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
} | null;
|
||||
|
||||
const selectClassName =
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
function FormSection({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface/50 p-5">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<Icon className="size-4.5 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
</div>
|
||||
<FieldGroup className="@container grid grid-cols-1 gap-4 @md:grid-cols-2">
|
||||
{children}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
type = "text",
|
||||
required,
|
||||
disabled,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue?: string | null;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={name}>
|
||||
{label} {required && <span className="text-destructive">*</span>}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue ?? ""}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
className="focus-visible:ring-0"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
options,
|
||||
required,
|
||||
disabled,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={name}>
|
||||
{label} {required && <span className="text-destructive">*</span>}
|
||||
</FieldLabel>
|
||||
<select
|
||||
id={name}
|
||||
name={name}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange ? (event) => onChange(event.target.value) : undefined}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className={selectClassName}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const StammdatenForm = ({
|
||||
profileId,
|
||||
client,
|
||||
paymentDetails,
|
||||
isEditing,
|
||||
onDone,
|
||||
}: {
|
||||
profileId: string;
|
||||
client: Client;
|
||||
paymentDetails: PaymentDetails;
|
||||
isEditing: boolean;
|
||||
onDone: () => void;
|
||||
}) => {
|
||||
const [state, formAction, pending] = useActionState<StammdatenState, FormData>(
|
||||
saveStammdaten,
|
||||
undefined
|
||||
);
|
||||
const [paymentMethod, setPaymentMethod] = useState(paymentDetails?.payment_method ?? "invoice");
|
||||
const [hasDifferentBillingAddress, setHasDifferentBillingAddress] = useState(
|
||||
Boolean(client?.billing_street)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state && "success" in state && state.success) {
|
||||
onDone();
|
||||
}
|
||||
}, [state, onDone]);
|
||||
|
||||
const disabled = !isEditing || pending;
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-6">
|
||||
<input type="hidden" name="profileId" value={profileId} />
|
||||
|
||||
<fieldset disabled={disabled} className="flex flex-col gap-6">
|
||||
<FormSection icon={Building2} title="Firma">
|
||||
<TextField name="companyName" label="Firmenname" defaultValue={client?.company_name} required />
|
||||
<SelectField
|
||||
name="legalForm"
|
||||
label="Rechtsform"
|
||||
defaultValue={client?.legal_form ?? LEGAL_FORMS[0]}
|
||||
disabled={disabled}
|
||||
required
|
||||
options={LEGAL_FORMS.map((form) => ({ value: form, label: form }))}
|
||||
/>
|
||||
<TextField name="industry" label="Branche" defaultValue={client?.industry} />
|
||||
<TextField name="vatId" label="USt-IdNr" defaultValue={client?.vat_id} />
|
||||
<TextField name="taxNumber" label="Steuernummer" defaultValue={client?.tax_number} />
|
||||
<TextField
|
||||
name="commercialRegisterNumber"
|
||||
label="Handelsregisternummer"
|
||||
defaultValue={client?.commercial_register_number}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection icon={MapPin} title="Firmenadresse">
|
||||
<TextField name="street" label="Straße & Hausnummer" defaultValue={client?.street} required />
|
||||
<TextField name="postalCode" label="PLZ" defaultValue={client?.postal_code} required />
|
||||
<TextField name="city" label="Stadt" defaultValue={client?.city} required />
|
||||
<SelectField
|
||||
name="country"
|
||||
label="Land"
|
||||
defaultValue={client?.country ?? "DE"}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: "DE", label: "Deutschland" },
|
||||
{ value: "AT", label: "Österreich" },
|
||||
{ value: "CH", label: "Schweiz" },
|
||||
]}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface/50 p-5">
|
||||
<label className="mb-5 flex items-center gap-2 has-disabled:opacity-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasDifferentBillingAddress}
|
||||
onChange={(event) => setHasDifferentBillingAddress(event.target.checked)}
|
||||
disabled={disabled}
|
||||
className="size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<FileText className="size-4.5 text-muted-foreground" />
|
||||
<span className="text-base font-semibold">Abweichende Rechnungsadresse</span>
|
||||
</label>
|
||||
{hasDifferentBillingAddress && (
|
||||
<FieldGroup className="@container grid grid-cols-1 gap-4 @md:grid-cols-2">
|
||||
<TextField
|
||||
name="billingStreet"
|
||||
label="Straße & Hausnummer"
|
||||
defaultValue={client?.billing_street}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
name="billingPostalCode"
|
||||
label="PLZ"
|
||||
defaultValue={client?.billing_postal_code}
|
||||
required
|
||||
/>
|
||||
<TextField name="billingCity" label="Stadt" defaultValue={client?.billing_city} required />
|
||||
<TextField
|
||||
name="billingCountry"
|
||||
label="Land"
|
||||
defaultValue={client?.billing_country}
|
||||
required
|
||||
/>
|
||||
</FieldGroup>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormSection icon={User} title="Ansprechpartner">
|
||||
<TextField name="contactName" label="Name" defaultValue={client?.contact_name} required />
|
||||
<TextField name="contactPosition" label="Position" defaultValue={client?.contact_position} />
|
||||
<TextField name="contactEmail" label="Email" type="email" defaultValue={client?.contact_email} required />
|
||||
<TextField name="contactPhone" label="Telefon" defaultValue={client?.contact_phone} />
|
||||
</FormSection>
|
||||
|
||||
<FormSection icon={CreditCard} title="Zahlungsdaten">
|
||||
<SelectField
|
||||
name="paymentMethod"
|
||||
label="Zahlungsart"
|
||||
value={paymentMethod}
|
||||
onChange={(value) => setPaymentMethod(value as typeof paymentMethod)}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: "invoice", label: "Rechnung" },
|
||||
{ value: "sepa_debit", label: "SEPA-Lastschrift" },
|
||||
{ value: "card", label: "Kreditkarte" },
|
||||
]}
|
||||
/>
|
||||
{paymentMethod === "sepa_debit" && (
|
||||
<>
|
||||
<TextField
|
||||
name="accountHolder"
|
||||
label="Kontoinhaber"
|
||||
defaultValue={paymentDetails?.account_holder}
|
||||
required
|
||||
/>
|
||||
<TextField name="iban" label="IBAN" defaultValue={paymentDetails?.iban} required />
|
||||
<TextField name="bic" label="BIC" defaultValue={paymentDetails?.bic} />
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
</fieldset>
|
||||
|
||||
{state && "error" in state && (
|
||||
<p className="text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onDone} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Wird gespeichert..." : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default StammdatenForm;
|
||||
82
modules/loginPage/ui/views/login-view.tsx
Normal file
82
modules/loginPage/ui/views/login-view.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { useActionState, useState } from "react"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Eye, EyeOff, Lock, User } from 'lucide-react';
|
||||
import { Input } from "@/components/ui/input"
|
||||
import Image from "next/image"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { login } from "../../actions/auth";
|
||||
|
||||
const LoginPageView = () => {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [state, formAction, pending] = useActionState(login, undefined);
|
||||
|
||||
return (
|
||||
<div className="bg-background h-screen p-6">
|
||||
<Image
|
||||
src="/pictures/since3-logo.svg" alt=""
|
||||
width={1617}
|
||||
height={332}
|
||||
loading="eager"
|
||||
className="h-5 w-auto md:h-6 dark:hidden"/>
|
||||
<Image
|
||||
src="/pictures/since3-logo-weiss.svg" alt=""
|
||||
width={1617}
|
||||
height={332}
|
||||
loading="eager"
|
||||
className="hidden h-5 w-auto md:h-6 dark:block"/>
|
||||
<div className="flex flex-col h-full items-center justify-center">
|
||||
<h1 className="mb-6 text-3xl font-semibold">Login</h1>
|
||||
<form action={formAction} className="w-full max-w-sm ">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="form-email"><User className="w-4 h-4"/> Email <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input
|
||||
id="form-email"
|
||||
name="email"
|
||||
type="text"
|
||||
className="focus-visible:ring-0"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="form-password"><Lock className="w-4 h-4"/> Password <span className="text-destructive">*</span></FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="form-password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="focus-visible:ring-0 pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
{state?.error && (
|
||||
<p className="text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
<Button type="submit" disabled={pending} className="cursor-pointer">
|
||||
{pending ? "Bitte warten..." : "Login"}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<div className="absolute bottom-2">
|
||||
<p className="text-muted-foreground text-sm">Bei Fragen: <a href="mailto:info@since3.de" className="underline">info@since3.de</a></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPageView
|
||||
22
modules/shared/actions/acceptTerms.ts
Normal file
22
modules/shared/actions/acceptTerms.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use server";
|
||||
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
|
||||
export async function acceptTerms() {
|
||||
const session = await verifySession();
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ terms_accepted_at: new Date().toISOString() })
|
||||
.eq("id", session.userId)
|
||||
|
||||
if (error) return {
|
||||
error: "Zustimmung konnte nicht gespeichert werden."
|
||||
};
|
||||
|
||||
return {
|
||||
success: true
|
||||
}
|
||||
}
|
||||
143
modules/shared/actions/credentials.ts
Normal file
143
modules/shared/actions/credentials.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { getClientId } from "@/modules/shared/queries/profile";
|
||||
|
||||
const CredentialSchema = z.object({
|
||||
profileId: z.string().uuid(),
|
||||
service: z.string().min(1, { error: "Dienst ist erforderlich." }),
|
||||
username: z.string().min(1, { error: "Benutzername ist erforderlich." }),
|
||||
password: z.string().min(1, { error: "Passwort ist erforderlich." }),
|
||||
});
|
||||
|
||||
export type CredentialState = { error: string } | { success: true } | undefined;
|
||||
|
||||
export async function createCredential(
|
||||
_prevState: CredentialState,
|
||||
formData: FormData
|
||||
): Promise<CredentialState> {
|
||||
const session = await verifySession();
|
||||
const profileId = String(formData.get("profileId") ?? "");
|
||||
|
||||
if (!session.isAdmin && session.userId !== profileId) {
|
||||
return { error: "Keine Berechtigung." };
|
||||
}
|
||||
|
||||
const validatedFields = CredentialSchema.safeParse({
|
||||
profileId,
|
||||
service: formData.get("service"),
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||
const firstError = Object.values(fieldErrors).flat()[0];
|
||||
return { error: firstError ?? "Bitte alle Felder ausfüllen." };
|
||||
}
|
||||
|
||||
const clientId = await getClientId(profileId);
|
||||
if (!clientId) {
|
||||
return { error: "Client hat noch keine Stammdaten hinterlegt." };
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc("create_credential", {
|
||||
p_client_id: clientId,
|
||||
p_service: validatedFields.data.service,
|
||||
p_username: validatedFields.data.username,
|
||||
p_password: validatedFields.data.password,
|
||||
});
|
||||
|
||||
if (error) return { error: "Eintrag konnte nicht gespeichert werden." };
|
||||
|
||||
revalidatePath("/client/passwords");
|
||||
revalidatePath(`/admin/clients/${profileId}/passwords`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const UpdateCredentialSchema = z.object({
|
||||
profileId: z.string().uuid(),
|
||||
credentialId: z.string().uuid(),
|
||||
service: z.string().min(1, { error: "Dienst ist erforderlich." }),
|
||||
username: z.string().min(1, { error: "Benutzername ist erforderlich." }),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function updateCredential(
|
||||
_prevState: CredentialState,
|
||||
formData: FormData
|
||||
): Promise<CredentialState> {
|
||||
const session = await verifySession();
|
||||
const profileId = String(formData.get("profileId") ?? "");
|
||||
|
||||
if (!session.isAdmin && session.userId !== profileId) {
|
||||
return { error: "Keine Berechtigung." };
|
||||
}
|
||||
|
||||
const password = String(formData.get("password") ?? "").trim();
|
||||
|
||||
const validatedFields = UpdateCredentialSchema.safeParse({
|
||||
profileId,
|
||||
credentialId: formData.get("credentialId"),
|
||||
service: formData.get("service"),
|
||||
username: formData.get("username"),
|
||||
password: password === "" ? undefined : password,
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||
const firstError = Object.values(fieldErrors).flat()[0];
|
||||
return { error: firstError ?? "Bitte alle Felder ausfüllen." };
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc("update_credential", {
|
||||
p_id: validatedFields.data.credentialId,
|
||||
p_service: validatedFields.data.service,
|
||||
p_username: validatedFields.data.username,
|
||||
p_password: validatedFields.data.password ?? null,
|
||||
});
|
||||
|
||||
if (error) return { error: "Eintrag konnte nicht aktualisiert werden." };
|
||||
|
||||
revalidatePath("/client/passwords");
|
||||
revalidatePath(`/admin/clients/${profileId}/passwords`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function revealCredential(credentialId: string): Promise<{ password: string } | { error: string }> {
|
||||
await verifySession();
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.rpc("reveal_credential", { p_id: credentialId });
|
||||
|
||||
if (error || typeof data !== "string") {
|
||||
return { error: "Passwort konnte nicht abgerufen werden." };
|
||||
}
|
||||
|
||||
return { password: data };
|
||||
}
|
||||
|
||||
export async function deleteCredential(profileId: string, credentialId: string): Promise<CredentialState> {
|
||||
const session = await verifySession();
|
||||
|
||||
if (!session.isAdmin) {
|
||||
return { error: "Keine Berechtigung." };
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { error } = await supabase.from("credentials").delete().eq("id", credentialId);
|
||||
|
||||
if (error) return { error: "Eintrag konnte nicht gelöscht werden." };
|
||||
|
||||
revalidatePath(`/admin/clients/${profileId}/passwords`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
249
modules/shared/actions/documents.ts
Normal file
249
modules/shared/actions/documents.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { getClientId } from "@/modules/shared/queries/profile";
|
||||
import {
|
||||
DOCUMENTS_BUCKET,
|
||||
FolderNameSchema,
|
||||
MAX_FILES_PER_UPLOAD,
|
||||
UploadFileSchema,
|
||||
} from "@/modules/shared/schemas/documents";
|
||||
|
||||
const IdSchema = z.string().uuid();
|
||||
const URL_TTL_SECONDS = 5 * 60;
|
||||
|
||||
export type DocumentActionState = { error: string } | { success: true } | undefined;
|
||||
|
||||
type Access = { clientId: string; isAdmin: boolean };
|
||||
|
||||
// Admin: jeder Client. Client: nur das eigene Profil. Storage und Tabellen prüfen zusätzlich per RLS.
|
||||
async function resolveAccess(profileId: string, adminOnly = false): Promise<Access | { error: string }> {
|
||||
const session = await verifySession();
|
||||
if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." };
|
||||
if (adminOnly && !session.isAdmin) return { error: "Keine Berechtigung." };
|
||||
if (!session.isAdmin && session.userId !== profileId) return { error: "Keine Berechtigung." };
|
||||
|
||||
const clientId = await getClientId(profileId);
|
||||
if (!clientId) return { error: "Für diesen Client sind noch keine Stammdaten hinterlegt." };
|
||||
|
||||
return { clientId, isAdmin: session.isAdmin };
|
||||
}
|
||||
|
||||
function revalidateDocuments(profileId: string) {
|
||||
revalidatePath("/client/storage");
|
||||
revalidatePath(`/admin/clients/${profileId}/storage`);
|
||||
}
|
||||
|
||||
function safeFileName(name: string) {
|
||||
const cleaned = name
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^\w.-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.slice(-120);
|
||||
return cleaned === "" || cleaned === "." ? "datei" : cleaned;
|
||||
}
|
||||
|
||||
export async function createFolder(_prev: DocumentActionState, formData: FormData): Promise<DocumentActionState> {
|
||||
const profileId = String(formData.get("profileId") ?? "");
|
||||
const access = await resolveAccess(profileId, true);
|
||||
if ("error" in access) return access;
|
||||
|
||||
const name = FolderNameSchema.safeParse(formData.get("name") ?? "");
|
||||
if (!name.success) return { error: name.error.issues[0]?.message ?? "Ungültiger Name." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from("folders").insert({ client_id: access.clientId, name: name.data });
|
||||
|
||||
if (error) {
|
||||
return { error: error.code === "23505" ? "Ein Ordner mit diesem Namen existiert bereits." : "Ordner konnte nicht erstellt werden." };
|
||||
}
|
||||
|
||||
revalidateDocuments(profileId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteFolder(profileId: string, folderId: string): Promise<DocumentActionState> {
|
||||
const access = await resolveAccess(profileId, true);
|
||||
if ("error" in access) return access;
|
||||
if (!IdSchema.safeParse(folderId).success) return { error: "Ungültiger Ordner." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("folders")
|
||||
.delete()
|
||||
.eq("id", folderId)
|
||||
.eq("client_id", access.clientId)
|
||||
.select("id");
|
||||
|
||||
if (error) {
|
||||
return { error: error.code === "23503" ? "Der Ordner ist nicht leer. Bitte zuerst alle Dateien löschen." : "Ordner konnte nicht gelöscht werden." };
|
||||
}
|
||||
if (!data?.length) return { error: "Ordner nicht gefunden." };
|
||||
|
||||
revalidateDocuments(profileId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function assertFolder(clientId: string, folderId: string) {
|
||||
if (!IdSchema.safeParse(folderId).success) return false;
|
||||
const supabase = await createClient();
|
||||
const { data } = await supabase.from("folders").select("id").eq("id", folderId).eq("client_id", clientId).maybeSingle();
|
||||
return Boolean(data);
|
||||
}
|
||||
|
||||
export type PreparedUpload = { documentId: string; path: string };
|
||||
|
||||
// Schritt 1: Pfade vergeben. Die Dateien lädt der Browser direkt in den Storage (Vercel-Limit ~4,5 MB pro Request).
|
||||
export async function prepareUpload(
|
||||
profileId: string,
|
||||
folderId: string,
|
||||
files: { name: string; size: number; type: string }[]
|
||||
): Promise<{ error: string } | { uploads: PreparedUpload[] }> {
|
||||
const access = await resolveAccess(profileId);
|
||||
if ("error" in access) return access;
|
||||
|
||||
if (files.length === 0 || files.length > MAX_FILES_PER_UPLOAD) {
|
||||
return { error: `Bitte 1 bis ${MAX_FILES_PER_UPLOAD} Dateien auf einmal hochladen.` };
|
||||
}
|
||||
for (const file of files) {
|
||||
const parsed = UploadFileSchema.safeParse(file);
|
||||
if (!parsed.success) return { error: `${file.name}: ${parsed.error.issues[0]?.message ?? "Ungültige Datei."}` };
|
||||
}
|
||||
if (!(await assertFolder(access.clientId, folderId))) return { error: "Ordner nicht gefunden." };
|
||||
|
||||
return {
|
||||
uploads: files.map((file) => {
|
||||
const documentId = crypto.randomUUID();
|
||||
return { documentId, path: `${access.clientId}/${documentId}/${safeFileName(file.name)}` };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const FinalizeSchema = z
|
||||
.array(UploadFileSchema.extend({ documentId: z.string().uuid(), path: z.string().min(1).max(400) }))
|
||||
.min(1)
|
||||
.max(MAX_FILES_PER_UPLOAD);
|
||||
|
||||
// Nur Dateien ohne Dokument-Datensatz entfernen – sonst könnte ein Aufruf mit dem Pfad einer
|
||||
// bestehenden Datei (Konflikt beim Insert) genau diese Datei löschen. Ohne Gewissheit: nichts löschen.
|
||||
async function removeOrphanedObjects(paths: string[]) {
|
||||
const supabase = await createClient();
|
||||
const { data: existing, error } = await supabase.from("documents").select("storage_path").in("storage_path", paths);
|
||||
if (error) return;
|
||||
|
||||
const referenced = new Set((existing ?? []).map((row) => row.storage_path as string));
|
||||
const orphaned = paths.filter((path) => !referenced.has(path));
|
||||
if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned);
|
||||
}
|
||||
|
||||
// Schritt 2: Datensätze anlegen – nur für Pfade im eigenen Client-Ordner, die zur vergebenen ID passen.
|
||||
export async function finalizeUpload(
|
||||
profileId: string,
|
||||
folderId: string,
|
||||
uploads: { documentId: string; path: string; name: string; size: number; type: string }[]
|
||||
): Promise<DocumentActionState> {
|
||||
const access = await resolveAccess(profileId);
|
||||
if ("error" in access) return access;
|
||||
|
||||
const parsed = FinalizeSchema.safeParse(uploads);
|
||||
if (!parsed.success) return { error: "Ungültige Upload-Daten." };
|
||||
if (!(await assertFolder(access.clientId, folderId))) return { error: "Ordner nicht gefunden." };
|
||||
|
||||
const invalidPath = parsed.data.some(
|
||||
(upload) => upload.path !== `${access.clientId}/${upload.documentId}/${safeFileName(upload.name)}`
|
||||
);
|
||||
if (invalidPath) return { error: "Ungültiger Speicherpfad." };
|
||||
|
||||
const session = await verifySession();
|
||||
const supabase = await createClient();
|
||||
|
||||
// Datei muss wirklich im Storage liegen; Größe und Typ kommen von dort, nicht aus dem Request.
|
||||
const stored = await Promise.all(
|
||||
parsed.data.map(async (upload) => {
|
||||
const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).info(upload.path);
|
||||
return error || !data ? null : { upload, size: data.size ?? null, contentType: data.contentType ?? null };
|
||||
})
|
||||
);
|
||||
const verified = stored.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||
if (verified.length !== parsed.data.length) return { error: "Mindestens eine Datei wurde nicht vollständig hochgeladen." };
|
||||
|
||||
const { error } = await supabase.from("documents").insert(
|
||||
verified.map((entry) => ({
|
||||
id: entry.upload.documentId,
|
||||
client_id: access.clientId,
|
||||
folder_id: folderId,
|
||||
name: entry.upload.name,
|
||||
storage_path: entry.upload.path,
|
||||
mime_type: entry.contentType,
|
||||
size_bytes: entry.size,
|
||||
uploaded_by: session.userId,
|
||||
}))
|
||||
);
|
||||
|
||||
if (error) {
|
||||
await removeOrphanedObjects(parsed.data.map((upload) => upload.path));
|
||||
return { error: "Dateien konnten nicht gespeichert werden." };
|
||||
}
|
||||
|
||||
revalidateDocuments(profileId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getDocumentUrls(
|
||||
profileId: string,
|
||||
documentIds: string[],
|
||||
mode: "preview" | "download"
|
||||
): Promise<{ error: string } | { files: { id: string; name: string; url: string }[] }> {
|
||||
const access = await resolveAccess(profileId);
|
||||
if ("error" in access) return access;
|
||||
|
||||
const ids = z.array(z.string().uuid()).min(1).max(100).safeParse(documentIds);
|
||||
if (!ids.success) return { error: "Ungültige Auswahl." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.select("id, name, storage_path")
|
||||
.eq("client_id", access.clientId)
|
||||
.in("id", ids.data);
|
||||
|
||||
if (error || !data || data.length !== new Set(ids.data).size) return { error: "Nicht alle Dateien gefunden." };
|
||||
|
||||
const signed = await Promise.all(
|
||||
data.map(async (row) => {
|
||||
const { data: url } = await supabase.storage
|
||||
.from(DOCUMENTS_BUCKET)
|
||||
.createSignedUrl(row.storage_path, URL_TTL_SECONDS, mode === "download" ? { download: row.name } : undefined);
|
||||
return url?.signedUrl ? { id: row.id as string, name: row.name as string, url: url.signedUrl } : null;
|
||||
})
|
||||
);
|
||||
|
||||
if (signed.some((entry) => entry === null)) return { error: "Download-Link konnte nicht erstellt werden." };
|
||||
return { files: signed as { id: string; name: string; url: string }[] };
|
||||
}
|
||||
|
||||
export async function deleteDocument(profileId: string, documentId: string): Promise<DocumentActionState> {
|
||||
const access = await resolveAccess(profileId, true);
|
||||
if ("error" in access) return access;
|
||||
if (!IdSchema.safeParse(documentId).success) return { error: "Ungültige Datei." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("id", documentId)
|
||||
.eq("client_id", access.clientId)
|
||||
.select("storage_path");
|
||||
|
||||
if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." };
|
||||
|
||||
const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]);
|
||||
|
||||
revalidateDocuments(profileId);
|
||||
if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." };
|
||||
return { success: true };
|
||||
}
|
||||
163
modules/shared/actions/info.ts
Normal file
163
modules/shared/actions/info.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { getClientId } from "@/modules/shared/queries/profile";
|
||||
import { NoteSchema, ProductSchema, TaskSchema } from "@/modules/shared/schemas/info";
|
||||
|
||||
export type InfoActionState = { error: string } | { success: true } | undefined;
|
||||
|
||||
const IdSchema = z.string().uuid();
|
||||
|
||||
type Table = "client_products" | "product_tasks" | "internal_notes";
|
||||
|
||||
const DELETE_TABLES: Record<string, Table | undefined> = {
|
||||
product: "client_products",
|
||||
task: "product_tasks",
|
||||
note: "internal_notes",
|
||||
};
|
||||
|
||||
function firstError(error: z.ZodError) {
|
||||
return error.issues[0]?.message ?? "Bitte alle Pflichtfelder ausfüllen.";
|
||||
}
|
||||
|
||||
function revalidateInfo(profileId: string) {
|
||||
revalidatePath("/client/dashboard");
|
||||
revalidatePath(`/admin/clients/${profileId}`);
|
||||
}
|
||||
|
||||
// Admin-Check + Client auflösen. Alle Schreibzugriffe laufen danach zusätzlich über RLS (admin_all).
|
||||
async function resolveAdminTarget(profileId: string): Promise<{ error: string } | { clientId: string }> {
|
||||
const session = await verifySession();
|
||||
if (!session.isAdmin) return { error: "Keine Berechtigung." };
|
||||
if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." };
|
||||
|
||||
const clientId = await getClientId(profileId);
|
||||
if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." };
|
||||
|
||||
return { clientId };
|
||||
}
|
||||
|
||||
async function upsertRow(
|
||||
table: Table,
|
||||
clientId: string,
|
||||
id: string | null,
|
||||
values: Record<string, unknown>
|
||||
): Promise<boolean> {
|
||||
const supabase = await createClient();
|
||||
|
||||
if (!id) {
|
||||
const { error } = await supabase.from(table).insert({ ...values, client_id: clientId });
|
||||
return !error;
|
||||
}
|
||||
|
||||
if (!IdSchema.safeParse(id).success) return false;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from(table)
|
||||
.update(values)
|
||||
.eq("id", id)
|
||||
.eq("client_id", clientId)
|
||||
.select("id");
|
||||
|
||||
return !error && Boolean(data?.length);
|
||||
}
|
||||
|
||||
function formString(formData: FormData, key: string) {
|
||||
return String(formData.get(key) ?? "");
|
||||
}
|
||||
|
||||
function formId(formData: FormData) {
|
||||
const id = formString(formData, "id");
|
||||
return id === "" ? null : id;
|
||||
}
|
||||
|
||||
export async function saveProduct(_prev: InfoActionState, formData: FormData): Promise<InfoActionState> {
|
||||
const profileId = formString(formData, "profileId");
|
||||
const target = await resolveAdminTarget(profileId);
|
||||
if ("error" in target) return target;
|
||||
|
||||
const parsed = ProductSchema.safeParse({
|
||||
name: formString(formData, "name"),
|
||||
category: formString(formData, "category"),
|
||||
url: formString(formData, "url"),
|
||||
status: formString(formData, "status"),
|
||||
statusMessage: formString(formData, "statusMessage"),
|
||||
uptime30d: formString(formData, "uptime30d"),
|
||||
});
|
||||
if (!parsed.success) return { error: firstError(parsed.error) };
|
||||
|
||||
const ok = await upsertRow("client_products", target.clientId, formId(formData), {
|
||||
name: parsed.data.name,
|
||||
category: parsed.data.category,
|
||||
url: parsed.data.url,
|
||||
status: parsed.data.status,
|
||||
status_message: parsed.data.statusMessage,
|
||||
uptime_30d: parsed.data.uptime30d,
|
||||
});
|
||||
if (!ok) return { error: "Produkt konnte nicht gespeichert werden." };
|
||||
|
||||
revalidateInfo(profileId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function saveTask(_prev: InfoActionState, formData: FormData): Promise<InfoActionState> {
|
||||
const profileId = formString(formData, "profileId");
|
||||
const target = await resolveAdminTarget(profileId);
|
||||
if ("error" in target) return target;
|
||||
|
||||
const parsed = TaskSchema.safeParse({
|
||||
name: formString(formData, "name"),
|
||||
description: formString(formData, "description"),
|
||||
status: formString(formData, "status"),
|
||||
since: formString(formData, "since"),
|
||||
});
|
||||
if (!parsed.success) return { error: firstError(parsed.error) };
|
||||
|
||||
const ok = await upsertRow("product_tasks", target.clientId, formId(formData), parsed.data);
|
||||
if (!ok) return { error: "Vorgang konnte nicht gespeichert werden." };
|
||||
|
||||
revalidateInfo(profileId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function saveNote(_prev: InfoActionState, formData: FormData): Promise<InfoActionState> {
|
||||
const profileId = formString(formData, "profileId");
|
||||
const target = await resolveAdminTarget(profileId);
|
||||
if ("error" in target) return target;
|
||||
|
||||
const parsed = NoteSchema.safeParse({ body: formString(formData, "body") });
|
||||
if (!parsed.success) return { error: firstError(parsed.error) };
|
||||
|
||||
const ok = await upsertRow("internal_notes", target.clientId, formId(formData), parsed.data);
|
||||
if (!ok) return { error: "Notiz konnte nicht gespeichert werden." };
|
||||
|
||||
revalidateInfo(profileId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteInfoEntry(
|
||||
kind: "product" | "task" | "note",
|
||||
profileId: string,
|
||||
id: string
|
||||
): Promise<InfoActionState> {
|
||||
const target = await resolveAdminTarget(profileId);
|
||||
if ("error" in target) return target;
|
||||
const table = Object.hasOwn(DELETE_TABLES, kind) ? DELETE_TABLES[kind] : undefined;
|
||||
if (!table || !IdSchema.safeParse(id).success) return { error: "Ungültiger Eintrag." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq("id", id)
|
||||
.eq("client_id", target.clientId)
|
||||
.select("id");
|
||||
|
||||
if (error || !data?.length) return { error: "Eintrag konnte nicht gelöscht werden." };
|
||||
|
||||
revalidateInfo(profileId);
|
||||
return { success: true };
|
||||
}
|
||||
95
modules/shared/actions/invoices.ts
Normal file
95
modules/shared/actions/invoices.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { getClientId } from "@/modules/shared/queries/profile";
|
||||
import {
|
||||
INVOICE_STATUSES,
|
||||
InvoiceDraftSchema,
|
||||
firstIssueMessage,
|
||||
type InvoiceDraft,
|
||||
type InvoiceStatus,
|
||||
} from "@/modules/shared/schemas/invoice";
|
||||
import { toIsoDate } from "@/modules/shared/lib/invoice-math";
|
||||
|
||||
export type InvoiceActionState = { error: string } | { success: true };
|
||||
|
||||
function revalidateBilling(profileId: string) {
|
||||
revalidatePath("/client/billing");
|
||||
revalidatePath(`/admin/clients/${profileId}/billing`);
|
||||
}
|
||||
|
||||
export async function createInvoice(draft: InvoiceDraft): Promise<InvoiceActionState> {
|
||||
const session = await verifySession();
|
||||
if (!session.isAdmin) return { error: "Keine Berechtigung." };
|
||||
|
||||
const validated = InvoiceDraftSchema.safeParse(draft);
|
||||
if (!validated.success) return { error: firstIssueMessage(validated.error) };
|
||||
|
||||
const data = validated.data;
|
||||
const clientId = await getClientId(data.profileId);
|
||||
if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc("create_invoice", {
|
||||
p_client_id: clientId,
|
||||
p_invoice_date: data.invoiceDate,
|
||||
p_due_date: data.dueDate,
|
||||
p_company_name: data.companyName,
|
||||
p_billing_street: data.billingStreet,
|
||||
p_billing_postal_code: data.billingPostalCode,
|
||||
p_billing_city: data.billingCity,
|
||||
p_billing_country: data.billingCountry.toUpperCase(),
|
||||
p_items: data.items.map((item) => ({
|
||||
product_template_id: item.productTemplateId,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice,
|
||||
tax_rate: item.taxRate,
|
||||
})),
|
||||
});
|
||||
|
||||
if (error) return { error: "Rechnung konnte nicht erstellt werden." };
|
||||
|
||||
revalidateBilling(data.profileId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const StatusSchema = z.object({
|
||||
profileId: z.string().uuid(),
|
||||
invoiceId: z.string().uuid(),
|
||||
status: z.enum(INVOICE_STATUSES),
|
||||
});
|
||||
|
||||
export async function updateInvoiceStatus(
|
||||
profileId: string,
|
||||
invoiceId: string,
|
||||
status: InvoiceStatus
|
||||
): Promise<InvoiceActionState> {
|
||||
const session = await verifySession();
|
||||
if (!session.isAdmin) return { error: "Keine Berechtigung." };
|
||||
|
||||
const validated = StatusSchema.safeParse({ profileId, invoiceId, status });
|
||||
if (!validated.success) return { error: "Ungültiger Status." };
|
||||
|
||||
const clientId = await getClientId(validated.data.profileId);
|
||||
if (!clientId) return { error: "Status konnte nicht geändert werden." };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("invoices")
|
||||
.update({
|
||||
status: validated.data.status,
|
||||
paid_at: validated.data.status === "paid" ? toIsoDate(new Date()) : null,
|
||||
})
|
||||
.eq("id", validated.data.invoiceId)
|
||||
.eq("client_id", clientId)
|
||||
.select("id");
|
||||
|
||||
if (error || !data?.length) return { error: "Status konnte nicht geändert werden." };
|
||||
|
||||
revalidateBilling(validated.data.profileId);
|
||||
return { success: true };
|
||||
}
|
||||
108
modules/shared/actions/product-checks.ts
Normal file
108
modules/shared/actions/product-checks.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { getClientId } from "@/modules/shared/queries/profile";
|
||||
import { checkUrl, toCheckableUrl, type CheckResult } from "@/modules/shared/lib/reachability";
|
||||
import { AUTO_CHECK_STALE_AFTER_MS } from "@/modules/shared/schemas/info";
|
||||
|
||||
const MANUAL_MIN_INTERVAL_MS = 30 * 1000;
|
||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const IdSchema = z.string().uuid();
|
||||
|
||||
export type CheckActionResult = { error: string } | { result: CheckResult };
|
||||
|
||||
type CheckableProduct = { id: string; url: URL; lastCheckedAt: string | null };
|
||||
|
||||
// Lesen über RLS (Admin: alle, Client: nur eigene) – damit ist gleichzeitig geprüft, ob der Aufrufer das Produkt sehen darf.
|
||||
async function loadCheckableProducts(profileId: string, productId?: string) {
|
||||
const session = await verifySession();
|
||||
if (!IdSchema.safeParse(profileId).success) return null;
|
||||
if (!session.isAdmin && session.userId !== profileId) return null;
|
||||
|
||||
const clientId = await getClientId(profileId);
|
||||
if (!clientId) return null;
|
||||
|
||||
const supabase = await createClient();
|
||||
let query = supabase.from("client_products").select("id, url").eq("client_id", clientId);
|
||||
if (productId) query = query.eq("id", productId);
|
||||
const { data: products, error } = await query;
|
||||
if (error || !products?.length) return null;
|
||||
|
||||
const { data: summaries } = await supabase
|
||||
.from("product_check_summary")
|
||||
.select("product_id, checked_at")
|
||||
.in("product_id", products.map((product) => product.id));
|
||||
|
||||
const lastChecked = new Map((summaries ?? []).map((row) => [row.product_id as string, row.checked_at as string | null]));
|
||||
|
||||
const checkable: CheckableProduct[] = [];
|
||||
for (const product of products) {
|
||||
const url = toCheckableUrl(product.url);
|
||||
if (url) checkable.push({ id: product.id, url, lastCheckedAt: lastChecked.get(product.id) ?? null });
|
||||
}
|
||||
return { clientId, products: checkable };
|
||||
}
|
||||
|
||||
async function runAndStore(clientId: string, products: CheckableProduct[]) {
|
||||
const results = await Promise.all(products.map(async (product) => ({ product, result: await checkUrl(product.url) })));
|
||||
|
||||
// Service Role, weil es bewusst keine Insert-Policy gibt (Clients sollen Ergebnisse nicht selbst schreiben können).
|
||||
const admin = createAdminClient();
|
||||
await admin.from("product_checks").insert(
|
||||
results.map(({ product, result }) => ({
|
||||
product_id: product.id,
|
||||
client_id: clientId,
|
||||
ok: result.ok,
|
||||
status_code: result.statusCode,
|
||||
response_ms: result.responseMs,
|
||||
error: result.error,
|
||||
}))
|
||||
);
|
||||
await admin
|
||||
.from("product_checks")
|
||||
.delete()
|
||||
.in("product_id", products.map((product) => product.id))
|
||||
.lt("checked_at", new Date(Date.now() - RETENTION_MS).toISOString());
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function revalidateInfo(profileId: string) {
|
||||
revalidatePath("/client/dashboard");
|
||||
revalidatePath(`/admin/clients/${profileId}`);
|
||||
}
|
||||
|
||||
function olderThan(timestamp: string | null, ms: number) {
|
||||
return timestamp === null || Date.now() - Date.parse(timestamp) > ms;
|
||||
}
|
||||
|
||||
export async function checkProduct(profileId: string, productId: string): Promise<CheckActionResult> {
|
||||
if (!IdSchema.safeParse(productId).success) return { error: "Ungültiges Produkt." };
|
||||
|
||||
const loaded = await loadCheckableProducts(profileId, productId);
|
||||
const product = loaded?.products[0];
|
||||
if (!loaded || !product) return { error: "Dieses Produkt kann nicht automatisch geprüft werden." };
|
||||
|
||||
if (!olderThan(product.lastCheckedAt, MANUAL_MIN_INTERVAL_MS)) {
|
||||
return { error: "Wurde gerade erst geprüft. Bitte kurz warten." };
|
||||
}
|
||||
|
||||
const [{ result }] = await runAndStore(loaded.clientId, [product]);
|
||||
revalidateInfo(profileId);
|
||||
return { result };
|
||||
}
|
||||
|
||||
export async function checkStaleProducts(profileId: string): Promise<{ checked: number }> {
|
||||
const loaded = await loadCheckableProducts(profileId);
|
||||
const stale = loaded?.products.filter((product) => olderThan(product.lastCheckedAt, AUTO_CHECK_STALE_AFTER_MS)) ?? [];
|
||||
if (!loaded || stale.length === 0) return { checked: 0 };
|
||||
|
||||
await runAndStore(loaded.clientId, stale);
|
||||
revalidateInfo(profileId);
|
||||
return { checked: stale.length };
|
||||
}
|
||||
226
modules/shared/actions/stammdaten.ts
Normal file
226
modules/shared/actions/stammdaten.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
import { getClientId } from "@/modules/shared/queries/profile";
|
||||
|
||||
const IBAN_REGEX = /^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/;
|
||||
|
||||
const StammdatenSchema = z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
|
||||
companyName: z.string().min(1, { error: "Firmenname ist erforderlich." }),
|
||||
legalForm: z.string().min(1, { error: "Rechtsform ist erforderlich." }),
|
||||
industry: z.string().optional(),
|
||||
vatId: z.string().optional(),
|
||||
taxNumber: z.string().optional(),
|
||||
commercialRegisterNumber: z.string().optional(),
|
||||
|
||||
street: z.string().min(1, { error: "Straße ist erforderlich." }),
|
||||
postalCode: z.string().min(1, { error: "PLZ ist erforderlich." }),
|
||||
city: z.string().min(1, { error: "Stadt ist erforderlich." }),
|
||||
country: z.string().length(2).default("DE"),
|
||||
|
||||
billingStreet: z.string().optional(),
|
||||
billingPostalCode: z.string().optional(),
|
||||
billingCity: z.string().optional(),
|
||||
billingCountry: z.string().optional(),
|
||||
|
||||
contactName: z.string().min(1, { error: "Ansprechpartner ist erforderlich." }),
|
||||
contactPosition: z.string().optional(),
|
||||
contactEmail: z.email({ error: "Bitte eine gültige Email eingeben." }),
|
||||
contactPhone: z.string().optional(),
|
||||
|
||||
paymentMethod: z.enum(["invoice", "sepa_debit", "card"]).default("invoice"),
|
||||
accountHolder: z.string().optional(),
|
||||
iban: z.string().optional(),
|
||||
bic: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.paymentMethod === "sepa_debit") {
|
||||
if (!data.accountHolder) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["accountHolder"],
|
||||
message: "Kontoinhaber ist bei SEPA-Lastschrift erforderlich.",
|
||||
});
|
||||
}
|
||||
if (!data.iban) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["iban"],
|
||||
message: "IBAN ist bei SEPA-Lastschrift erforderlich.",
|
||||
});
|
||||
} else if (!IBAN_REGEX.test(data.iban)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["iban"],
|
||||
message: "Bitte eine gültige IBAN eingeben.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const billingFields = [
|
||||
data.billingStreet,
|
||||
data.billingPostalCode,
|
||||
data.billingCity,
|
||||
data.billingCountry,
|
||||
];
|
||||
const filledCount = billingFields.filter(Boolean).length;
|
||||
|
||||
if (filledCount > 0 && filledCount < billingFields.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["billingStreet"],
|
||||
message: "Bitte alle Felder der Rechnungsadresse ausfüllen oder alle leer lassen.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type StammdatenState = { error: string } | { success: true } | undefined;
|
||||
|
||||
function emptyToUndefined(value: FormDataEntryValue | null) {
|
||||
const stringValue = String(value ?? "").trim();
|
||||
return stringValue === "" ? undefined : stringValue;
|
||||
}
|
||||
|
||||
export async function saveStammdaten(
|
||||
_prevState: StammdatenState,
|
||||
formData: FormData
|
||||
): Promise<StammdatenState> {
|
||||
const session = await verifySession();
|
||||
const profileId = String(formData.get("profileId") ?? "");
|
||||
|
||||
if (!session.isAdmin && session.userId !== profileId) {
|
||||
return { error: "Keine Berechtigung." };
|
||||
}
|
||||
|
||||
const validatedFields = StammdatenSchema.safeParse({
|
||||
profileId,
|
||||
companyName: emptyToUndefined(formData.get("companyName")),
|
||||
legalForm: emptyToUndefined(formData.get("legalForm")),
|
||||
industry: emptyToUndefined(formData.get("industry")),
|
||||
vatId: emptyToUndefined(formData.get("vatId")),
|
||||
taxNumber: emptyToUndefined(formData.get("taxNumber")),
|
||||
commercialRegisterNumber: emptyToUndefined(formData.get("commercialRegisterNumber")),
|
||||
|
||||
street: emptyToUndefined(formData.get("street")),
|
||||
postalCode: emptyToUndefined(formData.get("postalCode")),
|
||||
city: emptyToUndefined(formData.get("city")),
|
||||
country: emptyToUndefined(formData.get("country")) ?? "DE",
|
||||
|
||||
billingStreet: emptyToUndefined(formData.get("billingStreet")),
|
||||
billingPostalCode: emptyToUndefined(formData.get("billingPostalCode")),
|
||||
billingCity: emptyToUndefined(formData.get("billingCity")),
|
||||
billingCountry: emptyToUndefined(formData.get("billingCountry")),
|
||||
|
||||
contactName: emptyToUndefined(formData.get("contactName")),
|
||||
contactPosition: emptyToUndefined(formData.get("contactPosition")),
|
||||
contactEmail: emptyToUndefined(formData.get("contactEmail")),
|
||||
contactPhone: emptyToUndefined(formData.get("contactPhone")),
|
||||
|
||||
paymentMethod: emptyToUndefined(formData.get("paymentMethod")) ?? "invoice",
|
||||
accountHolder: emptyToUndefined(formData.get("accountHolder")),
|
||||
iban: emptyToUndefined(formData.get("iban")),
|
||||
bic: emptyToUndefined(formData.get("bic")),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||
const firstError = Object.values(fieldErrors).flat()[0];
|
||||
return { error: firstError ?? "Bitte alle Pflichtfelder ausfüllen." };
|
||||
}
|
||||
|
||||
const {
|
||||
billingStreet,
|
||||
billingPostalCode,
|
||||
billingCity,
|
||||
billingCountry,
|
||||
paymentMethod,
|
||||
accountHolder,
|
||||
iban,
|
||||
bic,
|
||||
...data
|
||||
} = validatedFields.data;
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const clientPayload = {
|
||||
company_name: data.companyName,
|
||||
legal_form: data.legalForm,
|
||||
industry: data.industry ?? null,
|
||||
vat_id: data.vatId ?? null,
|
||||
tax_number: data.taxNumber ?? null,
|
||||
commercial_register_number: data.commercialRegisterNumber ?? null,
|
||||
street: data.street,
|
||||
postal_code: data.postalCode,
|
||||
city: data.city,
|
||||
country: data.country,
|
||||
billing_street: billingStreet ?? null,
|
||||
billing_postal_code: billingPostalCode ?? null,
|
||||
billing_city: billingCity ?? null,
|
||||
billing_country: billingCountry ?? null,
|
||||
contact_name: data.contactName,
|
||||
contact_position: data.contactPosition ?? null,
|
||||
contact_email: data.contactEmail,
|
||||
contact_phone: data.contactPhone ?? null,
|
||||
};
|
||||
|
||||
let clientId: string | null;
|
||||
try {
|
||||
clientId = await getClientId(data.profileId);
|
||||
} catch {
|
||||
return { error: "Profil konnte nicht geladen werden." };
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
const { error } = await supabase.from("clients").update(clientPayload).eq("id", clientId);
|
||||
if (error) return { error: "Stammdaten konnten nicht gespeichert werden." };
|
||||
} else {
|
||||
const { data: newClientId, error } = await supabase.rpc("create_client_and_link_profile", {
|
||||
p_profile_id: data.profileId,
|
||||
p_company_name: clientPayload.company_name,
|
||||
p_legal_form: clientPayload.legal_form,
|
||||
p_industry: clientPayload.industry,
|
||||
p_vat_id: clientPayload.vat_id,
|
||||
p_tax_number: clientPayload.tax_number,
|
||||
p_commercial_register_number: clientPayload.commercial_register_number,
|
||||
p_street: clientPayload.street,
|
||||
p_postal_code: clientPayload.postal_code,
|
||||
p_city: clientPayload.city,
|
||||
p_country: clientPayload.country,
|
||||
p_billing_street: clientPayload.billing_street,
|
||||
p_billing_postal_code: clientPayload.billing_postal_code,
|
||||
p_billing_city: clientPayload.billing_city,
|
||||
p_billing_country: clientPayload.billing_country,
|
||||
p_contact_name: clientPayload.contact_name,
|
||||
p_contact_position: clientPayload.contact_position,
|
||||
p_contact_email: clientPayload.contact_email,
|
||||
p_contact_phone: clientPayload.contact_phone,
|
||||
});
|
||||
|
||||
if (error || !newClientId) return { error: "Stammdaten konnten nicht gespeichert werden." };
|
||||
|
||||
clientId = newClientId;
|
||||
}
|
||||
|
||||
const { error: paymentError } = await supabase.from("client_payment_details").upsert({
|
||||
client_id: clientId,
|
||||
payment_method: paymentMethod,
|
||||
account_holder: accountHolder ?? null,
|
||||
iban: iban ?? null,
|
||||
bic: bic ?? null,
|
||||
});
|
||||
|
||||
if (paymentError) {
|
||||
return { error: "Zahlungsdaten konnten nicht gespeichert werden." };
|
||||
}
|
||||
|
||||
revalidatePath("/client/data");
|
||||
revalidatePath(`/admin/clients/${data.profileId}/data`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
24
modules/shared/data/services-data.ts
Normal file
24
modules/shared/data/services-data.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export type Service = {
|
||||
id: string;
|
||||
label: string;
|
||||
logo: string;
|
||||
};
|
||||
|
||||
export const SERVICES: Service[] = [
|
||||
{ id: "vercel", label: "Vercel", logo: "/logos/vercel.svg" },
|
||||
{ id: "hostinger", label: "Hostinger", logo: "/logos/hostinger.svg" },
|
||||
{ id: "hostinger-webmail", label: "Hostinger Webmail", logo: "/logos/hostinger.svg" },
|
||||
{ id: "supabase", label: "Supabase", logo: "/logos/supabase.svg" },
|
||||
{ id: "github", label: "GitHub", logo: "/logos/github.svg" },
|
||||
{ id: "shopify", label: "Shopify", logo: "/logos/shopify.svg" },
|
||||
{ id: "wordpress", label: "Wordpress", logo: "/logos/wordpress.svg" },
|
||||
{ id: "google-workspace", label: "Google Workspace", logo: "/logos/google.svg" },
|
||||
{ id: "google-ads", label: "Google Ads", logo: "/logos/google-ads.svg" },
|
||||
{ id: "sanity", label: "Sanity", logo: "/logos/sanity.svg" },
|
||||
{ id: "stripe", label: "Stripe", logo: "/logos/stripe.svg" },
|
||||
{ id: "other", label: "Sonstiges", logo: "" },
|
||||
];
|
||||
|
||||
export function getServiceById(id: string): Service {
|
||||
return SERVICES.find((service) => service.id === id) ?? SERVICES[SERVICES.length - 1];
|
||||
}
|
||||
96
modules/shared/data/sidebar-menu-content.ts
Normal file
96
modules/shared/data/sidebar-menu-content.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import {
|
||||
FileTextIcon,
|
||||
FoldersIcon,
|
||||
InfoIcon,
|
||||
KeyIcon,
|
||||
LayoutDashboardIcon,
|
||||
LucideIcon,
|
||||
SettingsIcon,
|
||||
UserRoundIcon,
|
||||
UsersRoundIcon
|
||||
}
|
||||
from "lucide-react";
|
||||
|
||||
export type MenuItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const adminMenu: MenuItem[] = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/admin/dashboard",
|
||||
icon: LayoutDashboardIcon
|
||||
},
|
||||
{
|
||||
title: "Clients",
|
||||
url: "/admin/clients",
|
||||
icon: UsersRoundIcon
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/admin/settings",
|
||||
icon: SettingsIcon
|
||||
}
|
||||
]
|
||||
|
||||
export const clientMenu: MenuItem[] = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/client/dashboard",
|
||||
icon: LayoutDashboardIcon
|
||||
},
|
||||
{
|
||||
title: "Stammdaten",
|
||||
url: "/client/data",
|
||||
icon: UserRoundIcon
|
||||
},
|
||||
{
|
||||
title: "Rechungen",
|
||||
url: "/client/billing",
|
||||
icon: FileTextIcon
|
||||
},
|
||||
{
|
||||
title: "Passwörter",
|
||||
url: "/client/passwords",
|
||||
icon: KeyIcon
|
||||
},
|
||||
{
|
||||
title: "Dokumente",
|
||||
url: "/client/storage",
|
||||
icon: FoldersIcon
|
||||
}
|
||||
]
|
||||
|
||||
export function getClientDetailMenu(clientId: string): MenuItem[] {
|
||||
const base = `/admin/clients/${clientId}`
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Info",
|
||||
url: base,
|
||||
icon: InfoIcon
|
||||
},
|
||||
{
|
||||
title: "Stammdaten",
|
||||
url: `${base}/data`,
|
||||
icon: UserRoundIcon
|
||||
},
|
||||
{
|
||||
title: "Rechnungen",
|
||||
url: `${base}/billing`,
|
||||
icon: FileTextIcon
|
||||
},
|
||||
{
|
||||
title: "Passwörter",
|
||||
url: `${base}/passwords`,
|
||||
icon: KeyIcon
|
||||
},
|
||||
{
|
||||
title: "Dokumente",
|
||||
url: `${base}/storage`,
|
||||
icon: FoldersIcon
|
||||
}
|
||||
]
|
||||
}
|
||||
14
modules/shared/data/stammdaten-data.ts
Normal file
14
modules/shared/data/stammdaten-data.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
|
||||
export const LEGAL_FORMS = [
|
||||
"Einzelunternehmen",
|
||||
"GbR",
|
||||
"GmbH",
|
||||
"UG (haftungsbeschränkt)",
|
||||
"AG",
|
||||
"OHG",
|
||||
"KG",
|
||||
"GmbH & Co. KG",
|
||||
"e.K.",
|
||||
"e.V.",
|
||||
];
|
||||
45
modules/shared/lib/info-format.ts
Normal file
45
modules/shared/lib/info-format.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { TaskStatus } from "@/modules/shared/schemas/info";
|
||||
|
||||
const TIME_ZONE = "Europe/Berlin";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Server läuft in UTC; "heute" soll aber der Kalendertag in Deutschland sein.
|
||||
export function berlinToday() {
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone: TIME_ZONE }).format(new Date());
|
||||
}
|
||||
|
||||
function daysBetween(fromIso: string, toIso: string) {
|
||||
return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / DAY_MS);
|
||||
}
|
||||
|
||||
const shortDate = new Intl.DateTimeFormat("de-DE", { day: "numeric", month: "short", timeZone: "UTC" });
|
||||
|
||||
export function formatTaskTiming(status: TaskStatus, since: string, today = berlinToday()) {
|
||||
const days = daysBetween(since, today);
|
||||
|
||||
if (status === "planned" || days < 0) {
|
||||
return `ab ${shortDate.format(new Date(`${since}T00:00:00Z`))}`;
|
||||
}
|
||||
if (days === 0) return "seit heute";
|
||||
if (days === 1) return "seit gestern";
|
||||
return `seit ${days} Tagen`;
|
||||
}
|
||||
|
||||
const dateTime = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: TIME_ZONE,
|
||||
});
|
||||
|
||||
export function formatDateTime(timestamp: string) {
|
||||
return `${dateTime.format(new Date(timestamp))} Uhr`;
|
||||
}
|
||||
|
||||
const percent = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
export function formatUptime(value: number) {
|
||||
return `${percent.format(value)} %`;
|
||||
}
|
||||
50
modules/shared/lib/invoice-math.ts
Normal file
50
modules/shared/lib/invoice-math.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import Decimal from "decimal.js";
|
||||
|
||||
type Item = { quantity: number; unitPrice: number; taxRate: number };
|
||||
|
||||
// Dezimal statt Float, damit Summen exakt der View invoices_overview entsprechen
|
||||
// (Float: 0.01 * 14.50 = 0.14499… → 0.14, Postgres numeric: 0.15).
|
||||
const decimal = (value: number) => new Decimal(value.toString());
|
||||
const round2 = (value: Decimal) => value.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);
|
||||
|
||||
const lineNetDecimal = (item: Item) => round2(decimal(item.quantity).times(decimal(item.unitPrice)));
|
||||
|
||||
const lineTaxDecimal = (item: Item) =>
|
||||
round2(decimal(item.quantity).times(decimal(item.unitPrice)).times(decimal(item.taxRate)).div(100));
|
||||
|
||||
export function lineNet(item: Item) {
|
||||
return lineNetDecimal(item).toNumber();
|
||||
}
|
||||
|
||||
export function invoiceTotals(items: Item[]) {
|
||||
const net = items.reduce((sum, item) => sum.plus(lineNetDecimal(item)), new Decimal(0));
|
||||
const tax = items.reduce((sum, item) => sum.plus(lineTaxDecimal(item)), new Decimal(0));
|
||||
const rates = new Set(items.map((item) => item.taxRate));
|
||||
return {
|
||||
net: net.toNumber(),
|
||||
tax: tax.toNumber(),
|
||||
gross: net.plus(tax).toNumber(),
|
||||
uniformTaxRate: rates.size === 1 ? [...rates][0] : null,
|
||||
};
|
||||
}
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
export function formatAmount(value: number) {
|
||||
return numberFormat.format(value);
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number) {
|
||||
return `${numberFormat.format(value)} €`;
|
||||
}
|
||||
|
||||
export function formatDate(isoDate: string) {
|
||||
const [year, month, day] = isoDate.slice(0, 10).split("-");
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function toIsoDate(date: Date) {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
11
modules/shared/lib/parse-decimal.ts
Normal file
11
modules/shared/lib/parse-decimal.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Deutsches Format mit Tausenderpunkten ("1.000", "1.234,50") oder schlicht ("12,5", "12.50").
|
||||
// Mehrdeutiges wie "1.5,00" oder mehr als 2 Nachkommastellen ergibt NaN statt still falsch zu rechnen.
|
||||
const GROUPED = /^\d{1,3}(\.\d{3})*(,\d{1,2})?$/;
|
||||
const PLAIN = /^\d+([.,]\d{1,2})?$/;
|
||||
|
||||
export function parseDecimalInput(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (GROUPED.test(trimmed)) return Number(trimmed.replace(/\./g, "").replace(",", "."));
|
||||
if (PLAIN.test(trimmed)) return Number(trimmed.replace(",", "."));
|
||||
return Number.NaN;
|
||||
}
|
||||
124
modules/shared/lib/reachability.ts
Normal file
124
modules/shared/lib/reachability.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import "server-only"
|
||||
import dns from "node:dns"
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import net from "node:net"
|
||||
|
||||
export type CheckResult = {
|
||||
ok: boolean
|
||||
statusCode: number | null
|
||||
responseMs: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 8000
|
||||
|
||||
// Nur öffentliche Ziele: schützt davor, über den Check interne Dienste (Metadata-API, localhost, LAN) anzusprechen.
|
||||
const blocked = new net.BlockList()
|
||||
for (const [address, prefix] of [
|
||||
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
|
||||
["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
||||
] as const) {
|
||||
blocked.addSubnet(address, prefix, "ipv4")
|
||||
}
|
||||
for (const [address, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]] as const) {
|
||||
blocked.addSubnet(address, prefix, "ipv6")
|
||||
}
|
||||
|
||||
function isBlockedAddress(address: string, family: number) {
|
||||
const mappedV4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)?.[1]
|
||||
if (mappedV4) return blocked.check(mappedV4, "ipv4")
|
||||
return blocked.check(address, family === 6 ? "ipv6" : "ipv4")
|
||||
}
|
||||
|
||||
// Wird von http(s).request für jede Verbindung genutzt, also auch gegen DNS-Rebinding zwischen Prüfung und Connect.
|
||||
const safeLookup: net.LookupFunction = (hostname, options, callback) => {
|
||||
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
||||
if (error) return callback(error, "", 4)
|
||||
const list = addresses as dns.LookupAddress[]
|
||||
const forbidden = list.find((entry) => isBlockedAddress(entry.address, entry.family))
|
||||
if (list.length === 0 || forbidden) {
|
||||
return callback(Object.assign(new Error("Ziel nicht erlaubt"), { code: "EBLOCKED" }), "", 4)
|
||||
}
|
||||
if (options.all) return (callback as unknown as (err: null, addresses: dns.LookupAddress[]) => void)(null, list)
|
||||
callback(null, list[0].address, list[0].family)
|
||||
})
|
||||
}
|
||||
|
||||
const DOMAIN_PATTERN = /^(https?:\/\/)?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d{1,5})?(\/\S*)?$/i
|
||||
|
||||
// "www.firma.de" oder "https://firma.de/shop" → prüfbar; "Server", "info@firma.de" → nicht.
|
||||
export function toCheckableUrl(value: string | null): URL | null {
|
||||
if (!value) return null
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.includes("@") || !DOMAIN_PATTERN.test(trimmed)) return null
|
||||
try {
|
||||
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`)
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function describeError(error: NodeJS.ErrnoException) {
|
||||
switch (error.code) {
|
||||
case "EBLOCKED": return "Ziel nicht erlaubt"
|
||||
case "ENOTFOUND": case "EAI_AGAIN": return "Domain nicht gefunden"
|
||||
case "ECONNREFUSED": return "Verbindung abgelehnt"
|
||||
case "ECONNRESET": return "Verbindung abgebrochen"
|
||||
case "ETIMEDOUT": return "Zeitüberschreitung"
|
||||
default:
|
||||
return error.code?.startsWith("ERR_TLS") || error.code?.includes("CERT")
|
||||
? "SSL-Zertifikat ungültig"
|
||||
: "Nicht erreichbar"
|
||||
}
|
||||
}
|
||||
|
||||
export function checkUrl(url: URL): Promise<CheckResult> {
|
||||
// Bei IP-Literalen ruft Node den lookup-Hook nicht auf, daher hier separat prüfen.
|
||||
const host = url.hostname.replace(/^\[|\]$/g, "")
|
||||
const ipFamily = net.isIP(host)
|
||||
if (ipFamily !== 0 && isBlockedAddress(host, ipFamily)) {
|
||||
return Promise.resolve({ ok: false, statusCode: null, responseMs: null, error: "Ziel nicht erlaubt" })
|
||||
}
|
||||
|
||||
const client = url.protocol === "http:" ? http : https
|
||||
const started = performance.now()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (result: CheckResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const request = client.request(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
lookup: safeLookup,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { "User-Agent": "Since3-Statuscheck/1.0", Accept: "text/html,*/*" },
|
||||
},
|
||||
(response) => {
|
||||
const statusCode = response.statusCode ?? 0
|
||||
response.destroy()
|
||||
finish({
|
||||
ok: statusCode > 0 && statusCode < 400,
|
||||
statusCode,
|
||||
responseMs: Math.round(performance.now() - started),
|
||||
error: statusCode >= 400 ? `HTTP ${statusCode}` : null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
request.on("timeout", () => {
|
||||
request.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }))
|
||||
})
|
||||
request.on("error", (error: NodeJS.ErrnoException) => {
|
||||
finish({ ok: false, statusCode: null, responseMs: null, error: describeError(error) })
|
||||
})
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
BIN
modules/shared/pdf/assets/GoogleSans-bold.ttf
Normal file
BIN
modules/shared/pdf/assets/GoogleSans-bold.ttf
Normal file
Binary file not shown.
BIN
modules/shared/pdf/assets/GoogleSans-regular.ttf
Normal file
BIN
modules/shared/pdf/assets/GoogleSans-regular.ttf
Normal file
Binary file not shown.
BIN
modules/shared/pdf/assets/gradient-line.png
Normal file
BIN
modules/shared/pdf/assets/gradient-line.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
BIN
modules/shared/pdf/assets/since3-logo.jpg
Normal file
BIN
modules/shared/pdf/assets/since3-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
214
modules/shared/pdf/invoice-document.tsx
Normal file
214
modules/shared/pdf/invoice-document.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { Document, Image as PdfImage, Link, Page, StyleSheet, Text, View } from "@react-pdf/renderer"
|
||||
import type { InvoicePdfData } from "@/modules/shared/schemas/invoice"
|
||||
import { formatAmount, formatCurrency, formatDate, invoiceTotals, lineNet } from "@/modules/shared/lib/invoice-math"
|
||||
|
||||
const COMPANY = {
|
||||
name: "Since3 GbR",
|
||||
street: "Sandweg 93",
|
||||
city: "48565 Steinfurt",
|
||||
phone: "+49 176 47601638",
|
||||
email: "info@since3.de",
|
||||
web: "www.since3.de",
|
||||
taxNumber: "311/5963/2094",
|
||||
accountHolder: "Since3",
|
||||
iban: "DE35 4007 0211 0068 9224 00",
|
||||
bic: "DEUTDE3BP10",
|
||||
bank: "Postbank",
|
||||
partners: "Ben Luis Dalhoff, Rajpreet Singh",
|
||||
// Kleinunternehmerregelung nach § 19 UStG. Bei Wechsel zur Regelbesteuerung auf false setzen.
|
||||
smallBusiness: true,
|
||||
}
|
||||
|
||||
const INK = "#1a1a1a"
|
||||
const MUTED = "#6e6e6e"
|
||||
const LINK = "#1155cc"
|
||||
const RULE = "#d9d9d9"
|
||||
|
||||
// Maße aus Rechnung_Template_Since3_.docx (twips / 20 = pt)
|
||||
const ITEM_COLUMNS = [45, 225.3, 75, 75, 85]
|
||||
const SUMMARY_COLUMNS = [85.3, 84, 84, 84, 84, 84]
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Ligaturen aus, sonst wird "tt"/"ff" beim Kopieren/Auslesen falsch extrahiert ("Bite" statt "Bitte").
|
||||
page: { paddingTop: 35, paddingBottom: 95, paddingHorizontal: 45, fontFamily: "Google Sans", fontSize: 9, color: "#000", fontFeatureSettings: { liga: false, clig: false } },
|
||||
gradient: { width: 499.5, height: 5.25 },
|
||||
row: { flexDirection: "row" },
|
||||
left: { width: 305.3 },
|
||||
right: { width: 200, alignItems: "flex-end" },
|
||||
senderLine: { fontSize: 7, color: MUTED, marginTop: 14, marginBottom: 6 },
|
||||
recipient: { fontSize: 10.5, lineHeight: 1.35 },
|
||||
bold: { fontWeight: 700 },
|
||||
logo: { width: 122, height: 21.75, marginTop: 10 },
|
||||
companyInfo: { fontSize: 8.5, textAlign: "right", lineHeight: 1.35 },
|
||||
title: { fontSize: 20, fontWeight: 700, color: INK },
|
||||
refBox: { width: 190.5, backgroundColor: "#f2f2f2", padding: 8, gap: 2 },
|
||||
refHint: { fontSize: 8, fontWeight: 700, marginBottom: 2 },
|
||||
refLine: { fontSize: 8.5, fontWeight: 700, color: MUTED },
|
||||
itemHeader: { flexDirection: "row", backgroundColor: INK, color: "#fff", fontSize: 8, fontWeight: 700 },
|
||||
itemRow: { flexDirection: "row", borderBottomWidth: 0.5, borderBottomColor: RULE, fontSize: 9 },
|
||||
cell: { paddingVertical: 5, paddingHorizontal: 5 },
|
||||
right_: { textAlign: "right" },
|
||||
summaryHeader: { flexDirection: "row", borderTopWidth: 1, borderTopColor: INK, fontSize: 7.5, fontWeight: 700, color: MUTED },
|
||||
summaryValues: { flexDirection: "row", fontSize: 9 },
|
||||
total: { fontSize: 11, fontWeight: 700, color: INK, backgroundColor: "#f2f2f2" },
|
||||
note: { fontSize: 8, color: MUTED, marginTop: 10 },
|
||||
paymentBox: { borderWidth: 0.5, borderColor: RULE, backgroundColor: "#fafafa", padding: 10, marginTop: 16, gap: 2 },
|
||||
paymentTitle: { fontSize: 9.5, fontWeight: 700, color: INK, marginBottom: 4 },
|
||||
paymentText: { fontSize: 9, fontWeight: 700 },
|
||||
footer: { position: "absolute", bottom: 35, left: 45, right: 45, gap: 4 },
|
||||
footerText: { fontSize: 7, color: MUTED },
|
||||
})
|
||||
|
||||
type PdfStyle = React.ComponentProps<typeof View>["style"]
|
||||
|
||||
function Cell({ width, align, children, style }: {
|
||||
width: number
|
||||
align?: "right"
|
||||
children: React.ReactNode
|
||||
style?: PdfStyle
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.cell, { width }, style]}>
|
||||
<Text style={align === "right" ? styles.right_ : {}}>{children}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type InvoiceAssets = {
|
||||
logo: { data: Buffer; format: "jpg" }
|
||||
gradient: { data: Buffer; format: "png" }
|
||||
}
|
||||
|
||||
function InvoicePage({ invoice, assets }: { invoice: InvoicePdfData; assets: InvoiceAssets }) {
|
||||
const totals = invoiceTotals(invoice.items)
|
||||
const showCountry = invoice.billingCountry.toUpperCase() !== "DE"
|
||||
|
||||
return (
|
||||
<Page size="A4" style={styles.page}>
|
||||
<PdfImage src={assets.gradient} style={styles.gradient} />
|
||||
|
||||
<View style={styles.row}>
|
||||
<View style={styles.left}>
|
||||
<Text style={styles.senderLine}>
|
||||
{COMPANY.name} · {COMPANY.street} · {COMPANY.city}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
<PdfImage src={assets.logo} style={styles.logo} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.row, { marginTop: 4 }]}>
|
||||
<View style={[styles.left, styles.recipient]}>
|
||||
<Text style={styles.bold}>{invoice.companyName}</Text>
|
||||
<Text>{invoice.billingStreet}</Text>
|
||||
<Text>{invoice.billingPostalCode} {invoice.billingCity}</Text>
|
||||
{showCountry && <Text>{invoice.billingCountry.toUpperCase()}</Text>}
|
||||
</View>
|
||||
<View style={[styles.right, styles.companyInfo]}>
|
||||
<Text style={styles.bold}>{COMPANY.name}</Text>
|
||||
<Text>{COMPANY.street}</Text>
|
||||
<Text>{COMPANY.city}</Text>
|
||||
<Text>{COMPANY.phone}</Text>
|
||||
<Link src={`mailto:${COMPANY.email}`} style={{ color: LINK, textDecoration: "none" }}>{COMPANY.email}</Link>
|
||||
<Text>{COMPANY.web}</Text>
|
||||
<Text>St.-Nr.: {COMPANY.taxNumber}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.row, { marginTop: 28, marginBottom: 18, alignItems: "flex-end" }]}>
|
||||
<View style={styles.left}>
|
||||
<Text style={styles.title}>Rechnung</Text>
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
<View style={styles.refBox}>
|
||||
<Text style={styles.refHint}>Bitte bei Zahlung und Rückfragen angeben:</Text>
|
||||
<Text style={styles.refLine}>Rechnungsnummer: {invoice.invoiceNumber}</Text>
|
||||
<Text style={styles.refLine}>Rechnungsdatum: {formatDate(invoice.invoiceDate)}</Text>
|
||||
<Text style={styles.refLine}>Leistungsdatum: {formatDate(invoice.invoiceDate)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.itemHeader}>
|
||||
<Cell width={ITEM_COLUMNS[0]}>Menge</Cell>
|
||||
<Cell width={ITEM_COLUMNS[1]}>Bezeichnung</Cell>
|
||||
<Cell width={ITEM_COLUMNS[2]} align="right">Preis/Einh. (€)</Cell>
|
||||
<Cell width={ITEM_COLUMNS[3]} align="right">Gesamt (€)</Cell>
|
||||
<Cell width={ITEM_COLUMNS[4]} align="right">MwSt (%)</Cell>
|
||||
</View>
|
||||
{invoice.items.map((item, index) => (
|
||||
<View key={index} style={styles.itemRow} wrap={false}>
|
||||
<Cell width={ITEM_COLUMNS[0]}>{item.quantity.toLocaleString("de-DE")}</Cell>
|
||||
<Cell width={ITEM_COLUMNS[1]}>{item.description}</Cell>
|
||||
<Cell width={ITEM_COLUMNS[2]} align="right">{formatAmount(item.unitPrice)}</Cell>
|
||||
<Cell width={ITEM_COLUMNS[3]} align="right">{formatAmount(lineNet(item))}</Cell>
|
||||
<Cell width={ITEM_COLUMNS[4]} align="right">{formatAmount(item.taxRate)}</Cell>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View wrap={false} style={{ marginTop: 18 }}>
|
||||
<View style={styles.summaryHeader}>
|
||||
<Cell width={SUMMARY_COLUMNS[0]}>Fälligkeit</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[1]} align="right">Teilbetrag</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[2]} align="right">MwSt</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[3]} align="right">Betrag MwSt</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[4]} align="right">Brutto</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[5]} align="right">Gesamtbetrag</Cell>
|
||||
</View>
|
||||
<View style={styles.summaryValues}>
|
||||
<Cell width={SUMMARY_COLUMNS[0]} style={styles.bold}>{formatDate(invoice.dueDate)}</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[1]} align="right">{formatCurrency(totals.net)}</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[2]} align="right">
|
||||
{totals.uniformTaxRate === null ? "gemischt" : `${formatAmount(totals.uniformTaxRate)} %`}
|
||||
</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[3]} align="right">{formatCurrency(totals.tax)}</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[4]} align="right">{formatCurrency(totals.gross)}</Cell>
|
||||
<Cell width={SUMMARY_COLUMNS[5]} align="right" style={styles.total}>{formatCurrency(totals.gross)}</Cell>
|
||||
</View>
|
||||
{COMPANY.smallBusiness && totals.tax === 0 && (
|
||||
<Text style={styles.note}>Gemäß § 19 UStG enthält der Rechnungsbetrag keine Umsatzsteuer.</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View wrap={false} style={styles.paymentBox}>
|
||||
<Text style={styles.paymentTitle}>Zahlungsinformationen</Text>
|
||||
<Text style={[styles.paymentText, { marginBottom: 4 }]}>
|
||||
Bitte überweisen Sie den Gesamtbetrag bis zum {formatDate(invoice.dueDate)} auf folgendes Konto:
|
||||
</Text>
|
||||
<Text style={styles.paymentText}>Kontoinhaber: {COMPANY.accountHolder}</Text>
|
||||
<Text style={styles.paymentText}>IBAN: {COMPANY.iban}</Text>
|
||||
<Text style={styles.paymentText}>BIC: {COMPANY.bic}</Text>
|
||||
<Text style={styles.paymentText}>Betrag: {formatCurrency(totals.gross)}</Text>
|
||||
<Text style={styles.paymentText}>Verwendungszweck: {invoice.invoiceNumber}</Text>
|
||||
</View>
|
||||
|
||||
<View wrap={false} style={{ marginTop: 14, gap: 10 }}>
|
||||
<Text style={{ fontWeight: 700, color: LINK }}>
|
||||
Hinweis: Bei Rückfragen kontaktieren Sie uns gerne unter {COMPANY.email}.
|
||||
</Text>
|
||||
<Text>Mit freundlichen Grüßen</Text>
|
||||
<PdfImage src={assets.logo} style={{ width: 82.5, height: 14.25 }} />
|
||||
</View>
|
||||
|
||||
<View style={styles.footer} fixed>
|
||||
<PdfImage src={assets.gradient} style={styles.gradient} />
|
||||
<Text style={styles.footerText}>Das Produkt bleibt bis zur vollständigen Bezahlung unser Eigentum.</Text>
|
||||
<Text style={styles.footerText}>
|
||||
{COMPANY.name}. Sitz der Gesellschaft: Steinfurt, Deutschland. Gesellschafter: {COMPANY.partners}.
|
||||
</Text>
|
||||
<Text style={styles.footerText}>
|
||||
{COMPANY.bank}, Kontoinhaber: {COMPANY.accountHolder}, IBAN: {COMPANY.iban}, BIC: {COMPANY.bic}
|
||||
</Text>
|
||||
</View>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export function InvoiceDocument({ invoice, assets }: { invoice: InvoicePdfData; assets: InvoiceAssets }) {
|
||||
return (
|
||||
<Document title={`Rechnung ${invoice.invoiceNumber}`} author={COMPANY.name}>
|
||||
<InvoicePage invoice={invoice} assets={assets} />
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
35
modules/shared/pdf/render-invoice.tsx
Normal file
35
modules/shared/pdf/render-invoice.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import "server-only"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { Font, renderToBuffer } from "@react-pdf/renderer"
|
||||
import type { InvoicePdfData } from "@/modules/shared/schemas/invoice"
|
||||
import { InvoiceDocument, type InvoiceAssets } from "./invoice-document"
|
||||
|
||||
const ASSET_DIR = path.join(process.cwd(), "modules/shared/pdf/assets")
|
||||
|
||||
Font.register({
|
||||
family: "Google Sans",
|
||||
fonts: [
|
||||
{ src: path.join(ASSET_DIR, "GoogleSans-regular.ttf"), fontWeight: 400 },
|
||||
{ src: path.join(ASSET_DIR, "GoogleSans-bold.ttf"), fontWeight: 700 },
|
||||
],
|
||||
})
|
||||
Font.registerHyphenationCallback((word) => [word])
|
||||
|
||||
let assets: InvoiceAssets | null = null
|
||||
|
||||
function loadAssets(): InvoiceAssets {
|
||||
assets ??= {
|
||||
logo: { data: readFileSync(path.join(ASSET_DIR, "since3-logo.jpg")), format: "jpg" },
|
||||
gradient: { data: readFileSync(path.join(ASSET_DIR, "gradient-line.png")), format: "png" },
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
export function renderInvoicePdf(invoice: InvoicePdfData) {
|
||||
return renderToBuffer(<InvoiceDocument invoice={invoice} assets={loadAssets()} />)
|
||||
}
|
||||
|
||||
export function invoiceFileName(invoiceNumber: string) {
|
||||
return `Rechnung_${invoiceNumber.replace(/[^\w-]/g, "_")}.pdf`
|
||||
}
|
||||
16
modules/shared/queries/credentials.ts
Normal file
16
modules/shared/queries/credentials.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import "server-only"
|
||||
import { createAdminClient } from "@/lib/supabase/admin"
|
||||
|
||||
export async function getCredentials(clientId: string) {
|
||||
const supabase = createAdminClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("credentials")
|
||||
.select("id, service, username, created_at, updated_at")
|
||||
.eq("client_id", clientId)
|
||||
.order("service")
|
||||
|
||||
if (error) throw new Error("Passwörter konnten nicht geladen werden.")
|
||||
|
||||
return data
|
||||
}
|
||||
71
modules/shared/queries/documents.ts
Normal file
71
modules/shared/queries/documents.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import "server-only"
|
||||
import { createClient } from "@/lib/supabase/server"
|
||||
import { DOCUMENTS_BUCKET, previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents"
|
||||
|
||||
const THUMBNAIL_TTL_SECONDS = 60 * 60
|
||||
|
||||
export async function getFolders(clientId: string): Promise<Folder[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("folders")
|
||||
.select("id, name, documents(count)")
|
||||
.eq("client_id", clientId)
|
||||
.is("parent_id", null)
|
||||
.order("name")
|
||||
|
||||
if (error) throw new Error("Ordner konnten nicht geladen werden.")
|
||||
|
||||
return data.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
documentCount: (row.documents as unknown as { count: number }[])[0]?.count ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getFolder(clientId: string, folderId: string): Promise<Folder | null> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("folders")
|
||||
.select("id, name")
|
||||
.eq("client_id", clientId)
|
||||
.eq("id", folderId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw new Error("Ordner konnte nicht geladen werden.")
|
||||
return data ? { id: data.id, name: data.name, documentCount: 0 } : null
|
||||
}
|
||||
|
||||
export async function getDocuments(clientId: string, folderId: string): Promise<DocumentItem[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.select("id, name, mime_type, size_bytes, created_at, storage_path")
|
||||
.eq("client_id", clientId)
|
||||
.eq("folder_id", folderId)
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (error) throw new Error("Dokumente konnten nicht geladen werden.")
|
||||
|
||||
const imagePaths = data
|
||||
.filter((row) => previewKind(row.mime_type) === "image")
|
||||
.map((row) => row.storage_path as string)
|
||||
|
||||
const thumbnails = new Map<string, string>()
|
||||
if (imagePaths.length > 0) {
|
||||
const { data: signed } = await supabase.storage
|
||||
.from(DOCUMENTS_BUCKET)
|
||||
.createSignedUrls(imagePaths, THUMBNAIL_TTL_SECONDS)
|
||||
for (const entry of signed ?? []) {
|
||||
if (entry.path && entry.signedUrl) thumbnails.set(entry.path, entry.signedUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return data.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
mimeType: row.mime_type,
|
||||
sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes),
|
||||
createdAt: row.created_at,
|
||||
thumbnailUrl: thumbnails.get(row.storage_path) ?? null,
|
||||
}))
|
||||
}
|
||||
95
modules/shared/queries/info.ts
Normal file
95
modules/shared/queries/info.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import "server-only"
|
||||
import { createClient } from "@/lib/supabase/server"
|
||||
import { AUTO_CHECK_STALE_AFTER_MS, type InternalNote, type Product, type Task } from "@/modules/shared/schemas/info"
|
||||
import { toCheckableUrl } from "@/modules/shared/lib/reachability"
|
||||
|
||||
export async function getProducts(clientId: string): Promise<Product[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("client_products")
|
||||
.select("id, name, category, url, status, status_message, uptime_30d, updated_at")
|
||||
.eq("client_id", clientId)
|
||||
.order("created_at")
|
||||
|
||||
if (error) throw new Error("Produkte konnten nicht geladen werden.")
|
||||
if (data.length === 0) return []
|
||||
|
||||
const { data: summaries, error: summaryError } = await supabase
|
||||
.from("product_check_summary")
|
||||
.select("product_id, checked_at, ok, response_ms, error, checks_30d, uptime_30d")
|
||||
.in("product_id", data.map((row) => row.id))
|
||||
|
||||
if (summaryError) throw new Error("Prüfergebnisse konnten nicht geladen werden.")
|
||||
|
||||
const summaryById = new Map(summaries.map((row) => [row.product_id as string, row]))
|
||||
const now = Date.now()
|
||||
|
||||
return data.map((row) => {
|
||||
const summary = summaryById.get(row.id)
|
||||
const checkable = toCheckableUrl(row.url) !== null
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
category: row.category,
|
||||
url: row.url,
|
||||
status: row.status,
|
||||
statusMessage: row.status_message,
|
||||
uptime30d: row.uptime_30d === null ? null : Number(row.uptime_30d),
|
||||
updatedAt: row.updated_at,
|
||||
checkable,
|
||||
checkDue: checkable && (!summary?.checked_at || now - Date.parse(summary.checked_at) > AUTO_CHECK_STALE_AFTER_MS),
|
||||
measuredUptime30d: summary?.uptime_30d == null ? null : Number(summary.uptime_30d),
|
||||
checks30d: summary?.checks_30d ?? 0,
|
||||
lastCheck: summary?.checked_at
|
||||
? {
|
||||
checkedAt: summary.checked_at,
|
||||
ok: summary.ok,
|
||||
responseMs: summary.response_ms,
|
||||
error: summary.error,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function getOpenTasks(clientId: string): Promise<Task[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("product_tasks")
|
||||
.select("id, name, description, status, since")
|
||||
.eq("client_id", clientId)
|
||||
.in("status", ["in_progress", "planned"])
|
||||
.order("status")
|
||||
.order("since")
|
||||
|
||||
if (error) throw new Error("Vorgänge konnten nicht geladen werden.")
|
||||
|
||||
return data.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
since: row.since,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getInternalNotes(clientId: string): Promise<InternalNote[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("internal_notes")
|
||||
.select("id, body, updated_at, editor:profiles!internal_notes_updated_by_fkey(name)")
|
||||
.eq("client_id", clientId)
|
||||
.order("updated_at", { ascending: false })
|
||||
|
||||
if (error) throw new Error("Interne Notizen konnten nicht geladen werden.")
|
||||
|
||||
return data.map((row) => {
|
||||
const editor = row.editor as { name: string } | { name: string }[] | null
|
||||
return {
|
||||
id: row.id,
|
||||
body: row.body,
|
||||
updatedAt: row.updated_at,
|
||||
editorName: (Array.isArray(editor) ? editor[0]?.name : editor?.name) ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
85
modules/shared/queries/invoices.ts
Normal file
85
modules/shared/queries/invoices.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import "server-only"
|
||||
import { createClient } from "@/lib/supabase/server"
|
||||
import type { InvoiceListItem, InvoicePdfData, ProductTemplate } from "@/modules/shared/schemas/invoice"
|
||||
|
||||
export async function getInvoices(clientId: string): Promise<InvoiceListItem[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("invoices_overview")
|
||||
.select("id, invoice_number, status, display_status, gross_total, due_date, invoice_date")
|
||||
.eq("client_id", clientId)
|
||||
.order("invoice_date", { ascending: false })
|
||||
.order("invoice_number", { ascending: false })
|
||||
|
||||
if (error) throw new Error("Rechnungen konnten nicht geladen werden.")
|
||||
|
||||
return data.map((row) => ({
|
||||
id: row.id,
|
||||
invoiceNumber: row.invoice_number,
|
||||
status: row.status,
|
||||
displayStatus: row.display_status,
|
||||
grossTotal: Number(row.gross_total),
|
||||
dueDate: row.due_date,
|
||||
invoiceDate: row.invoice_date,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getProductTemplates(): Promise<ProductTemplate[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("product_templates")
|
||||
.select("id, name, description, unit_price, tax_rate")
|
||||
.eq("is_active", true)
|
||||
.order("name")
|
||||
|
||||
if (error) throw new Error("Produktvorlagen konnten nicht geladen werden.")
|
||||
|
||||
return data.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
unitPrice: Number(row.unit_price),
|
||||
taxRate: Number(row.tax_rate),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function peekNextInvoiceNumber(): Promise<string | null> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase.rpc("peek_next_invoice_number")
|
||||
if (error || typeof data !== "string") return null
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getInvoicesForPdf(invoiceIds: string[]): Promise<InvoicePdfData[]> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("invoices")
|
||||
.select(`
|
||||
invoice_number, invoice_date, due_date, company_name,
|
||||
billing_street, billing_postal_code, billing_city, billing_country,
|
||||
invoice_items ( position, description, quantity, unit_price, tax_rate )
|
||||
`)
|
||||
.in("id", invoiceIds)
|
||||
.order("invoice_number")
|
||||
|
||||
if (error) throw new Error("Rechnungen konnten nicht geladen werden.")
|
||||
|
||||
return data.map((row) => ({
|
||||
invoiceNumber: row.invoice_number,
|
||||
invoiceDate: row.invoice_date,
|
||||
dueDate: row.due_date,
|
||||
companyName: row.company_name,
|
||||
billingStreet: row.billing_street,
|
||||
billingPostalCode: row.billing_postal_code,
|
||||
billingCity: row.billing_city,
|
||||
billingCountry: row.billing_country,
|
||||
items: [...row.invoice_items]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item) => ({
|
||||
description: item.description,
|
||||
quantity: Number(item.quantity),
|
||||
unitPrice: Number(item.unit_price),
|
||||
taxRate: Number(item.tax_rate),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
16
modules/shared/queries/profile.ts
Normal file
16
modules/shared/queries/profile.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import "server-only"
|
||||
import { createClient } from "@/lib/supabase/server"
|
||||
|
||||
// null = Profil fehlt oder hat noch keinen Client. DB-Fehler werfen, damit sie nicht als "kein Client" durchgehen.
|
||||
export async function getClientId(profileId: string): Promise<string | null> {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("client_id")
|
||||
.eq("id", profileId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw new Error("Profil konnte nicht geladen werden.")
|
||||
|
||||
return data?.client_id ?? null
|
||||
}
|
||||
23
modules/shared/queries/stammdaten.ts
Normal file
23
modules/shared/queries/stammdaten.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import "server-only"
|
||||
import { createAdminClient } from "@/lib/supabase/admin"
|
||||
import { getClientId } from "@/modules/shared/queries/profile"
|
||||
|
||||
export async function getStammdaten(profileId: string) {
|
||||
const clientId = await getClientId(profileId)
|
||||
|
||||
if (!clientId) {
|
||||
return { client: null, paymentDetails: null }
|
||||
}
|
||||
|
||||
const supabase = createAdminClient()
|
||||
const [clientRes, paymentRes] = await Promise.all([
|
||||
supabase.from("clients").select("*").eq("id", clientId).maybeSingle(),
|
||||
supabase.from("client_payment_details").select("*").eq("client_id", clientId).maybeSingle(),
|
||||
])
|
||||
|
||||
if (clientRes.error || paymentRes.error) {
|
||||
throw new Error("Stammdaten konnten nicht geladen werden.")
|
||||
}
|
||||
|
||||
return { client: clientRes.data, paymentDetails: paymentRes.data }
|
||||
}
|
||||
101
modules/shared/schemas/documents.ts
Normal file
101
modules/shared/schemas/documents.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import * as z from "zod";
|
||||
|
||||
export const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
||||
export const MAX_FILES_PER_UPLOAD = 20;
|
||||
export const DOCUMENTS_BUCKET = "client-documents";
|
||||
|
||||
export type Folder = {
|
||||
id: string;
|
||||
name: string;
|
||||
documentCount: number;
|
||||
};
|
||||
|
||||
export type DocumentItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
createdAt: string;
|
||||
thumbnailUrl: string | null;
|
||||
};
|
||||
|
||||
export type FileKind = "image" | "pdf" | "text" | "spreadsheet" | "presentation" | "document" | "archive" | "video" | "audio" | "other";
|
||||
|
||||
const EXTENSION_KINDS: Record<string, FileKind> = {
|
||||
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image",
|
||||
pdf: "pdf",
|
||||
txt: "text", md: "text", csv: "spreadsheet", json: "text",
|
||||
xls: "spreadsheet", xlsx: "spreadsheet", ods: "spreadsheet",
|
||||
ppt: "presentation", pptx: "presentation", odp: "presentation",
|
||||
doc: "document", docx: "document", odt: "document", rtf: "document",
|
||||
zip: "archive", rar: "archive", "7z": "archive", gz: "archive",
|
||||
mp4: "video", mov: "video", webm: "video",
|
||||
mp3: "audio", wav: "audio", m4a: "audio",
|
||||
};
|
||||
|
||||
export function fileKind(name: string, mimeType: string | null): FileKind {
|
||||
const extension = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (EXTENSION_KINDS[extension]) return EXTENSION_KINDS[extension];
|
||||
if (mimeType?.startsWith("image/")) return "image";
|
||||
if (mimeType?.startsWith("video/")) return "video";
|
||||
if (mimeType?.startsWith("audio/")) return "audio";
|
||||
if (mimeType === "application/pdf") return "pdf";
|
||||
if (mimeType?.startsWith("text/")) return "text";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export type PreviewKind = "image" | "pdf" | "text" | "video" | "audio";
|
||||
|
||||
// Vorschau richtet sich nach dem Content-Type, mit dem der Storage die Datei ausliefert – nicht nach der Endung.
|
||||
// So landet z. B. ein als ".pdf" benanntes HTML nie im Iframe. SVG (kann Skripte enthalten) nur als Download.
|
||||
const PREVIEW_TYPES: Record<string, PreviewKind> = {
|
||||
"image/png": "image",
|
||||
"image/jpeg": "image",
|
||||
"image/gif": "image",
|
||||
"image/webp": "image",
|
||||
"application/pdf": "pdf",
|
||||
"text/plain": "text",
|
||||
"text/markdown": "text",
|
||||
"text/csv": "text",
|
||||
"application/json": "text",
|
||||
"video/mp4": "video",
|
||||
"video/webm": "video",
|
||||
"video/quicktime": "video",
|
||||
"audio/mpeg": "audio",
|
||||
"audio/wav": "audio",
|
||||
"audio/mp4": "audio",
|
||||
"audio/x-m4a": "audio",
|
||||
};
|
||||
|
||||
export function previewKind(mimeType: string | null): PreviewKind | null {
|
||||
if (!mimeType) return null;
|
||||
return PREVIEW_TYPES[mimeType.split(";")[0].trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number | null) {
|
||||
if (bytes === null) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${value.toLocaleString("de-DE", { maximumFractionDigits: value < 10 ? 1 : 0 })} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export const UploadFileSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
size: z.number().int().positive({ error: "Leere Dateien können nicht hochgeladen werden." }).max(MAX_FILE_BYTES, {
|
||||
error: "Dateien dürfen maximal 50 MB groß sein.",
|
||||
}),
|
||||
type: z.string().max(255),
|
||||
});
|
||||
|
||||
export const FolderNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: "Name ist erforderlich." })
|
||||
.max(100, { error: "Name ist zu lang." })
|
||||
.refine((value) => !/[\\/]/.test(value), { error: "Name darf keine Schrägstriche enthalten." });
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue