-
Materialliste
+
{info.liste}
@@ -466,7 +467,7 @@ export default function ProjektDetail() {
)}
- Materialschein erstellt über die BergVOLT Materialschein-App. Bei Rückfragen wende dich bitte
+ {info.name} erstellt über die BergVOLT Materialschein-App. Bei Rückfragen wende dich bitte
an den zuständigen Monteur.
diff --git a/client/src/styles.css b/client/src/styles.css
index f507c2c..a1da24c 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -335,3 +335,15 @@ table.pinfo td:last-child{text-align:right;font-weight:600}
.mat-table td.td-notiz{grid-area:notiz;flex-direction:column;align-items:stretch;gap:6px;border-bottom:none}
.mat-table td.td-notiz::before{content:'Notiz'}
}
+
+/* Favoriten-Stern, Kabelsuche */
+.fav-star{background:transparent;border:0;cursor:pointer;font-size:22px;line-height:1;padding:4px 6px;color:var(--muted);flex:0 0 auto}
+.fav-star:hover{color:#f5b301}
+.fav-star.on{color:#f5b301}
+.kab-select{display:flex;flex-direction:column;gap:6px;min-width:0}
+.kab-search-row{display:flex;gap:4px;align-items:center}
+.kab-search{flex:1;min-width:0;height:38px;border:1.5px solid var(--bor);border-radius:9px;padding:0 12px;font-size:13px;background:var(--bg2);color:var(--text)}
+.kab-search:focus{outline:none;border-color:var(--cyan)}
+.kab-search-info{font-size:11px;color:var(--muted)}
+.reftab td .fav-star{font-size:18px;padding:0 4px}
+@media print{.fav-star,.kab-select .kab-search-row{display:none!important}}
diff --git a/server/prisma/migrations/20261009180000_add_bestellschein_favoriten/migration.sql b/server/prisma/migrations/20261009180000_add_bestellschein_favoriten/migration.sql
new file mode 100644
index 0000000..600a018
--- /dev/null
+++ b/server/prisma/migrations/20261009180000_add_bestellschein_favoriten/migration.sql
@@ -0,0 +1,25 @@
+-- CreateEnum
+CREATE TYPE "ScheinTyp" AS ENUM ('Material', 'Bestell');
+
+-- AlterTable
+ALTER TABLE "projekte" ADD COLUMN "typ" "ScheinTyp" NOT NULL DEFAULT 'Material';
+
+-- CreateTable
+CREATE TABLE "favoriten" (
+ "id" TEXT NOT NULL,
+ "userKey" TEXT NOT NULL,
+ "art" TEXT NOT NULL,
+ "refId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "favoriten_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "projekte_typ_idx" ON "projekte"("typ");
+
+-- CreateIndex
+CREATE INDEX "favoriten_userKey_idx" ON "favoriten"("userKey");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "favoriten_userKey_art_refId_key" ON "favoriten"("userKey", "art", "refId");
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 58497f7..e102888 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -13,6 +13,12 @@ enum ProjektStatus {
Abgeschlossen
}
+// Materialschein und Bestellschein teilen sich dieselbe Struktur (Kopfdaten + Positionen).
+enum ScheinTyp {
+ Material
+ Bestell
+}
+
model Projekt {
id String @id @default(cuid())
titel String
@@ -22,10 +28,12 @@ model Projekt {
monteur String?
notiz String?
status ProjektStatus @default(Aktiv)
+ typ ScheinTyp @default(Material)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
material Material[]
+ @@index([typ])
@@map("projekte")
}
@@ -56,3 +64,17 @@ model User {
@@map("users")
}
+
+// Favoriten je Benutzer (Microsoft-Konto per oid, lokales Konto per Benutzername).
+// art = SCHEIN (refId = Projekt-ID) oder KABEL (refId = Kabelname aus der Kabelliste).
+model Favorit {
+ id String @id @default(cuid())
+ userKey String
+ art String
+ refId String
+ createdAt DateTime @default(now())
+
+ @@unique([userKey, art, refId])
+ @@index([userKey])
+ @@map("favoriten")
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index cdd55fa..421fa73 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -7,6 +7,7 @@ import { requireAuth } from './auth.js';
import { authRouter } from './routes/auth.js';
import { projekteRouter } from './routes/projekte.js';
import { materialRouter } from './routes/material.js';
+import { favoritenRouter } from './routes/favoriten.js';
import { prisma } from './prisma.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -22,6 +23,7 @@ app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.use('/api/auth', authRouter);
app.use('/api/projekte', requireAuth, projekteRouter);
app.use('/api/material', requireAuth, materialRouter);
+app.use('/api/favoriten', requireAuth, favoritenRouter);
// Statisch gebautes React/Vite-Frontend ausliefern.
app.use(express.static(PUBLIC_DIR));
diff --git a/server/src/routes/favoriten.ts b/server/src/routes/favoriten.ts
new file mode 100644
index 0000000..d16fbd4
--- /dev/null
+++ b/server/src/routes/favoriten.ts
@@ -0,0 +1,46 @@
+import { Router } from 'express';
+import { prisma } from '../prisma.js';
+import type { AuthedRequest } from '../auth.js';
+
+export const favoritenRouter = Router();
+
+const ARTEN = ['SCHEIN', 'KABEL'];
+
+// Favoriten gehören dem jeweiligen Benutzer: Microsoft-Konto über die oid, lokales
+// Konto (keine oid) über den Benutzernamen.
+function userKey(req: AuthedRequest): string | null {
+ const u = req.user;
+ if (!u) return null;
+ if (u.oid) return `ms:${u.oid}`;
+ if (u.email) return `local:${u.email.toLowerCase()}`;
+ return null;
+}
+
+// GET /api/favoriten → [{ art, refId }]
+favoritenRouter.get('/', async (req: AuthedRequest, res) => {
+ const key = userKey(req);
+ if (!key) { res.status(401).json({ error: 'Benutzer unbekannt.' }); return; }
+ const items = await prisma.favorit.findMany({ where: { userKey: key }, select: { art: true, refId: true } });
+ res.json(items);
+});
+
+// PUT /api/favoriten { art, refId, favorit: boolean }
+favoritenRouter.put('/', async (req: AuthedRequest, res) => {
+ const key = userKey(req);
+ if (!key) { res.status(401).json({ error: 'Benutzer unbekannt.' }); return; }
+ const { art, refId, favorit } = req.body ?? {};
+ if (!ARTEN.includes(art) || typeof refId !== 'string' || !refId || refId.length > 300 || typeof favorit !== 'boolean') {
+ res.status(400).json({ error: 'art, refId und favorit sind erforderlich.' });
+ return;
+ }
+ if (favorit) {
+ await prisma.favorit.upsert({
+ where: { userKey_art_refId: { userKey: key, art, refId } },
+ update: {},
+ create: { userKey: key, art, refId }
+ });
+ } else {
+ await prisma.favorit.deleteMany({ where: { userKey: key, art, refId } });
+ }
+ res.status(204).end();
+});
diff --git a/server/src/routes/projekte.ts b/server/src/routes/projekte.ts
index ee74bd4..e6b283e 100644
--- a/server/src/routes/projekte.ts
+++ b/server/src/routes/projekte.ts
@@ -4,13 +4,23 @@ import type { AuthedRequest } from '../auth.js';
export const projekteRouter = Router();
-projekteRouter.get('/', async (_req, res) => {
- const projekte = await prisma.projekt.findMany({ orderBy: { createdAt: 'desc' } });
+const SCHEIN_TYPEN = ['Material', 'Bestell'] as const;
+type ScheinTypName = (typeof SCHEIN_TYPEN)[number];
+const parseTyp = (v: unknown): ScheinTypName | undefined =>
+ SCHEIN_TYPEN.find(t => t === v);
+
+// GET /api/projekte?typ=Material|Bestell (ohne typ: alle)
+projekteRouter.get('/', async (req, res) => {
+ const typ = parseTyp(req.query.typ);
+ const projekte = await prisma.projekt.findMany({
+ where: typ ? { typ } : undefined,
+ orderBy: { createdAt: 'desc' }
+ });
res.json(projekte);
});
projekteRouter.post('/', async (req: AuthedRequest, res) => {
- const { titel, auftraggeber, auftragsNr, datum, monteur, notiz } = req.body ?? {};
+ const { titel, auftraggeber, auftragsNr, datum, monteur, notiz, typ } = req.body ?? {};
if (!titel || typeof titel !== 'string' || !titel.trim()) {
res.status(400).json({ error: 'Titel (Projekt/Baustelle) ist erforderlich.' });
return;
@@ -22,7 +32,8 @@ projekteRouter.post('/', async (req: AuthedRequest, res) => {
auftragsNr: auftragsNr || null,
datum: datum ? new Date(datum) : null,
monteur: monteur || null,
- notiz: notiz || null
+ notiz: notiz || null,
+ typ: parseTyp(typ) ?? 'Material'
}
});
res.status(201).json(projekt);
@@ -61,5 +72,6 @@ projekteRouter.delete('/:id', async (req, res) => {
return;
}
await prisma.projekt.delete({ where: { id } });
+ await prisma.favorit.deleteMany({ where: { art: 'SCHEIN', refId: id } });
res.status(204).end();
});