mobile responsive

This commit is contained in:
Rajpreet0 2026-09-29 22:56:37 +02:00
parent 496ad28723
commit b6f741be8f
30 changed files with 245 additions and 159 deletions

View file

@ -1,7 +1,8 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider } from "@/components/ui/sidebar";
import { verifySession } from "@/lib/supabase/dal"; import { verifySession } from "@/lib/supabase/dal";
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar"; import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
import { MobileHeader } from "@/modules/shared/ui/components/mobile-header";
import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog"; import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog";
@ -14,11 +15,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<SidebarProvider defaultOpen={defaultOpen}> <SidebarProvider defaultOpen={defaultOpen}>
{!session.isAdmin && !session.hasAcceptedTerms && <TermsDialog/>} {!session.isAdmin && !session.hasAcceptedTerms && <TermsDialog/>}
<AppSidebar session={session}/> <AppSidebar session={session}/>
<div className="flex w-full flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center border-b border-border p-3 md:hidden"> <MobileHeader />
<SidebarTrigger /> <main className="flex-1 px-4 py-6 sm:px-6 md:p-8">{children}</main>
</div>
<main className="flex-1 p-4 md:p-8">{children}</main>
</div> </div>
</SidebarProvider> </SidebarProvider>
) )

View file

@ -1,7 +1,8 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider } from "@/components/ui/sidebar";
import { verifySession } from "@/lib/supabase/dal"; import { verifySession } from "@/lib/supabase/dal";
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar"; import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
import { MobileHeader } from "@/modules/shared/ui/components/mobile-header";
import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog"; import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog";
@ -17,11 +18,9 @@ export default async function ClientLayout({ children }: { children: React.React
return ( return (
<SidebarProvider defaultOpen={defaultOpen}> <SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar session={session}/> <AppSidebar session={session}/>
<div className="flex w-full flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center border-b border-border p-3 md:hidden"> <MobileHeader />
<SidebarTrigger /> <main className="flex-1 px-4 py-6 sm:px-6 md:p-8">{children}</main>
</div>
<main className="flex-1 p-4 md:p-8">{children}</main>
</div> </div>
</SidebarProvider> </SidebarProvider>
) )

View file

@ -213,3 +213,9 @@
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* iOS zoomt beim Fokussieren in Felder mit < 16px Schrift. Auf Touch-Geräten daher mindestens 16px. */
@media (pointer: coarse) {
input:not([type="checkbox"], [type="radio"], [type="file"]), select, textarea {
font-size: max(16px, 1em);
}
}

View file

@ -1,4 +1,4 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Inter, JetBrains_Mono } from "next/font/google"; import { Inter, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css"; import "./globals.css";
@ -15,13 +15,29 @@ const jetbrainsMono = JetBrains_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Since3 Portal", title: "Since3 Portal",
description: "Generated by create next app", description: "Kundenportal von Since3: Status, Rechnungen, Dokumente und Zugangsdaten.",
applicationName: "Since3 Portal",
appleWebApp: {
capable: true,
title: "Since3",
statusBarStyle: "default",
},
formatDetection: {
telephone: false,
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#fcfcfc" },
{ media: "(prefers-color-scheme: dark)", color: "#020202" },
],
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html <html
lang="en" lang="de"
className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`} className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`}
suppressHydrationWarning suppressHydrationWarning
> >

36
app/manifest.ts Normal file
View file

@ -0,0 +1,36 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
id: "/",
name: "Since3 Portal",
short_name: "Since3",
description: "Kundenportal von Since3: Status, Rechnungen, Dokumente und Zugangsdaten.",
lang: "de",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#020202",
theme_color: "#020202",
icons: [
{
src: "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

View file

@ -1,5 +1,11 @@
import { redirect } from "next/navigation";
import { getProfile } from "@/lib/supabase/dal";
import LoginPageView from "@/modules/loginPage/ui/views/login-view"; import LoginPageView from "@/modules/loginPage/ui/views/login-view";
export default function Home() { export default async function Home() {
// Installierte App startet auf "/": mit gültiger Session direkt ins Dashboard.
const profile = await getProfile();
if (profile) redirect(profile.role === "admin" ? "/admin/dashboard" : "/client/dashboard");
return <LoginPageView/>; return <LoginPageView/>;
} }

View file

@ -1,6 +1,6 @@
const TermsPage = () => { const TermsPage = () => {
return ( return (
<div className="mx-auto max-w-2xl p-8"> <div className="mx-auto max-w-2xl px-4 py-8 sm:p-8">
<h1 className="mb-4 text-2xl font-semibold">Nutzungsbedingungen</h1> <h1 className="mb-4 text-2xl font-semibold">Nutzungsbedingungen</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
TODO: Nutzungsbedingungen einfügen. TODO: Nutzungsbedingungen einfügen.

View file

@ -53,7 +53,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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", "fixed top-1/2 left-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto overscroll-contain 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 className
)} )}
{...props} {...props}

View file

@ -50,7 +50,7 @@ function ResetPasswordContent({ client, onDone }: { client: Client; onDone: () =
<input type="hidden" name="profileId" value={client.id} /> <input type="hidden" name="profileId" value={client.id} />
<input type="hidden" name="mode" value={mode} /> <input type="hidden" name="mode" value={mode} />
<FieldGroup> <FieldGroup>
<div role="radiogroup" aria-label="Art des neuen Passworts" className="grid grid-cols-2 gap-2"> <div role="radiogroup" aria-label="Art des neuen Passworts" className="grid gap-2 sm:grid-cols-2">
{(["generate", "manual"] as const).map((value) => ( {(["generate", "manual"] as const).map((value) => (
<label <label
key={value} key={value}

View file

@ -51,9 +51,9 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
}, [clients, query]); }, [clients, query]);
return ( return (
<div className="flex flex-col gap-8 w-full"> <div className="flex w-full flex-col gap-6 md:gap-8">
<div className="flex flex-row items-center justify-between gap-2 w-full p-2"> <div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<InputGroup className="max-w-lg rounded-md focus-visible:ring-0"> <InputGroup className="w-full rounded-md focus-visible:ring-0 sm:max-w-lg">
<InputGroupInput <InputGroupInput
placeholder="Search..." placeholder="Search..."
value={query} value={query}
@ -75,8 +75,8 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Client</TableHead> <TableHead>Client</TableHead>
<TableHead>Status</TableHead> <TableHead className="hidden sm:table-cell">Status</TableHead>
<TableHead className="w-24" /> <TableHead className="w-20 sm:w-24" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -86,7 +86,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
return ( return (
<TableRow key={client.id}> <TableRow key={client.id}>
<TableCell> <TableCell className="w-full max-w-0 sm:w-auto sm:max-w-none">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar size="lg" className="shrink-0"> <Avatar size="lg" className="shrink-0">
<AvatarFallback> <AvatarFallback>
@ -99,7 +99,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="hidden sm:table-cell">
<Badge variant={status.variant}>{status.label}</Badge> <Badge variant={status.variant}>{status.label}</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>

View file

@ -256,7 +256,7 @@ function ActiveToggle({ template }: { template: AdminProductTemplate }) {
export function ProductTemplatesSection({ templates }: { templates: AdminProductTemplate[] }) { export function ProductTemplatesSection({ templates }: { templates: AdminProductTemplate[] }) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex sm:justify-end">
<TemplateDialog /> <TemplateDialog />
</div> </div>
{templates.length === 0 ? ( {templates.length === 0 ? (
@ -267,8 +267,8 @@ export function ProductTemplatesSection({ templates }: { templates: AdminProduct
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border"> <ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
{templates.map((template) => ( {templates.map((template) => (
<li key={template.id} className={cn("flex flex-wrap items-center gap-3 px-4 py-3", !template.isActive && "opacity-60")}> <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"> <div className="flex min-w-0 flex-1 basis-full flex-col sm:basis-auto">
<span className="flex items-center gap-2 font-medium"> <span className="flex flex-wrap items-center gap-2 font-medium break-words">
{template.name} {template.name}
{!template.isActive && <Badge>Inaktiv</Badge>} {!template.isActive && <Badge>Inaktiv</Badge>}
</span> </span>
@ -278,7 +278,7 @@ export function ProductTemplatesSection({ templates }: { templates: AdminProduct
<span className="w-20 text-right text-small text-muted-foreground tabular-nums"> <span className="w-20 text-right text-small text-muted-foreground tabular-nums">
{template.taxRate.toLocaleString("de-DE")} % MwSt {template.taxRate.toLocaleString("de-DE")} % MwSt
</span> </span>
<div className="flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<ActiveToggle template={template} /> <ActiveToggle template={template} />
<TemplateDialog template={template} /> <TemplateDialog template={template} />
<DeleteTemplateDialog template={template} /> <DeleteTemplateDialog template={template} />

View file

@ -5,8 +5,8 @@ const ClientListViewPage = async () => {
const clients = await getClients() const clients = await getClients()
return ( return (
<div className="flex flex-col gap-8 w-full"> <div className="flex w-full flex-col gap-6 md:gap-8">
<h1 className="text-3xl font-semibold">Clients</h1> <h1 className="text-2xl font-semibold md:text-3xl">Clients</h1>
<ClientsTable clients={clients} /> <ClientsTable clients={clients} />
</div> </div>
) )

View file

@ -46,7 +46,7 @@ function StatCard({
<span className="text-small text-muted-foreground">{label}</span> <span className="text-small text-muted-foreground">{label}</span>
<span <span
className={cn( className={cn(
"text-2xl font-semibold tabular-nums", "text-xl font-semibold tabular-nums break-words sm:text-2xl",
tone === "danger" && "text-signal-error", tone === "danger" && "text-signal-error",
tone === "success" && "text-signal-success" tone === "success" && "text-signal-success"
)} )}
@ -57,7 +57,7 @@ function StatCard({
</> </>
) )
const className = "flex flex-col gap-1 rounded-lg border border-border p-4" const className = "flex min-w-0 flex-col gap-1 rounded-lg border border-border p-4"
return href ? ( return href ? (
<Link href={href} className={cn(className, "transition-colors hover:bg-surface")}>{content}</Link> <Link href={href} className={cn(className, "transition-colors hover:bg-surface")}>{content}</Link>
) : ( ) : (
@ -68,7 +68,7 @@ function StatCard({
function Section({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) { function Section({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
return ( return (
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-xl font-semibold"> <h2 className="flex items-center gap-2 text-lg font-semibold md:text-xl">
{title} {title}
{count !== undefined && count > 0 && <Badge>{count}</Badge>} {count !== undefined && count > 0 && <Badge>{count}</Badge>}
</h2> </h2>
@ -101,13 +101,13 @@ const DashboardView = async ({ name }: { name: string }) => {
const today = berlinToday() const today = berlinToday()
return ( return (
<div className="flex flex-col gap-10 w-full"> <div className="flex w-full flex-col gap-8 md:gap-10">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-3xl font-semibold">Hallo {name.split(" ")[0]}</h1> <h1 className="text-2xl font-semibold md:text-3xl">Hallo {name.split(" ")[0]}</h1>
<p className="text-accent-400">Alles Wichtige über alle Clients auf einen Blick.</p> <p className="text-accent-400">Alles Wichtige über alle Clients auf einen Blick.</p>
</div> </div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid gap-3 min-[480px]:grid-cols-2 xl:grid-cols-4">
<StatCard label="Offene Rechnungen" value={formatCurrency(data.openTotal)} detail={`${data.openCount} ${data.openCount === 1 ? "Rechnung" : "Rechnungen"}`} /> <StatCard label="Offene Rechnungen" value={formatCurrency(data.openTotal)} detail={`${data.openCount} ${data.openCount === 1 ? "Rechnung" : "Rechnungen"}`} />
<StatCard <StatCard
label="Davon überfällig" label="Davon überfällig"
@ -148,7 +148,7 @@ const DashboardView = async ({ name }: { name: string }) => {
)} )}
</Section> </Section>
<div className="grid gap-10 lg:grid-cols-2"> <div className="grid gap-8 md:gap-10 xl:grid-cols-2">
<Section title="Neueste Uploads von Clients"> <Section title="Neueste Uploads von Clients">
{data.uploads.length === 0 ? ( {data.uploads.length === 0 ? (
<EmptyHint icon={UploadIcon}>Noch keine Uploads von Clients.</EmptyHint> <EmptyHint icon={UploadIcon}>Noch keine Uploads von Clients.</EmptyHint>
@ -179,15 +179,17 @@ const DashboardView = async ({ name }: { name: string }) => {
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border"> <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{data.tasks.map((task) => ( {data.tasks.map((task) => (
<LinkRow key={task.id} href={task.href}> <LinkRow key={task.id} href={task.href}>
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20"> <Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20 shrink-0">
{TASK_STATUS_LABELS[task.status]} {TASK_STATUS_LABELS[task.status]}
</Badge> </Badge>
<span className="flex min-w-0 flex-1 flex-col"> <span className="flex min-w-0 flex-1 flex-col sm:flex-row sm:items-center sm:gap-3">
<span className="truncate font-medium">{task.name}</span> <span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-small text-muted-foreground">{task.clientName}</span> <span className="truncate font-medium">{task.name}</span>
</span> <span className="truncate text-small text-muted-foreground">{task.clientName}</span>
<span className="shrink-0 text-small text-muted-foreground"> </span>
{formatTaskTiming(task.status, task.since, today)} <span className="text-small text-muted-foreground sm:shrink-0">
{formatTaskTiming(task.status, task.since, today)}
</span>
</span> </span>
</LinkRow> </LinkRow>
))} ))}

View file

@ -17,8 +17,8 @@ const SettingsView = async ({ name, email }: { name: string; email: string }) =>
const templates = await getAllProductTemplates() const templates = await getAllProductTemplates()
return ( return (
<div className="flex flex-col gap-8 w-full max-w-4xl"> <div className="flex w-full max-w-4xl flex-col gap-6 md:gap-8">
<h1 className="text-3xl font-semibold">Einstellungen</h1> <h1 className="text-2xl font-semibold md:text-3xl">Einstellungen</h1>
<Section title="Profil" description="So wirst du im Portal angezeigt, z. B. bei „zuletzt bearbeitet von“."> <Section title="Profil" description="So wirst du im Portal angezeigt, z. B. bei „zuletzt bearbeitet von“.">
<ProfileForm name={name} email={email} /> <ProfileForm name={name} email={email} />

View file

@ -56,8 +56,8 @@ function FormSection({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="rounded-lg border border-border bg-surface/50 p-5"> <div className="rounded-lg border border-border bg-surface/50 p-4 sm:p-5">
<div className="mb-5 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2 sm:mb-5">
<Icon className="size-4.5 text-muted-foreground" /> <Icon className="size-4.5 text-muted-foreground" />
<h2 className="text-base font-semibold">{title}</h2> <h2 className="text-base font-semibold">{title}</h2>
</div> </div>
@ -217,8 +217,8 @@ const StammdatenForm = ({
/> />
</FormSection> </FormSection>
<div className="rounded-lg border border-border bg-surface/50 p-5"> <div className="rounded-lg border border-border bg-surface/50 p-4 sm:p-5">
<label className="mb-5 flex items-center gap-2 has-disabled:opacity-50"> <label className="mb-4 flex items-center gap-2 has-disabled:opacity-50 sm:mb-5">
<input <input
type="checkbox" type="checkbox"
checked={hasDifferentBillingAddress} checked={hasDifferentBillingAddress}
@ -294,7 +294,7 @@ const StammdatenForm = ({
)} )}
{isEditing && ( {isEditing && (
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onDone} disabled={pending}> <Button type="button" variant="outline" onClick={onDone} disabled={pending}>
Abbrechen Abbrechen
</Button> </Button>

View file

@ -13,21 +13,21 @@ const LoginPageView = () => {
const [state, formAction, pending] = useActionState(login, undefined); const [state, formAction, pending] = useActionState(login, undefined);
return ( return (
<div className="bg-background h-screen p-6"> <div className="flex min-h-svh flex-col bg-background p-4 sm:p-6">
<Image <Image
src="/pictures/since3-logo.svg" alt="" src="/pictures/since3-logo.svg" alt=""
width={1617} width={1617}
height={332} height={332}
loading="eager" loading="eager"
className="h-5 w-auto md:h-6 dark:hidden"/> className="h-5 w-auto self-start md:h-6 dark:hidden"/>
<Image <Image
src="/pictures/since3-logo-weiss.svg" alt="" src="/pictures/since3-logo-weiss.svg" alt=""
width={1617} width={1617}
height={332} height={332}
loading="eager" loading="eager"
className="hidden h-5 w-auto md:h-6 dark:block"/> className="hidden h-5 w-auto self-start md:h-6 dark:block"/>
<div className="flex flex-col h-full items-center justify-center"> <div className="flex flex-1 flex-col items-center justify-center py-10">
<h1 className="mb-6 text-3xl font-semibold">Login</h1> <h1 className="mb-6 text-2xl font-semibold sm:text-3xl">Login</h1>
<form action={formAction} className="w-full max-w-sm "> <form action={formAction} className="w-full max-w-sm ">
<FieldGroup> <FieldGroup>
<Field> <Field>
@ -72,8 +72,8 @@ const LoginPageView = () => {
</FieldGroup> </FieldGroup>
</form> </form>
</div> </div>
<div className="absolute bottom-2"> <div>
<p className="text-muted-foreground text-sm">Bei Fragen: <a href="mailto:info@since3.de" className="underline">info@since3.de</a></p> <p className="text-center text-muted-foreground text-sm sm:text-left">Bei Fragen: <a href="mailto:info@since3.de" className="underline">info@since3.de</a></p>
</div> </div>
</div> </div>
) )

View file

@ -183,7 +183,7 @@ function BillingDialogContent({
<DialogHeader> <DialogHeader>
<DialogTitle>Vorschau</DialogTitle> <DialogTitle>Vorschau</DialogTitle>
</DialogHeader> </DialogHeader>
<iframe src={previewUrl} title="Rechnungsvorschau" className="h-[70vh] w-full rounded-md border border-border" /> <iframe src={previewUrl} title="Rechnungsvorschau" className="h-[60dvh] w-full rounded-md border border-border sm:h-[70dvh]" />
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setPreviewUrl(null)} disabled={createPending}> <Button type="button" variant="outline" onClick={() => setPreviewUrl(null)} disabled={createPending}>
@ -215,7 +215,7 @@ function BillingDialogContent({
<FieldLabel htmlFor="billingStreet">Straße <span className="text-destructive">*</span></FieldLabel> <FieldLabel htmlFor="billingStreet">Straße <span className="text-destructive">*</span></FieldLabel>
<Input id="billingStreet" value={header.billingStreet} onChange={(e) => updateHeader("billingStreet", e.target.value)} /> <Input id="billingStreet" value={header.billingStreet} onChange={(e) => updateHeader("billingStreet", e.target.value)} />
</Field> </Field>
<div className="grid grid-cols-[1fr_2fr_4rem] gap-2"> <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_4rem] gap-2">
<Field> <Field>
<FieldLabel htmlFor="billingPostalCode">PLZ <span className="text-destructive">*</span></FieldLabel> <FieldLabel htmlFor="billingPostalCode">PLZ <span className="text-destructive">*</span></FieldLabel>
<Input id="billingPostalCode" value={header.billingPostalCode} onChange={(e) => updateHeader("billingPostalCode", e.target.value)} /> <Input id="billingPostalCode" value={header.billingPostalCode} onChange={(e) => updateHeader("billingPostalCode", e.target.value)} />
@ -256,8 +256,8 @@ function BillingDialogContent({
const net = Number.isFinite(item.quantity) && Number.isFinite(item.unitPrice) ? lineNet(item) : null; const net = Number.isFinite(item.quantity) && Number.isFinite(item.unitPrice) ? lineNet(item) : null;
return ( return (
<div key={row.key} className="grid gap-2 rounded-lg border border-border p-3 md:grid-cols-[10rem_1fr_5rem_7rem_5rem_auto] md:items-end"> <div key={row.key} className="grid grid-cols-3 items-end gap-2 rounded-lg border border-border p-3 md:grid-cols-[10rem_1fr_5rem_7rem_5rem_auto]">
<Field> <Field className="col-span-3 md:col-span-1">
<FieldLabel htmlFor={`template-${row.key}`}>Vorlage</FieldLabel> <FieldLabel htmlFor={`template-${row.key}`}>Vorlage</FieldLabel>
<select <select
id={`template-${row.key}`} id={`template-${row.key}`}
@ -274,7 +274,7 @@ function BillingDialogContent({
))} ))}
</select> </select>
</Field> </Field>
<Field> <Field className="col-span-3 md:col-span-1">
<FieldLabel htmlFor={`description-${row.key}`}>Bezeichnung</FieldLabel> <FieldLabel htmlFor={`description-${row.key}`}>Bezeichnung</FieldLabel>
<Input id={`description-${row.key}`} value={row.description} onChange={(e) => updateRow(row.key, { description: e.target.value })} /> <Input id={`description-${row.key}`} value={row.description} onChange={(e) => updateRow(row.key, { description: e.target.value })} />
</Field> </Field>
@ -290,7 +290,7 @@ function BillingDialogContent({
<FieldLabel htmlFor={`tax-${row.key}`}>MwSt %</FieldLabel> <FieldLabel htmlFor={`tax-${row.key}`}>MwSt %</FieldLabel>
<Input id={`tax-${row.key}`} inputMode="decimal" value={row.taxRate} onChange={(e) => updateRow(row.key, { taxRate: e.target.value })} /> <Input id={`tax-${row.key}`} inputMode="decimal" value={row.taxRate} onChange={(e) => updateRow(row.key, { taxRate: e.target.value })} />
</Field> </Field>
<div className="flex items-center justify-between gap-2 md:justify-end md:h-10"> <div className="col-span-3 flex items-center justify-between gap-2 md:col-span-1 md:h-10 md:justify-end">
<span className="min-w-20 text-right tabular-nums text-muted-foreground"> <span className="min-w-20 text-right tabular-nums text-muted-foreground">
{net === null ? "–" : formatCurrency(net)} {net === null ? "–" : formatCurrency(net)}
</span> </span>
@ -363,7 +363,7 @@ export function BillingDialog({
</> </>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl"> <DialogContent className="sm:max-w-5xl">
{prefill ? ( {prefill ? (
<BillingDialogContent <BillingDialogContent
key={instanceKey} key={instanceKey}

View file

@ -46,7 +46,7 @@ function RevealButton({ credentialId }: { credentialId: string }) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="min-w-32 font-mono text-small"> <span className="font-mono text-small break-all sm:min-w-32">
{password ?? "••••••••"} {password ?? "••••••••"}
</span> </span>
<Button <Button
@ -86,9 +86,9 @@ export function CredentialsTable({
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead>Dienst</TableHead> <TableHead>Dienst</TableHead>
<TableHead>Benutzername</TableHead> <TableHead className="hidden md:table-cell">Benutzername</TableHead>
<TableHead>Passwort</TableHead> <TableHead>Passwort</TableHead>
<TableHead className="w-24" /> <TableHead className="w-20 sm:w-24" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -102,10 +102,13 @@ export function CredentialsTable({
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background"> <div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<ServiceLogo serviceId={credential.service} size={18} className="size-4.5 object-contain" /> <ServiceLogo serviceId={credential.service} size={18} className="size-4.5 object-contain" />
</div> </div>
<span className="font-medium">{service.label}</span> <div className="flex min-w-0 flex-col">
<span className="font-medium">{service.label}</span>
<span className="text-small text-muted-foreground break-all md:hidden">{credential.username}</span>
</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{credential.username}</TableCell> <TableCell className="hidden text-muted-foreground break-all md:table-cell">{credential.username}</TableCell>
<TableCell> <TableCell>
<RevealButton credentialId={credential.id} /> <RevealButton credentialId={credential.id} />
</TableCell> </TableCell>

View file

@ -108,16 +108,16 @@ function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) {
switch (kind) { switch (kind) {
case "image": case "image":
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL // eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
return <img src={url} alt={doc.name} className="mx-auto max-h-[70vh] w-auto rounded-md object-contain" />; return <img src={url} alt={doc.name} className="mx-auto max-h-[70dvh] w-auto max-w-full rounded-md object-contain" />;
case "pdf": case "pdf":
return <iframe src={url} title={doc.name} className="h-[70vh] w-full rounded-md border border-border" />; return <iframe src={url} title={doc.name} className="h-[60dvh] w-full rounded-md border border-border sm:h-[70dvh]" />;
case "video": case "video":
return <video src={url} controls className="max-h-[70vh] w-full rounded-md" />; return <video src={url} controls className="max-h-[70dvh] w-full rounded-md" />;
case "audio": case "audio":
return <audio src={url} controls className="w-full" />; return <audio src={url} controls className="w-full" />;
case "text": case "text":
return ( return (
<pre className="max-h-[70vh] overflow-auto rounded-md border border-border bg-surface p-4 text-small whitespace-pre-wrap break-words"> <pre className="max-h-[70dvh] overflow-auto rounded-md border border-border bg-surface p-4 text-small whitespace-pre-wrap break-words">
{text ?? "Wird geladen..."} {text ?? "Wird geladen..."}
</pre> </pre>
); );
@ -185,7 +185,7 @@ function PreviewDialog({
return ( return (
<Dialog open={doc !== null} onOpenChange={handleOpenChange}> <Dialog open={doc !== null} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[95vh] overflow-y-auto sm:max-w-4xl"> <DialogContent className="max-h-[95dvh] overflow-y-auto sm:max-w-4xl">
{doc && ( {doc && (
<> <>
<DialogHeader> <DialogHeader>
@ -398,7 +398,7 @@ export function DocumentsBrowser({
</label> </label>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" onClick={() => handleDownload(visibleSelection)} disabled={visibleSelection.length === 0 || busy}> <Button variant="secondary" onClick={() => handleDownload(visibleSelection)} disabled={visibleSelection.length === 0 || busy}>
{downloading ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />} {downloading ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
{downloading ? "Wird vorbereitet..." : visibleSelection.length > 1 ? `Download (${visibleSelection.length})` : "Download"} {downloading ? "Wird vorbereitet..." : visibleSelection.length > 1 ? `Download (${visibleSelection.length})` : "Download"}
@ -427,7 +427,7 @@ export function DocumentsBrowser({
<button <button
type="button" type="button"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-surface/50 p-12 text-center text-muted-foreground transition-colors hover:bg-surface" className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-surface/50 p-8 text-center text-muted-foreground transition-colors hover:bg-surface sm:p-12"
> >
<UploadIcon className="size-8" /> <UploadIcon className="size-8" />
<span>Noch keine Dateien. Hierher ziehen oder klicken zum Hochladen.</span> <span>Noch keine Dateien. Hierher ziehen oder klicken zum Hochladen.</span>

View file

@ -65,7 +65,7 @@ function NoteItem({ profileId, note }: { profileId: string; note: InternalNote }
<NoteForm profileId={profileId} note={note} onDone={() => setEditing(false)} onCancel={() => setEditing(false)} /> <NoteForm profileId={profileId} note={note} onDone={() => setEditing(false)} onCancel={() => setEditing(false)} />
) : ( ) : (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<p className="whitespace-pre-wrap break-words">{note.body}</p> <p className="min-w-0 whitespace-pre-wrap break-words">{note.body}</p>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon-sm" aria-label="Notiz bearbeiten" onClick={() => setEditing(true)}> <Button variant="ghost" size="icon-sm" aria-label="Notiz bearbeiten" onClick={() => setEditing(true)}>
<PencilIcon className="size-4" /> <PencilIcon className="size-4" />
@ -87,7 +87,7 @@ export function InternalNotes({ profileId, notes }: { profileId: string; notes:
return ( return (
<section className="flex flex-col gap-4 rounded-lg border border-dashed border-border bg-surface/50 p-4 md:p-6"> <section className="flex flex-col gap-4 rounded-lg border border-dashed border-border bg-surface/50 p-4 md:p-6">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-xl font-semibold">Interne Notiz</h2> <h2 className="text-lg font-semibold md:text-xl">Interne Notiz</h2>
<span className="inline-flex items-center gap-1.5 rounded-full bg-ink-100 px-2.5 py-0.5 text-label text-ink-700 dark:bg-ink-800 dark:text-ink-300"> <span className="inline-flex items-center gap-1.5 rounded-full bg-ink-100 px-2.5 py-0.5 text-label text-ink-700 dark:bg-ink-800 dark:text-ink-300">
<EyeOffIcon className="size-3.5" /> Kunde sieht das nicht <EyeOffIcon className="size-3.5" /> Kunde sieht das nicht
</span> </span>

View file

@ -204,7 +204,7 @@ export function InvoicesTable({
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" onClick={handleDownload} disabled={selectedVisible.length === 0 || downloading}> <Button variant="secondary" onClick={handleDownload} disabled={selectedVisible.length === 0 || downloading}>
<ArrowDownToLineIcon /> <ArrowDownToLineIcon />
{downloading {downloading
@ -242,14 +242,14 @@ export function InvoicesTable({
<TableHead>Rechnung</TableHead> <TableHead>Rechnung</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Betrag</TableHead> <TableHead className="text-right">Betrag</TableHead>
<TableHead>Fällig am</TableHead> <TableHead className="hidden sm:table-cell">Fällig am</TableHead>
{isAdmin && <TableHead className="w-12" />} {isAdmin && <TableHead className="w-12" />}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visible.length === 0 ? ( {visible.length === 0 ? (
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableCell colSpan={isAdmin ? 6 : 5} className="py-8 text-center text-muted-foreground"> <TableCell colSpan={isAdmin ? 6 : 5} className="py-8 text-center text-muted-foreground wrap-anywhere">
Keine Treffer für „{query}“. Keine Treffer für „{query}“.
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -275,18 +275,21 @@ export function InvoicesTable({
href={invoiceUrl(invoice.id)} href={invoiceUrl(invoice.id)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:underline underline-offset-4" className="whitespace-nowrap hover:underline underline-offset-4"
> >
{invoice.invoiceNumber} {invoice.invoiceNumber}
</a> </a>
<span className="block text-small font-normal whitespace-nowrap text-muted-foreground tabular-nums sm:hidden">
fällig {formatDate(invoice.dueDate)}
</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={STATUS_VARIANTS[invoice.displayStatus]}> <Badge variant={STATUS_VARIANTS[invoice.displayStatus]}>
{STATUS_LABELS[invoice.displayStatus]} {STATUS_LABELS[invoice.displayStatus]}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(invoice.grossTotal)}</TableCell> <TableCell className="text-right tabular-nums whitespace-nowrap">{formatCurrency(invoice.grossTotal)}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">{formatDate(invoice.dueDate)}</TableCell> <TableCell className="hidden tabular-nums text-muted-foreground sm:table-cell">{formatDate(invoice.dueDate)}</TableCell>
{isAdmin && ( {isAdmin && (
<TableCell> <TableCell>
<div className="flex justify-end"> <div className="flex justify-end">

View file

@ -0,0 +1,24 @@
import Image from "next/image";
import { SidebarTrigger } from "@/components/ui/sidebar";
export function MobileHeader() {
return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:hidden">
<SidebarTrigger className="size-9" />
<Image
src="/pictures/since3-logo.svg"
alt="Since3"
width={1617}
height={332}
className="h-5 w-auto dark:hidden"
/>
<Image
src="/pictures/since3-logo-weiss.svg"
alt="Since3"
width={1617}
height={332}
className="hidden h-5 w-auto dark:block"
/>
</header>
)
}

View file

@ -29,8 +29,8 @@ export function ProductCheckLine({
} }
return ( return (
<div className="flex items-center gap-1 text-small"> <div className="flex items-start gap-1 text-small sm:items-center">
<span aria-live="polite" className={cn(lastCheck && !lastCheck.ok ? "text-signal-error" : "text-muted-foreground")}> <span aria-live="polite" className={cn("min-w-0 wrap-anywhere", lastCheck && !lastCheck.ok ? "text-signal-error" : "text-muted-foreground")}>
{pending {pending
? "Wird geprüft..." ? "Wird geprüft..."
: error ?? : error ??
@ -44,7 +44,7 @@ export function ProductCheckLine({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="size-6" className="size-6 shrink-0"
onClick={handleCheck} onClick={handleCheck}
disabled={pending} disabled={pending}
aria-label="Erreichbarkeit jetzt prüfen" aria-label="Erreichbarkeit jetzt prüfen"

View file

@ -43,9 +43,9 @@ export async function BillingPanel({
const invoices = clientId ? await getInvoices(clientId) : [] const invoices = clientId ? await getInvoices(clientId) : []
return ( return (
<div className="flex flex-col gap-8 w-full"> <div className="flex w-full flex-col gap-6 md:gap-8">
<div className="w-full flex flex-row justify-between items-center"> <div className="flex w-full flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-semibold">Rechnungen</h1> <h1 className="text-2xl font-semibold md:text-3xl">Rechnungen</h1>
</div> </div>
<InvoicesTable <InvoicesTable
profileId={profileId} profileId={profileId}

View file

@ -14,9 +14,9 @@ export async function CredentialsPanel({
const credentials = clientId ? await getCredentials(clientId) : [] const credentials = clientId ? await getCredentials(clientId) : []
return ( return (
<div className="flex flex-col gap-8 w-full"> <div className="flex w-full flex-col gap-6 md:gap-8">
<div className="w-full flex flex-row justify-between items-center"> <div className="flex w-full flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-semibold">Passwörter</h1> <h1 className="text-2xl font-semibold md:text-3xl">Passwörter</h1>
<CredentialDialog profileId={profileId} /> <CredentialDialog profileId={profileId} />
</div> </div>
<CredentialsTable profileId={profileId} credentials={credentials} isAdmin={isAdmin} /> <CredentialsTable profileId={profileId} credentials={credentials} isAdmin={isAdmin} />

View file

@ -26,8 +26,8 @@ export async function DocumentsPanel({
if (!clientId) { if (!clientId) {
return ( return (
<div className="flex flex-col gap-8 w-full"> <div className="flex w-full flex-col gap-6 md:gap-8">
<h1 className="text-3xl font-semibold">Dokumente</h1> <h1 className="text-2xl font-semibold md:text-3xl">Dokumente</h1>
<EmptyState> <EmptyState>
{isAdmin {isAdmin
? "Für diesen Client sind noch keine Stammdaten hinterlegt. Bitte zuerst die Stammdaten ausfüllen." ? "Für diesen Client sind noch keine Stammdaten hinterlegt. Bitte zuerst die Stammdaten ausfüllen."
@ -46,9 +46,9 @@ export async function DocumentsPanel({
return ( return (
<div className="flex flex-col gap-6 w-full"> <div className="flex flex-col gap-6 w-full">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<nav aria-label="Pfad" className="flex min-w-0 items-center gap-1.5 text-3xl font-semibold"> <nav aria-label="Pfad" className="flex min-w-0 items-center gap-1.5 text-2xl font-semibold md:text-3xl">
<Link href={basePath} className="text-muted-foreground hover:text-foreground">Dokumente</Link> <Link href={basePath} className="text-muted-foreground hover:text-foreground">Dokumente</Link>
<ChevronRightIcon className="size-6 shrink-0 text-muted-foreground" aria-hidden /> <ChevronRightIcon className="size-5 shrink-0 text-muted-foreground md:size-6" aria-hidden />
<span className="truncate">{folder.name}</span> <span className="truncate">{folder.name}</span>
</nav> </nav>
{isAdmin && documents.length === 0 && ( {isAdmin && documents.length === 0 && (
@ -65,7 +65,7 @@ export async function DocumentsPanel({
return ( return (
<div className="flex flex-col gap-6 w-full"> <div className="flex flex-col gap-6 w-full">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-semibold">Dokumente</h1> <h1 className="text-2xl font-semibold md:text-3xl">Dokumente</h1>
{isAdmin && <CreateFolderDialog profileId={profileId} />} {isAdmin && <CreateFolderDialog profileId={profileId} />}
</div> </div>
@ -79,9 +79,9 @@ export async function DocumentsPanel({
<li key={folder.id}> <li key={folder.id}>
<Link <Link
href={`${basePath}?folder=${folder.id}`} href={`${basePath}?folder=${folder.id}`}
className="flex flex-col items-center gap-2 rounded-lg border border-border bg-background p-5 text-center transition-colors hover:border-ink-300 hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring outline-none" className="flex flex-col items-center gap-2 rounded-lg border border-border bg-background p-4 text-center sm:p-5 transition-colors hover:border-ink-300 hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring outline-none"
> >
<FolderIcon className="size-12 fill-accent-400/20 stroke-[1.25] text-accent-500" aria-hidden /> <FolderIcon className="size-10 sm:size-12 fill-accent-400/20 stroke-[1.25] text-accent-500" aria-hidden />
<span className="w-full truncate font-medium">{folder.name}</span> <span className="w-full truncate font-medium">{folder.name}</span>
<span className="text-label text-muted-foreground"> <span className="text-label text-muted-foreground">
{folder.documentCount} {folder.documentCount === 1 ? "Datei" : "Dateien"} {folder.documentCount} {folder.documentCount === 1 ? "Datei" : "Dateien"}

View file

@ -80,7 +80,7 @@ function SectionHeader({ title, meta, action }: { title: React.ReactNode; meta?:
return ( return (
<div className="flex flex-wrap items-end justify-between gap-2"> <div className="flex flex-wrap items-end justify-between gap-2">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<h2 className="text-xl font-semibold">{title}</h2> <h2 className="text-lg font-semibold md:text-xl">{title}</h2>
{meta && <p className="text-small text-muted-foreground">{meta}</p>} {meta && <p className="text-small text-muted-foreground">{meta}</p>}
</div> </div>
{action} {action}
@ -115,9 +115,9 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
const today = berlinToday() const today = berlinToday()
return ( return (
<div className="flex flex-col gap-10 w-full"> <div className="flex w-full flex-col gap-8 md:gap-10">
<div className="w-full flex flex-col justify-between gap-2"> <div className="w-full flex flex-col justify-between gap-2">
<h1 className="text-3xl font-semibold">Info</h1> <h1 className="text-2xl font-semibold md:text-3xl">Info</h1>
<p className="text-accent-400">Status eurer Systeme und laufende Arbeiten auf einen Blick.</p> <p className="text-accent-400">Status eurer Systeme und laufende Arbeiten auf einen Blick.</p>
</div> </div>
@ -147,7 +147,7 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
<EmptyState>Noch keine Systeme hinterlegt.</EmptyState> <EmptyState>Noch keine Systeme hinterlegt.</EmptyState>
) : ( ) : (
<> <>
<div role="status" className={cn("flex items-center gap-3 rounded-lg border px-4 py-3 font-medium", banner.className)}> <div role="status" className={cn("flex items-start gap-3 rounded-lg border px-4 py-3 font-medium sm:items-center", banner.className)}>
<banner.icon className="size-5 shrink-0" /> <banner.icon className="size-5 shrink-0" />
{banner.text} {banner.text}
</div> </div>
@ -158,8 +158,8 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead>Produkt</TableHead> <TableHead>Produkt</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Uptime</TableHead> <TableHead className="hidden text-right sm:table-cell">Uptime</TableHead>
{isAdmin && <TableHead className="w-24" />} {isAdmin && <TableHead className="w-20 sm:w-24" />}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -173,11 +173,14 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
aria-label={PRODUCT_STATUS_LABELS[product.status]} aria-label={PRODUCT_STATUS_LABELS[product.status]}
title={PRODUCT_STATUS_LABELS[product.status]} title={PRODUCT_STATUS_LABELS[product.status]}
/> />
<div className="flex flex-col"> <div className="flex min-w-0 flex-col">
<span className="font-medium">{product.name}</span> <span className="font-medium">{product.name}</span>
<span className="text-small text-muted-foreground"> <span className="text-small text-muted-foreground wrap-anywhere">
{product.url ? <ProductLink value={product.url} /> : CATEGORY_LABELS[product.category]} {product.url ? <ProductLink value={product.url} /> : CATEGORY_LABELS[product.category]}
</span> </span>
<span className="text-small tabular-nums sm:hidden">
<UptimeValue value={displayedUptime(product)} />
</span>
</div> </div>
</div> </div>
</TableCell> </TableCell>
@ -196,7 +199,7 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
</div> </div>
</TableCell> </TableCell>
<TableCell <TableCell
className="text-right tabular-nums whitespace-nowrap" className="hidden text-right tabular-nums whitespace-nowrap sm:table-cell"
title={product.checks30d > 0 ? `Gemessen aus ${product.checks30d} Prüfungen` : undefined} title={product.checks30d > 0 ? `Gemessen aus ${product.checks30d} Prüfungen` : undefined}
> >
<UptimeValue value={displayedUptime(product)} /> <UptimeValue value={displayedUptime(product)} />
@ -234,19 +237,21 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
) : ( ) : (
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border"> <ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
{tasks.map((task) => ( {tasks.map((task) => (
<li key={task.id} className="flex items-center gap-4 px-4 py-3"> <li key={task.id} className="flex items-start gap-3 px-4 py-3 sm:items-center sm:gap-4">
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20"> <Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="mt-0.5 w-20 shrink-0 sm:mt-0">
{TASK_STATUS_LABELS[task.status]} {TASK_STATUS_LABELS[task.status]}
</Badge> </Badge>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col gap-0.5 sm:flex-row sm:items-center sm:gap-4">
<span className="font-medium">{task.name}</span> <div className="flex min-w-0 flex-1 flex-col">
{task.description && ( <span className="font-medium break-words">{task.name}</span>
<span className="text-small text-muted-foreground">{task.description}</span> {task.description && (
)} <span className="text-small text-muted-foreground break-words">{task.description}</span>
)}
</div>
<span className="text-small text-muted-foreground tabular-nums sm:shrink-0">
{formatTaskTiming(task.status, task.since, today)}
</span>
</div> </div>
<span className="shrink-0 text-small text-muted-foreground tabular-nums">
{formatTaskTiming(task.status, task.since, today)}
</span>
{isAdmin && ( {isAdmin && (
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
<TaskDialog profileId={profileId} task={task} /> <TaskDialog profileId={profileId} task={task} />

View file

@ -26,9 +26,9 @@ export function StammdatenPanel({
} }
return ( return (
<div className="flex flex-col gap-8 w-full"> <div className="flex w-full flex-col gap-6 md:gap-8">
<div className="w-full flex flex-row justify-between items-center"> <div className="flex w-full flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-semibold">Stammdaten</h1> <h1 className="text-2xl font-semibold md:text-3xl">Stammdaten</h1>
{!isEditing && ( {!isEditing && (
<Button variant="outline" onClick={() => setIsEditing(true)}> <Button variant="outline" onClick={() => setIsEditing(true)}>
<Pen className="w-4 h-4 mr-2" /> Edit <Pen className="w-4 h-4 mr-2" /> Edit

51
package-lock.json generated
View file

@ -693,18 +693,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@dotenvx/dotenvx/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@ -2625,7 +2613,7 @@
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -4254,7 +4242,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
@ -7423,6 +7411,18 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@ -8184,12 +8184,12 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=12"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
@ -9674,19 +9674,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -9897,7 +9884,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View file

@ -22,5 +22,5 @@ export default async function proxy(request: NextRequest) {
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|pictures).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|pictures|manifest.webmanifest|apple-icon|icons/icon-).*)"],
} }