first working release
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -39,3 +39,12 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# prisma — Datenbankdatei NICHT einchecken
|
||||||
|
/prisma/*.db
|
||||||
|
/prisma/*.db-journal
|
||||||
|
|
||||||
|
/*.db
|
||||||
|
/*.db-journal
|
||||||
2745
package-lock.json
generated
2745
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,17 +9,24 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/tailwindcss": "^3.0.11",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
52
prisma/schema.prisma
Normal file
52
prisma/schema.prisma
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
// A physical location in the building (e.g., "Serverraum", "Wohnzimmer")
|
||||||
|
model Location {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
description String? // Optional Markdown text
|
||||||
|
|
||||||
|
// A location can be the start OR end of many cables
|
||||||
|
startCables Cable[] @relation("StartLocation")
|
||||||
|
endCables Cable[] @relation("EndLocation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single cable run documented in the system
|
||||||
|
model Cable {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
identifier String // e.g. "Kabel 01", "LAN-01"
|
||||||
|
description String? // Short plain-text summary
|
||||||
|
notes String? // Long-form Markdown notes
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
startLocationId Int
|
||||||
|
endLocationId Int
|
||||||
|
|
||||||
|
startLocation Location @relation("StartLocation", fields: [startLocationId], references: [id])
|
||||||
|
endLocation Location @relation("EndLocation", fields: [endLocationId], references: [id])
|
||||||
|
|
||||||
|
cores Core[] // One cable can have many cores/wires
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single wire/core within a cable (e.g., "Blau", "Rot/Weiß")
|
||||||
|
model Core {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
color String // Color designation, e.g. "Blau", "Braun/Weiß"
|
||||||
|
notes String? // Optional Markdown notes
|
||||||
|
|
||||||
|
cableId Int
|
||||||
|
cable Cable @relation(fields: [cableId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
58
src/actions/cableActions.ts
Normal file
58
src/actions/cableActions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
// Discriminated union return type allows the client to branch on success/failure
|
||||||
|
export type CreateCableResult =
|
||||||
|
| { success: true; cableId: number }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Cable and returns either the new cable's ID (on success)
|
||||||
|
* or an error message (on validation/DB failure).
|
||||||
|
* The client component handles navigation to the detail page on success.
|
||||||
|
*/
|
||||||
|
export async function createCable(
|
||||||
|
formData: FormData
|
||||||
|
): Promise<CreateCableResult> {
|
||||||
|
const identifier = (formData.get("identifier") as string)?.trim();
|
||||||
|
const description = (formData.get("description") as string)?.trim();
|
||||||
|
const notes = (formData.get("notes") as string)?.trim();
|
||||||
|
const startLocationId = Number(formData.get("startLocationId"));
|
||||||
|
const endLocationId = Number(formData.get("endLocationId"));
|
||||||
|
|
||||||
|
if (!identifier) {
|
||||||
|
return { success: false, error: "Bezeichnung ist erforderlich." };
|
||||||
|
}
|
||||||
|
if (!startLocationId || !endLocationId) {
|
||||||
|
return { success: false, error: "Start- und End-Ort müssen ausgewählt werden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cable = await prisma.cable.create({
|
||||||
|
data: {
|
||||||
|
identifier,
|
||||||
|
description: description || null,
|
||||||
|
notes: notes || null,
|
||||||
|
startLocationId,
|
||||||
|
endLocationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/cables");
|
||||||
|
return { success: true, cableId: cable.id };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("createCable failed:", e);
|
||||||
|
return { success: false, error: "Fehler beim Speichern des Kabels." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a Cable and all its Cores (via Prisma's onDelete: Cascade).
|
||||||
|
* Navigation back to /cables is handled by the client component.
|
||||||
|
*/
|
||||||
|
export async function deleteCable(id: number): Promise<void> {
|
||||||
|
await prisma.cable.delete({ where: { id } });
|
||||||
|
revalidatePath("/cables");
|
||||||
|
}
|
||||||
52
src/actions/coreActions.ts
Normal file
52
src/actions/coreActions.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export type FormState = {
|
||||||
|
error?: string;
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a Core/Wire to an existing Cable. Designed for useActionState.
|
||||||
|
*/
|
||||||
|
export async function createCore(
|
||||||
|
prevState: FormState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<FormState> {
|
||||||
|
const cableId = Number(formData.get("cableId"));
|
||||||
|
const color = (formData.get("color") as string)?.trim();
|
||||||
|
const notes = (formData.get("notes") as string)?.trim();
|
||||||
|
|
||||||
|
if (!color) {
|
||||||
|
return { error: "Farbe ist erforderlich." };
|
||||||
|
}
|
||||||
|
if (!cableId || isNaN(cableId)) {
|
||||||
|
return { error: "Ungültige Kabel-ID." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.core.create({
|
||||||
|
data: {
|
||||||
|
cableId,
|
||||||
|
color,
|
||||||
|
notes: notes || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/cables/${cableId}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("createCore failed:", e);
|
||||||
|
return { error: "Fehler beim Speichern der Ader." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a single Core by ID.
|
||||||
|
*/
|
||||||
|
export async function deleteCore(id: number, cableId: number): Promise<void> {
|
||||||
|
await prisma.core.delete({ where: { id } });
|
||||||
|
revalidatePath(`/cables/${cableId}`);
|
||||||
|
}
|
||||||
52
src/actions/locationActions.ts
Normal file
52
src/actions/locationActions.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
// Shared form state shape used with useActionState
|
||||||
|
export type FormState = {
|
||||||
|
error?: string;
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Location. Designed for use with React's useActionState hook.
|
||||||
|
*/
|
||||||
|
export async function createLocation(
|
||||||
|
prevState: FormState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<FormState> {
|
||||||
|
const name = (formData.get("name") as string)?.trim();
|
||||||
|
const description = (formData.get("description") as string)?.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return { error: "Name ist erforderlich." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.location.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: description || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate both pages that show location data
|
||||||
|
revalidatePath("/locations");
|
||||||
|
revalidatePath("/cables");
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("createLocation failed:", e);
|
||||||
|
return { error: "Fehler beim Anlegen des Ortes. Bitte erneut versuchen." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a Location by ID. The UI prevents calling this if the location
|
||||||
|
* is still referenced by cables (enforced via disabled state on the button).
|
||||||
|
*/
|
||||||
|
export async function deleteLocation(id: number): Promise<void> {
|
||||||
|
await prisma.location.delete({ where: { id } });
|
||||||
|
revalidatePath("/locations");
|
||||||
|
revalidatePath("/cables");
|
||||||
|
}
|
||||||
141
src/app/cables/[id]/page.tsx
Normal file
141
src/app/cables/[id]/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import MarkdownContent from "@/components/ui/MarkdownContent";
|
||||||
|
import CoresList from "@/components/cores/CoresList";
|
||||||
|
import NewCoreForm from "@/components/cores/NewCoreForm";
|
||||||
|
import DeleteCableButton from "@/components/cables/DeleteCableButton";
|
||||||
|
|
||||||
|
// Next.js 15: dynamic route params are a Promise
|
||||||
|
interface CableDetailPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CableDetailPage({
|
||||||
|
params,
|
||||||
|
}: CableDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const cableId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (isNaN(cableId)) notFound();
|
||||||
|
|
||||||
|
const cable = await prisma.cable.findUnique({
|
||||||
|
where: { id: cableId },
|
||||||
|
include: {
|
||||||
|
startLocation: true,
|
||||||
|
endLocation: true,
|
||||||
|
cores: { orderBy: { id: "asc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cable) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="flex items-center gap-2 text-sm text-slate-400">
|
||||||
|
<Link href="/cables" className="hover:text-slate-700 transition-colors">
|
||||||
|
Kabel-Übersicht
|
||||||
|
</Link>
|
||||||
|
<span>›</span>
|
||||||
|
<span className="text-slate-700 font-medium">{cable.identifier}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ── Header card ── */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">
|
||||||
|
{cable.identifier}
|
||||||
|
</h1>
|
||||||
|
{cable.description && (
|
||||||
|
<p className="text-slate-500 mt-1">{cable.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-400 mt-2 tabular-nums">
|
||||||
|
Erstellt:{" "}
|
||||||
|
{new Date(cable.createdAt).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
})}{" "}
|
||||||
|
· Aktualisiert:{" "}
|
||||||
|
{new Date(cable.updatedAt).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DeleteCableButton id={cable.id} identifier={cable.identifier} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route visualization */}
|
||||||
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-[1fr_auto_1fr] gap-3 items-center">
|
||||||
|
{/* Start location */}
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4">
|
||||||
|
<p className="text-xs text-blue-400 font-medium uppercase tracking-wide mb-1">
|
||||||
|
Start-Ort
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-900 font-semibold">{cable.startLocation.name}</p>
|
||||||
|
{cable.startLocation.description && (
|
||||||
|
<p className="text-blue-600 text-xs mt-1">
|
||||||
|
{cable.startLocation.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="flex justify-center text-slate-300">
|
||||||
|
<svg className="w-6 h-6 rotate-90 sm:rotate-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End location */}
|
||||||
|
<div className="bg-emerald-50 border border-emerald-100 rounded-lg p-4">
|
||||||
|
<p className="text-xs text-emerald-400 font-medium uppercase tracking-wide mb-1">
|
||||||
|
End-Ort
|
||||||
|
</p>
|
||||||
|
<p className="text-emerald-900 font-semibold">{cable.endLocation.name}</p>
|
||||||
|
{cable.endLocation.description && (
|
||||||
|
<p className="text-emerald-600 text-xs mt-1">
|
||||||
|
{cable.endLocation.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Notes card (only shown if notes exist) ── */}
|
||||||
|
{cable.notes && (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-3">
|
||||||
|
Notizen
|
||||||
|
</h2>
|
||||||
|
<MarkdownContent content={cable.notes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Cores card ── */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||||
|
{/* Section header */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">Adern</h2>
|
||||||
|
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">
|
||||||
|
{cable.cores.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Existing cores list */}
|
||||||
|
<CoresList cores={cable.cores} cableId={cable.id} />
|
||||||
|
|
||||||
|
{/* Divider before add-form */}
|
||||||
|
<div className="border-t border-slate-100 pt-6">
|
||||||
|
<NewCoreForm cableId={cable.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/cables/page.tsx
Normal file
49
src/app/cables/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import CablesTable from "@/components/cables/CablesTable";
|
||||||
|
import NewCableForm from "@/components/cables/NewCableForm";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function CablesPage() {
|
||||||
|
const [cables, locations] = await Promise.all([
|
||||||
|
prisma.cable.findMany({
|
||||||
|
include: {
|
||||||
|
startLocation: true,
|
||||||
|
endLocation: true,
|
||||||
|
_count: { select: { cores: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.location.findMany({ orderBy: { name: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Kabel-Übersicht</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm">
|
||||||
|
Alle dokumentierten Kabel mit Start-Ort, End-Ort und Adern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show warning if no locations exist yet */}
|
||||||
|
{locations.length === 0 ? (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5 mb-6 text-sm text-amber-800">
|
||||||
|
<strong>Hinweis:</strong> Es sind noch keine Orte angelegt.{" "}
|
||||||
|
<Link
|
||||||
|
href="/locations"
|
||||||
|
className="font-semibold underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Bitte legen Sie zuerst mindestens einen Ort an.
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-6">
|
||||||
|
<NewCableForm locations={locations} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CablesTable cables={cables} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,8 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
/* Push content below the fixed mobile header */
|
||||||
--background: #ffffff;
|
@media (max-width: 1023px) {
|
||||||
--foreground: #171717;
|
body > div > main {
|
||||||
}
|
padding-top: 4rem;
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Navigation from "@/components/Navigation";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Kabel-Dokumentation",
|
||||||
description: "Generated by create next app",
|
description: "Dokumentation aller Netzwerk-, Strom- und Signalkabel im Haus",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="de">
|
||||||
lang="en"
|
<body className={`${inter.className} bg-slate-50 text-slate-900`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
<div className="flex min-h-screen">
|
||||||
>
|
{/* Sidebar — fixed on desktop, slide-in overlay on mobile */}
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<Navigation />
|
||||||
|
{/* Main content — offset by sidebar width on large screens */}
|
||||||
|
<main className="flex-1 lg:ml-64 p-6 md:p-8">
|
||||||
|
<div className="max-w-5xl mx-auto">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
39
src/app/locations/page.tsx
Normal file
39
src/app/locations/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import NewLocationForm from "@/components/locations/NewLocationForm";
|
||||||
|
import LocationsTable from "@/components/locations/LocationsTabe";
|
||||||
|
|
||||||
|
export default async function LocationsPage() {
|
||||||
|
const locations = await prisma.location.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
startCables: true,
|
||||||
|
endCables: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Orte verwalten</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm">
|
||||||
|
Räume und Installationspunkte für die Kabeldokumentation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout: form left, table right */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<NewLocationForm />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<LocationsTable locations={locations} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,6 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
// The root URL redirects to the cable overview
|
||||||
return (
|
export default function HomePage() {
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
redirect("/cables");
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
188
src/components/Navigation.tsx
Normal file
188
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
href: "/cables",
|
||||||
|
label: "Kabel-Übersicht",
|
||||||
|
icon: (
|
||||||
|
// Simple cable/plug icon via inline SVG
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/locations",
|
||||||
|
label: "Orte verwalten",
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Navigation() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Mobile top bar ── */}
|
||||||
|
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between
|
||||||
|
bg-slate-900 px-4 py-3 border-b border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center">
|
||||||
|
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-semibold text-sm">Kabel-Manager</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
|
className="text-slate-300 hover:text-white p-1 rounded"
|
||||||
|
aria-label="Navigation öffnen"
|
||||||
|
>
|
||||||
|
{mobileOpen ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Desktop sidebar ── */}
|
||||||
|
<aside className="hidden lg:flex fixed inset-y-0 left-0 w-64 bg-slate-900 flex-col z-40">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Brand / Logo area */}
|
||||||
|
<div className="px-5 py-5 border-b border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold text-sm leading-tight">Kabel-Manager</p>
|
||||||
|
<p className="text-slate-400 text-xs">Hausverkabelung</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||||
|
transition-colors duration-150
|
||||||
|
${isActive
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "text-slate-300 hover:bg-slate-700/60 hover:text-white"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer note */}
|
||||||
|
<div className="px-5 py-4 border-t border-slate-700/50">
|
||||||
|
<p className="text-slate-500 text-xs">
|
||||||
|
Kabel, Adern & Orte dokumentieren
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Mobile drawer ── */}
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
lg:hidden fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40
|
||||||
|
transform transition-transform duration-200 ease-in-out
|
||||||
|
${mobileOpen ? "translate-x-0" : "-translate-x-full"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="pt-14"> {/* offset mobile top bar */}
|
||||||
|
{/* Brand / Logo area */}
|
||||||
|
<div className="px-5 py-5 border-b border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold text-sm leading-tight">Kabel-Manager</p>
|
||||||
|
<p className="text-slate-400 text-xs">Hausverkabelung</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||||
|
transition-colors duration-150
|
||||||
|
${isActive
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "text-slate-300 hover:bg-slate-700/60 hover:text-white"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer note */}
|
||||||
|
<div className="px-5 py-4 border-t border-slate-700/50">
|
||||||
|
<p className="text-slate-500 text-xs">
|
||||||
|
Kabel, Adern & Orte dokumentieren
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Mobile backdrop overlay ── */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 z-30 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/components/cables/CablesTable.tsx
Normal file
172
src/components/cables/CablesTable.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { CableWithLocations } from "@/lib/types";
|
||||||
|
|
||||||
|
interface CablesTableProps {
|
||||||
|
cables: CableWithLocations[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CablesTable({ cables }: CablesTableProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const filtered = query.trim()
|
||||||
|
? cables.filter((cable) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
cable.identifier.toLowerCase().includes(q) ||
|
||||||
|
(cable.description ?? "").toLowerCase().includes(q) ||
|
||||||
|
(cable.notes ?? "").toLowerCase().includes(q) ||
|
||||||
|
cable.startLocation.name.toLowerCase().includes(q) ||
|
||||||
|
cable.endLocation.name.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: cables;
|
||||||
|
|
||||||
|
if (cables.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-14 text-center shadow-sm">
|
||||||
|
<div className="text-4xl mb-4">🔌</div>
|
||||||
|
<p className="text-slate-600 font-medium">
|
||||||
|
Noch keine Kabel dokumentiert.
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
|
Legen Sie oben Ihr erstes Kabel an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
{/* Header with search */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">Alle Kabel</h2>
|
||||||
|
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">
|
||||||
|
{filtered.length}{query.trim() ? ` / ${cables.length}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Kabel suchen…"
|
||||||
|
className="
|
||||||
|
pl-9 pr-3 py-1.5 text-sm
|
||||||
|
border border-slate-200 rounded-lg
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
placeholder:text-slate-400 w-full sm:w-56
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No results */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center text-slate-400 text-sm">
|
||||||
|
Keine Kabel für „{query}" gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Responsive table */
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
|
||||||
|
Bezeichnung
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
|
||||||
|
Start-Ort
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
|
||||||
|
End-Ort
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden sm:table-cell">
|
||||||
|
Adern
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden md:table-cell">
|
||||||
|
Angelegt
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{filtered.map((cable) => (
|
||||||
|
<tr key={cable.id} className="hover:bg-slate-50/60 group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Link
|
||||||
|
href={`/cables/${cable.id}`}
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
|
{cable.identifier}
|
||||||
|
</Link>
|
||||||
|
{cable.description && (
|
||||||
|
<p className="text-slate-400 text-xs mt-0.5">
|
||||||
|
{cable.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="
|
||||||
|
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
|
bg-blue-50 text-blue-700
|
||||||
|
">
|
||||||
|
{cable.startLocation.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="
|
||||||
|
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
|
bg-emerald-50 text-emerald-700
|
||||||
|
">
|
||||||
|
{cable.endLocation.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 hidden sm:table-cell">
|
||||||
|
<span className="
|
||||||
|
inline-flex items-center px-2 py-0.5 rounded text-xs
|
||||||
|
bg-slate-100 text-slate-600 tabular-nums
|
||||||
|
">
|
||||||
|
{cable._count.cores}{" "}
|
||||||
|
{cable._count.cores === 1 ? "Ader" : "Adern"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-slate-400 text-xs hidden md:table-cell tabular-nums">
|
||||||
|
{new Date(cable.createdAt).toLocaleDateString("de-DE")}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/cables/${cable.id}`}
|
||||||
|
className="
|
||||||
|
text-xs text-slate-400 hover:text-slate-700
|
||||||
|
opacity-0 group-hover:opacity-100 transition-opacity duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Details →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/cables/DeleteCableButton.tsx
Normal file
55
src/components/cables/DeleteCableButton.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { deleteCable } from "@/actions/cableActions";
|
||||||
|
|
||||||
|
interface DeleteCableButtonProps {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteCableButton({
|
||||||
|
id,
|
||||||
|
identifier,
|
||||||
|
}: DeleteCableButtonProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Kabel „${identifier}" wirklich löschen?\n\nAlle zugehörigen Adern werden ebenfalls gelöscht.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteCable(id);
|
||||||
|
// Navigation is handled client-side (server action does not redirect)
|
||||||
|
router.push("/cables");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
className="
|
||||||
|
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||||
|
border border-red-200 text-red-600
|
||||||
|
hover:bg-red-50 hover:border-red-300
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
text-sm font-medium rounded-lg transition-colors duration-150
|
||||||
|
flex-shrink-0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
{isPending ? "Wird gelöscht…" : "Kabel löschen"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
src/components/cables/NewCableForm.tsx
Normal file
228
src/components/cables/NewCableForm.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { Location } from "@/generated/prisma/client";
|
||||||
|
import { createCable } from "@/actions/cableActions";
|
||||||
|
|
||||||
|
interface NewCableFormProps {
|
||||||
|
locations: Location[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewCableForm({ locations }: NewCableFormProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createCable(formData);
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
router.push(`/cables/${result.cableId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="
|
||||||
|
inline-flex items-center gap-2 px-4 py-2.5
|
||||||
|
bg-blue-600 hover:bg-blue-700 text-white
|
||||||
|
text-sm font-medium rounded-lg transition-colors duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Neues Kabel anlegen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||||
|
{/* Form header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">
|
||||||
|
Neues Kabel anlegen
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsOpen(false); setError(null); }}
|
||||||
|
className="text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label="Formular schließen"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form fields */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
{/* Identifier */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="cable-identifier"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1.5"
|
||||||
|
>
|
||||||
|
Bezeichnung <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cable-identifier"
|
||||||
|
name="identifier"
|
||||||
|
type="text"
|
||||||
|
placeholder="z. B. Kabel 01, LAN-01, Strom-KG-01"
|
||||||
|
required
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
placeholder:text-slate-400
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start location */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="cable-start"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1.5"
|
||||||
|
>
|
||||||
|
Start-Ort <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="cable-start"
|
||||||
|
name="startLocationId"
|
||||||
|
required
|
||||||
|
defaultValue=""
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-white
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Ort auswählen…</option>
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<option key={loc.id} value={loc.id}>
|
||||||
|
{loc.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End location */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="cable-end"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1.5"
|
||||||
|
>
|
||||||
|
End-Ort <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="cable-end"
|
||||||
|
name="endLocationId"
|
||||||
|
required
|
||||||
|
defaultValue=""
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-white
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Ort auswählen…</option>
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<option key={loc.id} value={loc.id}>
|
||||||
|
{loc.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Short description */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="cable-description"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1.5"
|
||||||
|
>
|
||||||
|
Kurzbeschreibung{" "}
|
||||||
|
<span className="text-slate-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cable-description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
placeholder="z. B. Netzwerkkabel, CAT 7"
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
placeholder:text-slate-400
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes (Markdown) */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="cable-notes"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1.5"
|
||||||
|
>
|
||||||
|
Notizen{" "}
|
||||||
|
<span className="text-slate-400 font-normal">(optional, Markdown)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="cable-notes"
|
||||||
|
name="notes"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Verlegeweg, Besonderheiten, Querschnitt…"
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
placeholder:text-slate-400 resize-y
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="
|
||||||
|
px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400
|
||||||
|
text-white text-sm font-medium rounded-lg
|
||||||
|
transition-colors duration-150 disabled:cursor-not-allowed
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{isPending ? "Wird gespeichert…" : "Kabel anlegen"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setIsOpen(false); setError(null); }}
|
||||||
|
className="
|
||||||
|
px-5 py-2 bg-slate-100 hover:bg-slate-200
|
||||||
|
text-slate-700 text-sm font-medium rounded-lg
|
||||||
|
transition-colors duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/cores/CoresList.tsx
Normal file
77
src/components/cores/CoresList.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Core } from "@prisma/client";
|
||||||
|
import DeleteCoreButton from "./DeleteCoreButton";
|
||||||
|
import MarkdownContent from "@/components/ui/MarkdownContent";
|
||||||
|
|
||||||
|
interface CoresListProps {
|
||||||
|
cores: Core[];
|
||||||
|
cableId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps German color names to Tailwind background colors for the indicator dot.
|
||||||
|
// Supports "Farbe1/Farbe2" notation (uses the first color).
|
||||||
|
const COLOR_MAP: Record<string, string> = {
|
||||||
|
blau: "bg-blue-500",
|
||||||
|
rot: "bg-red-500",
|
||||||
|
grün: "bg-green-500",
|
||||||
|
gelb: "bg-yellow-400",
|
||||||
|
braun: "bg-amber-800",
|
||||||
|
orange: "bg-orange-500",
|
||||||
|
grau: "bg-slate-400",
|
||||||
|
schwarz: "bg-slate-900",
|
||||||
|
weiß: "bg-white border border-slate-300",
|
||||||
|
violett: "bg-violet-500",
|
||||||
|
lila: "bg-purple-500",
|
||||||
|
pink: "bg-pink-400",
|
||||||
|
türkis: "bg-cyan-400",
|
||||||
|
beige: "bg-amber-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getColorClass(color: string): string {
|
||||||
|
// Handle "Rot/Weiß" or "Blau-Weiß" by taking the first color segment
|
||||||
|
const primary = color.toLowerCase().split(/[/\-,]/)[0].trim();
|
||||||
|
return COLOR_MAP[primary] ?? "bg-slate-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CoresList({ cores, cableId }: CoresListProps) {
|
||||||
|
if (cores.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-slate-400 text-sm text-center py-4">
|
||||||
|
Noch keine Adern dokumentiert.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cores.map((core) => (
|
||||||
|
<div
|
||||||
|
key={core.id}
|
||||||
|
className="
|
||||||
|
flex items-start gap-3 px-4 py-3
|
||||||
|
rounded-lg border border-slate-100
|
||||||
|
hover:bg-slate-50/80 transition-colors duration-100
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Color indicator dot */}
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded-full flex-shrink-0 mt-0.5 ${getColorClass(core.color)}`}
|
||||||
|
title={core.color}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Color name + optional notes */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900">{core.color}</p>
|
||||||
|
{core.notes && (
|
||||||
|
<div className="mt-1 text-slate-500">
|
||||||
|
<MarkdownContent content={core.notes} size="compact" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<DeleteCoreButton id={core.id} cableId={cableId} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/cores/DeleteCoreButton.tsx
Normal file
43
src/components/cores/DeleteCoreButton.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { deleteCore } from "@/actions/coreActions";
|
||||||
|
|
||||||
|
interface DeleteCoreButtonProps {
|
||||||
|
id: number;
|
||||||
|
cableId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteCoreButton({ id, cableId }: DeleteCoreButtonProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!confirm("Diese Ader wirklich löschen?")) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteCore(id, cableId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Ader löschen"
|
||||||
|
className="
|
||||||
|
text-slate-300 hover:text-red-500
|
||||||
|
disabled:text-slate-200 disabled:cursor-not-allowed
|
||||||
|
transition-colors duration-150 flex-shrink-0 p-0.5
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<span className="text-xs">…</span>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/components/cores/NewCoreForm.tsx
Normal file
115
src/components/cores/NewCoreForm.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useRef } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
import { createCore } from "@/actions/coreActions";
|
||||||
|
import type { FormState } from "@/actions/coreActions";
|
||||||
|
|
||||||
|
const initialState: FormState = {};
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="
|
||||||
|
inline-flex items-center gap-2
|
||||||
|
px-4 py-2 bg-blue-600 hover:bg-blue-700
|
||||||
|
disabled:bg-blue-400 disabled:cursor-not-allowed
|
||||||
|
text-white text-sm font-medium rounded-lg
|
||||||
|
transition-colors duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{pending ? "Wird gespeichert…" : "Ader hinzufügen"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewCoreFormProps {
|
||||||
|
cableId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewCoreForm({ cableId }: NewCoreFormProps) {
|
||||||
|
const [state, formAction] = useActionState(createCore, initialState);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
// Auto-reset the form fields on success so the user can add the next core quickly
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success) {
|
||||||
|
formRef.current?.reset();
|
||||||
|
// Re-focus the color input for rapid data entry
|
||||||
|
formRef.current?.querySelector<HTMLInputElement>('input[name="color"]')?.focus();
|
||||||
|
}
|
||||||
|
}, [state.success]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 mb-3">
|
||||||
|
Ader hinzufügen
|
||||||
|
</h3>
|
||||||
|
<form ref={formRef} action={formAction} className="space-y-3">
|
||||||
|
{/* Hidden cable ID */}
|
||||||
|
<input type="hidden" name="cableId" value={cableId} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{/* Color field */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="core-color"
|
||||||
|
className="block text-xs font-medium text-slate-600 mb-1"
|
||||||
|
>
|
||||||
|
Farbe <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="core-color"
|
||||||
|
name="color"
|
||||||
|
type="text"
|
||||||
|
placeholder="z. B. Blau, Rot/Weiß, Braun"
|
||||||
|
required
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
placeholder:text-slate-400
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes field */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="core-notes"
|
||||||
|
className="block text-xs font-medium text-slate-600 mb-1"
|
||||||
|
>
|
||||||
|
Notizen{" "}
|
||||||
|
<span className="text-slate-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="core-notes"
|
||||||
|
name="notes"
|
||||||
|
type="text"
|
||||||
|
placeholder="z. B. Phase L1, Neutral, Erdung"
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
placeholder:text-slate-400
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error feedback */}
|
||||||
|
{state.error && (
|
||||||
|
<p className="text-xs text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/locations/DeleteLocationButton.tsx
Normal file
46
src/components/locations/DeleteLocationButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { deleteLocation } from "@/actions/locationActions";
|
||||||
|
|
||||||
|
interface DeleteLocationButtonProps {
|
||||||
|
id: number;
|
||||||
|
/** Number of cables referencing this location — disables deletion if > 0 */
|
||||||
|
cableCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteLocationButton({
|
||||||
|
id,
|
||||||
|
cableCount,
|
||||||
|
}: DeleteLocationButtonProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const inUse = cableCount > 0;
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (inUse) return; // Safety guard (UI also disables the button)
|
||||||
|
if (!confirm("Diesen Ort wirklich löschen?")) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteLocation(id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending || inUse}
|
||||||
|
title={
|
||||||
|
inUse
|
||||||
|
? `Nicht löschbar — wird von ${cableCount} Kabel(n) verwendet`
|
||||||
|
: "Ort löschen"
|
||||||
|
}
|
||||||
|
className="
|
||||||
|
text-xs font-medium transition-colors duration-150
|
||||||
|
text-red-500 hover:text-red-700
|
||||||
|
disabled:text-slate-300 disabled:cursor-not-allowed
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{isPending ? "…" : "Löschen"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/locations/LocationsTabe.tsx
Normal file
89
src/components/locations/LocationsTabe.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { LocationWithCounts } from "@/lib/types";
|
||||||
|
import DeleteLocationButton from "./DeleteLocationButton";
|
||||||
|
|
||||||
|
interface LocationsTableProps {
|
||||||
|
locations: LocationWithCounts[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LocationsTable({ locations }: LocationsTableProps) {
|
||||||
|
if (locations.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center shadow-sm">
|
||||||
|
<p className="text-3xl mb-3">📍</p>
|
||||||
|
<p className="text-slate-500 font-medium">Noch keine Orte angelegt.</p>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
|
Legen Sie links Ihren ersten Ort an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
{/* Table header */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">
|
||||||
|
Alle Orte
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">
|
||||||
|
{locations.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Responsive table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden md:table-cell">
|
||||||
|
Beschreibung
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
|
||||||
|
Kabel
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{locations.map((location) => {
|
||||||
|
const totalCables =
|
||||||
|
location._count.startCables + location._count.endCables;
|
||||||
|
return (
|
||||||
|
<tr key={location.id} className="hover:bg-slate-50/60">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="font-medium text-slate-900">
|
||||||
|
{location.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-slate-500 hidden md:table-cell">
|
||||||
|
{location.description ? (
|
||||||
|
<span className="truncate block max-w-xs">
|
||||||
|
{location.description}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-slate-500 tabular-nums">
|
||||||
|
{totalCables}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<DeleteLocationButton
|
||||||
|
id={location.id}
|
||||||
|
cableCount={totalCables}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/locations/NewLocationForm.tsx
Normal file
106
src/components/locations/NewLocationForm.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useRef } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
import { createLocation } from "@/actions/locationActions";
|
||||||
|
import type { FormState } from "@/actions/locationActions";
|
||||||
|
|
||||||
|
const initialState: FormState = {};
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="
|
||||||
|
w-full py-2 px-4 bg-blue-600 hover:bg-blue-700
|
||||||
|
disabled:bg-blue-400 disabled:cursor-not-allowed
|
||||||
|
text-white text-sm font-medium rounded-lg
|
||||||
|
transition-colors duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{pending ? "Wird gespeichert…" : "Ort anlegen"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewLocationForm() {
|
||||||
|
const [state, formAction] = useActionState(createLocation, initialState);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
// Reset the form after a successful submission
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success) {
|
||||||
|
formRef.current?.reset();
|
||||||
|
}
|
||||||
|
}, [state.success]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-4">
|
||||||
|
Neuen Ort anlegen
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form ref={formRef} action={formAction} className="space-y-4">
|
||||||
|
{/* Name field */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="loc-name"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loc-name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="z. B. Serverraum, Wohnzimmer"
|
||||||
|
required
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
placeholder:text-slate-400
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="loc-description"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Beschreibung{" "}
|
||||||
|
<span className="text-slate-400 font-normal">(optional, Markdown)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="loc-description"
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Kurze Beschreibung des Ortes…"
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
placeholder:text-slate-400 resize-y
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error / success feedback */}
|
||||||
|
{state.error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.success && (
|
||||||
|
<p className="text-sm text-green-700 bg-green-50 border border-green-100 px-3 py-2 rounded-lg">
|
||||||
|
✓ Ort erfolgreich angelegt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/ui/MarkdownContent.tsx
Normal file
30
src/components/ui/MarkdownContent.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
interface MarkdownContentProps {
|
||||||
|
content: string;
|
||||||
|
/** Use 'compact' for small spaces like core notes, 'full' for large areas */
|
||||||
|
size?: "compact" | "full";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarkdownContent({
|
||||||
|
content,
|
||||||
|
size = "full",
|
||||||
|
}: MarkdownContentProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
prose max-w-none text-slate-700
|
||||||
|
${size === "compact" ? "prose-xs" : "prose-sm"}
|
||||||
|
prose-headings:text-slate-900
|
||||||
|
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
|
||||||
|
prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded
|
||||||
|
prose-code:text-slate-800 prose-code:font-mono prose-code:text-xs
|
||||||
|
prose-pre:bg-slate-100 prose-pre:border prose-pre:border-slate-200
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<ReactMarkdown>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/lib/prisma.ts
Normal file
17
src/lib/prisma.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||||
|
// Prevent multiple PrismaClient instances in development due to hot-reload.
|
||||||
|
// In production, a new client is created once per process.
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const adapter = new PrismaBetterSqlite3({ url: `${process.env.DATABASE_URL}` })
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
25
src/lib/types.ts
Normal file
25
src/lib/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Core, Cable, Location } from "@/generated/prisma/browser";
|
||||||
|
|
||||||
|
// Location enriched with the count of cables that reference it
|
||||||
|
export type LocationWithCounts = Location & {
|
||||||
|
_count: {
|
||||||
|
startCables: number;
|
||||||
|
endCables: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cable with its location names and core count (used in the overview table)
|
||||||
|
export type CableWithLocations = Cable & {
|
||||||
|
startLocation: Location;
|
||||||
|
endLocation: Location;
|
||||||
|
_count: {
|
||||||
|
cores: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cable with full detail: locations + all cores (used on the detail page)
|
||||||
|
export type CableWithDetails = Cable & {
|
||||||
|
startLocation: Location;
|
||||||
|
endLocation: Location;
|
||||||
|
cores: Core[];
|
||||||
|
};
|
||||||
20
tailwind.config.ts
Normal file
20
tailwind.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
import typography from "@tailwindcss/typography";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [typography],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Reference in New Issue
Block a user