Merge pull request #9 from Since-3/dev

v1
This commit is contained in:
Rajpreet0 2026-09-29 16:45:22 +02:00 • committed by GitHub
commit 983e8e27d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
143 changed files with 11215 additions and 23 deletions

37
.github/workflows/ci.yml vendored Normal file
View 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

View 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

View 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

View 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;
}

View 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

View 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

View 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

View 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

View 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
View 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>
)
}

View 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

View 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",
},
})
}

View 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",
},
})
}

View 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

View 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
View 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
View 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>
)
}

View 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

View 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

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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
View 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

View 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
View 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,
}

View file

@ -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: {

View file

@ -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: {

View 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
View 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,
}

View 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
View 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,
}

View 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
View 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 }

View 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
View 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
View 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,
}

View 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 }

View 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
View 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
View 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
View 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
View 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
View 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,
}
})

View file

@ -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);

View file

@ -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();

View 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

View file

@ -0,0 +1,2 @@
alter table public.profiles
add column if not exists terms_accepted_at timestamptz;

View file

@ -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;

View file

@ -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 $$;

View 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;

View 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);

View 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;

View file

@ -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
View 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 }
}

View file

@ -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.
}
},
},
}
);
}

View 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 }
}

View 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 };
}

View 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 };
}

View file

@ -0,0 +1 @@
export const MIN_PASSWORD_LENGTH = 10;

View 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
}

View 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),
})),
}
}

View 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,
}))
}

View 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>
)
}

View 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>
</>
);
}

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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

View 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

View 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

View 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("/");
}

View 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;

View 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

View 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
}
}

View 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 };
}

View 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 };
}

View 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 };
}

View 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 };
}

View 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 };
}

View 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 };
}

View 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];
}

View 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
}
]
}

View 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.",
];

View 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)} %`;
}

View 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}`;
}

View 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;
}

View 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()
})
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View 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>
)
}

View 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`
}

View 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
}

View 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,
}))
}

View 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,
}
})
}

View 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),
})),
}))
}

View 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
}

View 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 }
}

View 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