Die Aufnahme kann jetzt einen einzelnen Ausschnitt statt der ganzen Seite festhalten. Ein Bild der kompletten Einstellungsseite zeigt im Kapitel zu den Benachrichtigungen zu wenig vom Eigentlichen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
468 lines
13 KiB
TypeScript
468 lines
13 KiB
TypeScript
/**
|
||
* Daten für die Screenshot-Instanz.
|
||
*
|
||
* Alles hier ist frei erfunden. Diese Datei darf niemals gegen eine
|
||
* Datenbank mit echten Daten laufen — deshalb bricht sie ab, wenn
|
||
* DEMO_MODUS nicht ausdrücklich auf true steht.
|
||
*
|
||
* Aufruf: über npm run screenshots, nicht von Hand.
|
||
*/
|
||
import 'dotenv/config'
|
||
import { PrismaPg } from '@prisma/adapter-pg'
|
||
import bcrypt from 'bcryptjs'
|
||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||
|
||
if (process.env.DEMO_MODUS !== 'true') {
|
||
console.error(
|
||
'Der Demo-Seed läuft nur mit DEMO_MODUS=true. Das ist Absicht: er würde ' +
|
||
'sonst erfundene Daten in eine echte Datenbank schreiben.',
|
||
)
|
||
process.exit(1)
|
||
}
|
||
|
||
const verbindung = process.env.DATABASE_URL
|
||
if (!verbindung) {
|
||
console.error('DATABASE_URL ist nicht gesetzt.')
|
||
process.exit(1)
|
||
}
|
||
|
||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||
|
||
export const DEMO_EMAIL = 'martina.beispiel@planer.invalid'
|
||
export const DEMO_PASSWORT = 'demo-instanz-nur-fuer-bilder'
|
||
|
||
const BEREICHE = [
|
||
{
|
||
name: 'Firma',
|
||
farbe: '#2563eb',
|
||
reihenfolge: 1,
|
||
arbeitsfensterVon: 450,
|
||
arbeitsfensterBis: 990,
|
||
kapazitaetMo: 360,
|
||
kapazitaetDi: 360,
|
||
kapazitaetMi: 360,
|
||
kapazitaetDo: 360,
|
||
kapazitaetFr: 240,
|
||
kapazitaetSa: 0,
|
||
kapazitaetSo: 0,
|
||
},
|
||
{
|
||
name: 'Gewerbe',
|
||
farbe: '#7c3aed',
|
||
reihenfolge: 2,
|
||
arbeitsfensterVon: 1020,
|
||
arbeitsfensterBis: 1260,
|
||
kapazitaetMo: 90,
|
||
kapazitaetDi: 90,
|
||
kapazitaetMi: 90,
|
||
kapazitaetDo: 90,
|
||
kapazitaetFr: 60,
|
||
kapazitaetSa: 180,
|
||
kapazitaetSo: 0,
|
||
},
|
||
{
|
||
name: 'Privat',
|
||
farbe: '#16a34a',
|
||
reihenfolge: 3,
|
||
arbeitsfensterVon: 1020,
|
||
arbeitsfensterBis: 1320,
|
||
kapazitaetMo: 60,
|
||
kapazitaetDi: 60,
|
||
kapazitaetMi: 60,
|
||
kapazitaetDo: 60,
|
||
kapazitaetFr: 120,
|
||
kapazitaetSa: 240,
|
||
kapazitaetSo: 240,
|
||
},
|
||
{
|
||
name: 'Homelab',
|
||
farbe: '#ea580c',
|
||
reihenfolge: 4,
|
||
arbeitsfensterVon: 1140,
|
||
arbeitsfensterBis: 1380,
|
||
kapazitaetMo: 0,
|
||
kapazitaetDi: 60,
|
||
kapazitaetMi: 0,
|
||
kapazitaetDo: 60,
|
||
kapazitaetFr: 0,
|
||
kapazitaetSa: 120,
|
||
kapazitaetSo: 60,
|
||
},
|
||
]
|
||
|
||
/** Erfundene Protokolleinträge, damit die Protokollseite nicht leer wirkt. */
|
||
const PROTOKOLL = [
|
||
{ aktion: 'bereich.angelegt', objektTyp: 'Bereich', details: { name: 'Homelab' } },
|
||
{
|
||
aktion: 'bereich.geaendert',
|
||
objektTyp: 'Bereich',
|
||
details: { vorher: 'Nebenerwerb', nachher: 'Gewerbe' },
|
||
},
|
||
{
|
||
aktion: 'passkey.angelegt',
|
||
objektTyp: 'Passkey',
|
||
details: { bezeichnung: 'Diensthandy' },
|
||
},
|
||
]
|
||
|
||
/**
|
||
* Erfundene Aufgaben. Die Fälligkeit steht als Versatz in Tagen zum festen
|
||
* Demo-Zeitpunkt, damit die Bilder immer dieselbe Mischung aus überfällig,
|
||
* heute und später zeigen.
|
||
*/
|
||
const AUFGABEN = [
|
||
// Überfällig
|
||
{
|
||
titel: 'Angebot Heizungstausch nachfassen',
|
||
bereich: 'Gewerbe',
|
||
versatz: -3,
|
||
prioritaet: 2,
|
||
aufwand: 30,
|
||
},
|
||
{
|
||
titel: 'Rechnung 2026-114 korrigieren',
|
||
bereich: 'Gewerbe',
|
||
versatz: -1,
|
||
prioritaet: 1,
|
||
aufwand: 20,
|
||
},
|
||
// Heute und morgen
|
||
{
|
||
titel: 'Wochenbericht an die Geschäftsführung',
|
||
bereich: 'Firma',
|
||
versatz: 0,
|
||
prioritaet: 2,
|
||
aufwand: 45,
|
||
},
|
||
{
|
||
titel: 'Rückruf Frau Sonnenberg',
|
||
bereich: 'Firma',
|
||
versatz: 0,
|
||
prioritaet: 1,
|
||
aufwand: 15,
|
||
},
|
||
{
|
||
titel: 'Zahnarzttermin bestätigen',
|
||
bereich: 'Privat',
|
||
versatz: 1,
|
||
prioritaet: 3,
|
||
aufwand: 10,
|
||
},
|
||
// Später
|
||
{
|
||
titel: 'Backup-Strategie für den Server überdenken',
|
||
bereich: 'Homelab',
|
||
versatz: 9,
|
||
prioritaet: 4,
|
||
aufwand: 120,
|
||
},
|
||
{
|
||
titel: 'Reifen wechseln lassen',
|
||
bereich: 'Privat',
|
||
versatz: 14,
|
||
prioritaet: 3,
|
||
aufwand: 60,
|
||
},
|
||
// Ohne Termin
|
||
{
|
||
titel: 'Ablage im Keller sortieren',
|
||
bereich: 'Privat',
|
||
versatz: null,
|
||
prioritaet: 5,
|
||
aufwand: 180,
|
||
},
|
||
// Erledigt
|
||
{
|
||
titel: 'Steuerunterlagen zusammenstellen',
|
||
bereich: 'Gewerbe',
|
||
versatz: -5,
|
||
prioritaet: 3,
|
||
aufwand: 90,
|
||
status: 'ERLEDIGT' as const,
|
||
},
|
||
]
|
||
|
||
/** Liegt noch in der Inbox – ohne Bereich. */
|
||
const INBOX = [
|
||
{ titel: 'Sicherungskasten beschriften' },
|
||
{ titel: 'Angebot Netzwerkschrank einholen' },
|
||
]
|
||
|
||
const IDEEN = [
|
||
{ titel: 'Newsletter für Bestandskunden' },
|
||
{ titel: 'Werkstatt-Podcast aufnehmen' },
|
||
{ titel: 'Regale im Lager neu beschriften' },
|
||
]
|
||
|
||
const PROJEKT_AUFGABEN = [
|
||
{
|
||
titel: 'Anforderungen sammeln',
|
||
typ: 'ARBEITSPAKET' as const,
|
||
status: 'ERLEDIGT' as const,
|
||
},
|
||
{
|
||
titel: 'Entwurf abstimmen',
|
||
typ: 'ARBEITSPAKET' as const,
|
||
status: 'ERLEDIGT' as const,
|
||
},
|
||
{ titel: 'Texte schreiben', typ: 'ARBEITSPAKET' as const, status: 'OFFEN' as const },
|
||
{ titel: 'Fotos beauftragen', typ: 'ARBEITSPAKET' as const, status: 'WARTET' as const },
|
||
{ titel: 'Seite ist online', typ: 'MEILENSTEIN' as const, status: 'OFFEN' as const },
|
||
]
|
||
|
||
/** Fester Bezugspunkt, passend zu src/server/zeitgeber.ts. */
|
||
const DEMO_JETZT = new Date('2026-03-17T08:20:00.000Z')
|
||
|
||
function tageVersetzt(tage: number): Date {
|
||
return new Date(DEMO_JETZT.getTime() + tage * 24 * 60 * 60 * 1000)
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
console.log('Demo-Daten einspielen.')
|
||
|
||
for (const bereich of BEREICHE) {
|
||
await db.bereich.upsert({
|
||
where: { name: bereich.name },
|
||
update: bereich,
|
||
create: bereich,
|
||
})
|
||
}
|
||
|
||
const benutzer = await db.benutzer.upsert({
|
||
where: { email: DEMO_EMAIL },
|
||
update: { rolle: 'ADMIN', name: 'Martina Beispiel' },
|
||
create: {
|
||
email: DEMO_EMAIL,
|
||
name: 'Martina Beispiel',
|
||
rolle: 'ADMIN',
|
||
passwortHash: await bcrypt.hash(DEMO_PASSWORT, 10),
|
||
},
|
||
})
|
||
|
||
await db.auditEintrag.deleteMany({})
|
||
// Feste Zeitpunkte, damit die Bilder zwischen zwei Läufen gleich bleiben.
|
||
let versatz = 0
|
||
for (const eintrag of PROTOKOLL) {
|
||
versatz += 37
|
||
await db.auditEintrag.create({
|
||
data: {
|
||
zeitpunkt: new Date(Date.UTC(2026, 2, 17, 9, versatz % 60)),
|
||
benutzerId: benutzer.id,
|
||
benutzerEmail: benutzer.email,
|
||
aktion: eintrag.aktion,
|
||
objektTyp: eintrag.objektTyp,
|
||
details: eintrag.details,
|
||
instanz: 'daheim',
|
||
},
|
||
})
|
||
}
|
||
|
||
await db.passkey.deleteMany({ where: { benutzerId: benutzer.id } })
|
||
await db.passkey.create({
|
||
data: {
|
||
benutzerId: benutzer.id,
|
||
credentialId: 'demo-passkey-diensthandy',
|
||
publicKey: Buffer.from([0]),
|
||
bezeichnung: 'Diensthandy',
|
||
gesichert: true,
|
||
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 15)),
|
||
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 7, 42)),
|
||
},
|
||
})
|
||
|
||
// --- Aufgaben, Ideen und ein Projekt -------------------------------------
|
||
await db.aufgabe.deleteMany({})
|
||
await db.projekt.deleteMany({})
|
||
|
||
const bereichsKennung = new Map(
|
||
(await db.bereich.findMany({ select: { id: true, name: true } })).map((b) => [
|
||
b.name,
|
||
b.id,
|
||
]),
|
||
)
|
||
|
||
for (const eintrag of AUFGABEN) {
|
||
await db.aufgabe.create({
|
||
data: {
|
||
titel: eintrag.titel,
|
||
bereichId: bereichsKennung.get(eintrag.bereich) ?? null,
|
||
prioritaet: eintrag.prioritaet,
|
||
aufwandMinuten: eintrag.aufwand,
|
||
faelligAm: eintrag.versatz === null ? null : tageVersetzt(eintrag.versatz),
|
||
status: eintrag.status ?? 'OFFEN',
|
||
erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-4) : null,
|
||
},
|
||
})
|
||
}
|
||
|
||
for (const eintrag of INBOX) {
|
||
await db.aufgabe.create({ data: { titel: eintrag.titel } })
|
||
}
|
||
|
||
for (const eintrag of IDEEN) {
|
||
await db.aufgabe.create({ data: { titel: eintrag.titel, typ: 'IDEE' } })
|
||
}
|
||
|
||
const projekt = await db.projekt.create({
|
||
data: {
|
||
name: 'Website neu aufsetzen',
|
||
beschreibung:
|
||
'Der alte Auftritt ist von 2019 und auf dem Handy kaum lesbar. Fertig ist es, wenn die neue Seite online ist und das Kontaktformular ankommt.',
|
||
bereichId: bereichsKennung.get('Gewerbe') ?? null,
|
||
zielAm: tageVersetzt(45),
|
||
},
|
||
})
|
||
|
||
for (const eintrag of PROJEKT_AUFGABEN) {
|
||
await db.aufgabe.create({
|
||
data: {
|
||
titel: eintrag.titel,
|
||
typ: eintrag.typ,
|
||
status: eintrag.status,
|
||
projektId: projekt.id,
|
||
bereichId: bereichsKennung.get('Gewerbe') ?? null,
|
||
aufwandMinuten: eintrag.typ === 'MEILENSTEIN' ? null : 120,
|
||
erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-7) : null,
|
||
},
|
||
})
|
||
}
|
||
|
||
// --- Kalenderkonto und erfundene Termine ---------------------------------
|
||
// Die Tokens sind absichtlich Unsinn: die Screenshot-Instanz spricht mit
|
||
// keinem Anbieter, sie soll die Seite nur nicht leer zeigen.
|
||
await db.kalenderkonto.deleteMany({})
|
||
const kalenderkonto = await db.kalenderkonto.create({
|
||
data: {
|
||
benutzerId: benutzer.id,
|
||
anbieter: 'GRAPH',
|
||
anzeigename: 'Firma Outlook',
|
||
extKontoId: 'martina.beispiel@firma.invalid',
|
||
letzterAbgleich: new Date(DEMO_JETZT.getTime() - 6 * 60_000),
|
||
},
|
||
})
|
||
|
||
const TERMINE = [
|
||
{ betreff: 'Wochenbesprechung', vonStunde: 8, bisStunde: 9 },
|
||
{ betreff: 'Kundengespräch Sonnenberg', vonStunde: 10, bisStunde: 11 },
|
||
{ betreff: 'Mittagspause', vonStunde: 11, bisStunde: 12, frei: true },
|
||
{ betreff: 'Abnahme Baustelle', vonStunde: 13, bisStunde: 15 },
|
||
]
|
||
|
||
for (const eintrag of TERMINE) {
|
||
await db.termin.create({
|
||
data: {
|
||
kalenderkontoId: kalenderkonto.id,
|
||
extId: `demo-${eintrag.betreff.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
||
betreff: eintrag.betreff,
|
||
beginn: new Date(Date.UTC(2026, 2, 17, eintrag.vonStunde - 1, 0)),
|
||
ende: new Date(Date.UTC(2026, 2, 17, eintrag.bisStunde - 1, 0)),
|
||
zeigtAls: eintrag.frei ? 'FREI' : 'GEBUCHT',
|
||
},
|
||
})
|
||
}
|
||
|
||
await db.termin.create({
|
||
data: {
|
||
kalenderkontoId: kalenderkonto.id,
|
||
extId: 'demo-brueckentag',
|
||
betreff: 'Brückentag',
|
||
beginn: new Date(Date.UTC(2026, 2, 20, 0, 0)),
|
||
ende: new Date(Date.UTC(2026, 2, 21, 0, 0)),
|
||
ganztaegig: true,
|
||
zeigtAls: 'ABWESEND',
|
||
},
|
||
})
|
||
|
||
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
|
||
await db.zeitblock.deleteMany({})
|
||
const einzuplanen = await db.aufgabe.findMany({
|
||
where: {
|
||
titel: {
|
||
in: [
|
||
'Wochenbericht an die Geschäftsführung',
|
||
'Angebot Heizungstausch nachfassen',
|
||
],
|
||
},
|
||
},
|
||
select: { id: true, titel: true, aufwandMinuten: true },
|
||
})
|
||
|
||
let blockStunde = 15
|
||
for (const aufgabe of einzuplanen) {
|
||
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
|
||
await db.zeitblock.create({
|
||
data: {
|
||
aufgabeId: aufgabe.id,
|
||
kalenderkontoId: kalenderkonto.id,
|
||
beginn,
|
||
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
|
||
extId: `demo-block-${aufgabe.id}`,
|
||
hash: 'demo',
|
||
},
|
||
})
|
||
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||
blockStunde += 2
|
||
}
|
||
|
||
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
|
||
await db.aufgabe.create({
|
||
data: {
|
||
titel: 'Störung Klimagerät Serverraum prüfen',
|
||
bereichId: bereichsKennung.get('Firma') ?? null,
|
||
prioritaet: 2,
|
||
aufwandMinuten: 60,
|
||
faelligAm: tageVersetzt(2),
|
||
quelle: 'ZAMMAD',
|
||
quelleRef: 'demo-38421',
|
||
quelleUrl: 'https://zammad.example.invalid/ticket/38421',
|
||
},
|
||
})
|
||
|
||
// --- Benachrichtigungen ---------------------------------------------------
|
||
// Für die Bilder eingeschaltet. Eine leere Karte zeigt nichts von dem,
|
||
// was das Kapitel erklärt.
|
||
await db.benachrichtigungseinstellung.upsert({
|
||
where: { benutzerId: benutzer.id },
|
||
update: {},
|
||
create: {
|
||
benutzerId: benutzer.id,
|
||
anlaesse: 'tagesueberblick,faelligkeit,zeitblock',
|
||
perPush: true,
|
||
perMail: true,
|
||
ueberblickUm: 7 * 60,
|
||
ruheVon: 20 * 60,
|
||
ruheBis: 7 * 60,
|
||
ruheAmWochenende: false,
|
||
zeitblockVorlaufMinuten: 10,
|
||
},
|
||
})
|
||
|
||
await db.pushGeraet.deleteMany({ where: { benutzerId: benutzer.id } })
|
||
await db.pushGeraet.create({
|
||
data: {
|
||
benutzerId: benutzer.id,
|
||
// Erfunden und unerreichbar. Die Schlüsselfelder halten sonst
|
||
// verschlüsselte Werte; hier stehen Platzhalter, weil aus der
|
||
// Demo-Instanz ohnehin nichts hinausgeht.
|
||
endpunkt: 'https://push.invalid/demo-arbeitsplatz',
|
||
p256dh: 'demo',
|
||
auth: 'demo',
|
||
bezeichnung: 'Firefox auf Windows',
|
||
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 20)),
|
||
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 6, 5)),
|
||
},
|
||
})
|
||
|
||
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
||
}
|
||
|
||
main()
|
||
.then(async () => {
|
||
await db.$disconnect()
|
||
})
|
||
.catch(async (fehler: unknown) => {
|
||
console.error(fehler)
|
||
await db.$disconnect()
|
||
process.exit(1)
|
||
})
|