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

@ -183,7 +183,7 @@ function BillingDialogContent({
<DialogHeader>
<DialogTitle>Vorschau</DialogTitle>
</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>}
<DialogFooter className="gap-2">
<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>
<Input id="billingStreet" value={header.billingStreet} onChange={(e) => updateHeader("billingStreet", e.target.value)} />
</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>
<FieldLabel htmlFor="billingPostalCode">PLZ <span className="text-destructive">*</span></FieldLabel>
<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;
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">
<Field>
<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 className="col-span-3 md:col-span-1">
<FieldLabel htmlFor={`template-${row.key}`}>Vorlage</FieldLabel>
<select
id={`template-${row.key}`}
@ -274,7 +274,7 @@ function BillingDialogContent({
))}
</select>
</Field>
<Field>
<Field className="col-span-3 md:col-span-1">
<FieldLabel htmlFor={`description-${row.key}`}>Bezeichnung</FieldLabel>
<Input id={`description-${row.key}`} value={row.description} onChange={(e) => updateRow(row.key, { description: e.target.value })} />
</Field>
@ -290,7 +290,7 @@ function BillingDialogContent({
<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 })} />
</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">
{net === null ? "–" : formatCurrency(net)}
</span>
@ -363,7 +363,7 @@ export function BillingDialog({
</>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogContent className="sm:max-w-5xl">
{prefill ? (
<BillingDialogContent
key={instanceKey}

View file

@ -46,7 +46,7 @@ function RevealButton({ credentialId }: { credentialId: string }) {
return (
<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 ?? "••••••••"}
</span>
<Button
@ -86,9 +86,9 @@ export function CredentialsTable({
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Dienst</TableHead>
<TableHead>Benutzername</TableHead>
<TableHead className="hidden md:table-cell">Benutzername</TableHead>
<TableHead>Passwort</TableHead>
<TableHead className="w-24" />
<TableHead className="w-20 sm:w-24" />
</TableRow>
</TableHeader>
<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">
<ServiceLogo serviceId={credential.service} size={18} className="size-4.5 object-contain" />
</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>
</TableCell>
<TableCell className="text-muted-foreground">{credential.username}</TableCell>
<TableCell className="hidden text-muted-foreground break-all md:table-cell">{credential.username}</TableCell>
<TableCell>
<RevealButton credentialId={credential.id} />
</TableCell>

View file

@ -108,16 +108,16 @@ function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) {
switch (kind) {
case "image":
// 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":
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":
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":
return <audio src={url} controls className="w-full" />;
case "text":
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..."}
</pre>
);
@ -185,7 +185,7 @@ function PreviewDialog({
return (
<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 && (
<>
<DialogHeader>
@ -398,7 +398,7 @@ export function DocumentsBrowser({
</label>
)}
</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}>
{downloading ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
{downloading ? "Wird vorbereitet..." : visibleSelection.length > 1 ? `Download (${visibleSelection.length})` : "Download"}
@ -427,7 +427,7 @@ export function DocumentsBrowser({
<button
type="button"
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" />
<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)} />
) : (
<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">
<Button variant="ghost" size="icon-sm" aria-label="Notiz bearbeiten" onClick={() => setEditing(true)}>
<PencilIcon className="size-4" />
@ -87,7 +87,7 @@ export function InternalNotes({ profileId, notes }: { profileId: string; notes:
return (
<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">
<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">
<EyeOffIcon className="size-3.5" /> Kunde sieht das nicht
</span>

View file

@ -204,7 +204,7 @@ export function InvoicesTable({
</InputGroupAddon>
</InputGroup>
</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}>
<ArrowDownToLineIcon />
{downloading
@ -242,14 +242,14 @@ export function InvoicesTable({
<TableHead>Rechnung</TableHead>
<TableHead>Status</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" />}
</TableRow>
</TableHeader>
<TableBody>
{visible.length === 0 ? (
<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}“.
</TableCell>
</TableRow>
@ -275,18 +275,21 @@ export function InvoicesTable({
href={invoiceUrl(invoice.id)}
target="_blank"
rel="noopener noreferrer"
className="hover:underline underline-offset-4"
className="whitespace-nowrap hover:underline underline-offset-4"
>
{invoice.invoiceNumber}
</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>
<Badge variant={STATUS_VARIANTS[invoice.displayStatus]}>
{STATUS_LABELS[invoice.displayStatus]}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(invoice.grossTotal)}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">{formatDate(invoice.dueDate)}</TableCell>
<TableCell className="text-right tabular-nums whitespace-nowrap">{formatCurrency(invoice.grossTotal)}</TableCell>
<TableCell className="hidden tabular-nums text-muted-foreground sm:table-cell">{formatDate(invoice.dueDate)}</TableCell>
{isAdmin && (
<TableCell>
<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 (
<div className="flex items-center gap-1 text-small">
<span aria-live="polite" className={cn(lastCheck && !lastCheck.ok ? "text-signal-error" : "text-muted-foreground")}>
<div className="flex items-start gap-1 text-small sm:items-center">
<span aria-live="polite" className={cn("min-w-0 wrap-anywhere", lastCheck && !lastCheck.ok ? "text-signal-error" : "text-muted-foreground")}>
{pending
? "Wird geprüft..."
: error ??
@ -44,7 +44,7 @@ export function ProductCheckLine({
type="button"
variant="ghost"
size="icon-sm"
className="size-6"
className="size-6 shrink-0"
onClick={handleCheck}
disabled={pending}
aria-label="Erreichbarkeit jetzt prüfen"

View file

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

View file

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

View file

@ -26,8 +26,8 @@ export async function DocumentsPanel({
if (!clientId) {
return (
<div className="flex flex-col gap-8 w-full">
<h1 className="text-3xl font-semibold">Dokumente</h1>
<div className="flex w-full flex-col gap-6 md:gap-8">
<h1 className="text-2xl font-semibold md:text-3xl">Dokumente</h1>
<EmptyState>
{isAdmin
? "Für diesen Client sind noch keine Stammdaten hinterlegt. Bitte zuerst die Stammdaten ausfüllen."
@ -46,9 +46,9 @@ export async function DocumentsPanel({
return (
<div className="flex flex-col gap-6 w-full">
<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>
<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>
</nav>
{isAdmin && documents.length === 0 && (
@ -65,7 +65,7 @@ export async function DocumentsPanel({
return (
<div className="flex flex-col gap-6 w-full">
<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} />}
</div>
@ -79,9 +79,9 @@ export async function DocumentsPanel({
<li key={folder.id}>
<Link
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="text-label text-muted-foreground">
{folder.documentCount} {folder.documentCount === 1 ? "Datei" : "Dateien"}

View file

@ -80,7 +80,7 @@ function SectionHeader({ title, meta, action }: { title: React.ReactNode; meta?:
return (
<div className="flex flex-wrap items-end justify-between gap-2">
<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>}
</div>
{action}
@ -115,9 +115,9 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
const today = berlinToday()
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">
<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>
</div>
@ -147,7 +147,7 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
<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.text}
</div>
@ -158,8 +158,8 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
<TableRow className="hover:bg-transparent">
<TableHead>Produkt</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Uptime</TableHead>
{isAdmin && <TableHead className="w-24" />}
<TableHead className="hidden text-right sm:table-cell">Uptime</TableHead>
{isAdmin && <TableHead className="w-20 sm:w-24" />}
</TableRow>
</TableHeader>
<TableBody>
@ -173,11 +173,14 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
aria-label={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="text-small text-muted-foreground">
<span className="text-small text-muted-foreground wrap-anywhere">
{product.url ? <ProductLink value={product.url} /> : CATEGORY_LABELS[product.category]}
</span>
<span className="text-small tabular-nums sm:hidden">
<UptimeValue value={displayedUptime(product)} />
</span>
</div>
</div>
</TableCell>
@ -196,7 +199,7 @@ const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: b
</div>
</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}
>
<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">
{tasks.map((task) => (
<li key={task.id} className="flex items-center gap-4 px-4 py-3">
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20">
<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="mt-0.5 w-20 shrink-0 sm:mt-0">
{TASK_STATUS_LABELS[task.status]}
</Badge>
<div className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{task.name}</span>
{task.description && (
<span className="text-small text-muted-foreground">{task.description}</span>
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5 sm:flex-row sm:items-center sm:gap-4">
<div className="flex min-w-0 flex-1 flex-col">
<span className="font-medium break-words">{task.name}</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>
<span className="shrink-0 text-small text-muted-foreground tabular-nums">
{formatTaskTiming(task.status, task.since, today)}
</span>
{isAdmin && (
<div className="flex shrink-0 gap-1">
<TaskDialog profileId={profileId} task={task} />

View file

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