From 351f9ef34c54b22dae3589cbbbe8f78a0cfc247f Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 2 Sep 2026 19:57:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Abf=C3=BCllungs-Archiv=20und=20Social-M?= =?UTF-8?q?edia-Links=20auf=20der=20Herkunftsseite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abfüllungen hatten keine Übersichtsseite - die Navigation ging direkt auf /bottling/new, sodass eine angelegte Abfüllung danach nirgends mehr sichtbar war. Neue Seite /bottling listet alle Abfüllungen (Archiv), Klick führt zur bestehenden Detail-/Bearbeiten-Seite. Navigationslink zeigt jetzt auf die Liste statt direkt auf "Neu". Neues Modul site-settings (Singleton-Tabelle SiteSettings) für YouTube-/Instagram-/TikTok-Links, verwaltbar unter Verwaltung > Website (neue Berechtigung site-settings:read/update). Die öffentliche Herkunftsseite (/herkunft/[lot]) zeigt die konfigurierten Links jetzt zusätzlich zur Chargen-Herkunft an - PublicService liefert sie über das bereits oeffentliche /public/batches/:lotNumber mit aus, keine zusaetzliche unauthentifizierte Route noetig. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 10 ++ ApiCore-Backend/prisma/schema.prisma | 15 ++ ApiCore-Backend/src/app.module.ts | 2 + .../auth/permissions/permission-catalog.ts | 9 ++ ApiCore-Backend/src/public/public.module.ts | 2 + .../src/public/public.service.spec.ts | 42 +++++- ApiCore-Backend/src/public/public.service.ts | 20 ++- .../dto/update-site-settings.dto.ts | 28 ++++ .../site-settings/site-settings.controller.ts | 29 ++++ .../src/site-settings/site-settings.module.ts | 10 ++ .../site-settings.service.spec.ts | 134 ++++++++++++++++++ .../site-settings/site-settings.service.ts | 63 ++++++++ .../src/app/(app)/bottling/page.tsx | 72 ++++++++++ .../src/app/(app)/settings/website/page.tsx | 113 +++++++++++++++ ApiCore-Frontend/src/app/(app)/sidebar.tsx | 9 +- .../src/app/herkunft/[lot]/page.tsx | 25 +++- .../src/lib/public-batch/types.ts | 7 + ApiCore-Frontend/src/lib/site-settings/api.ts | 13 ++ .../src/lib/site-settings/types.ts | 13 ++ 19 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20260902160000_add_site_settings/migration.sql create mode 100644 ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts create mode 100644 ApiCore-Backend/src/site-settings/site-settings.controller.ts create mode 100644 ApiCore-Backend/src/site-settings/site-settings.module.ts create mode 100644 ApiCore-Backend/src/site-settings/site-settings.service.spec.ts create mode 100644 ApiCore-Backend/src/site-settings/site-settings.service.ts create mode 100644 ApiCore-Frontend/src/app/(app)/bottling/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/website/page.tsx create mode 100644 ApiCore-Frontend/src/lib/site-settings/api.ts create mode 100644 ApiCore-Frontend/src/lib/site-settings/types.ts diff --git a/ApiCore-Backend/prisma/migrations/20260902160000_add_site_settings/migration.sql b/ApiCore-Backend/prisma/migrations/20260902160000_add_site_settings/migration.sql new file mode 100644 index 0000000..c44076c --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260902160000_add_site_settings/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "SiteSettings" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "youtubeUrl" TEXT, + "instagramUrl" TEXT, + "tiktokUrl" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id") +); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 6ef57f2..29fad7a 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -1025,3 +1025,18 @@ model AutomationRule { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +// --------------------------------------------------------------------------- +// Website-Einstellungen – Social-Media-Links auf der öffentlichen Herkunftsseite +// --------------------------------------------------------------------------- + +// Singleton row (fixed id) rather than a key/value table: exactly one set of +// site-wide social links exists, and a fixed-id upsert is simpler than +// enforcing "at most one row" any other way. +model SiteSettings { + id String @id @default("singleton") + youtubeUrl String? + instagramUrl String? + tiktokUrl String? + updatedAt DateTime @updatedAt +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 50c2e02..5064b56 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -40,6 +40,7 @@ import { TaskTemplatesModule } from './task-templates/task-templates.module'; import { SwarmControlModule } from './swarm-control/swarm-control.module'; import { WeatherModule } from './weather/weather.module'; import { ProfileModule } from './profile/profile.module'; +import { SiteSettingsModule } from './site-settings/site-settings.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; @@ -87,6 +88,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module'; SwarmControlModule, WeatherModule, ProfileModule, + SiteSettingsModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 33a858e..311177c 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -276,6 +276,15 @@ export const PERMISSION_CATALOG = [ ...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800), ...group('Suche', 'search', [['read', 'Ansehen']], 2900), ...group('QR-Codes', 'qr', [['read', 'Ansehen']], 3000), + ...group( + 'Website-Einstellungen', + 'site-settings', + [ + ['read', 'Ansehen'], + ['update', 'Bearbeiten'], + ], + 3100, + ), ] as const satisfies readonly PermissionCatalogEntry[]; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; diff --git a/ApiCore-Backend/src/public/public.module.ts b/ApiCore-Backend/src/public/public.module.ts index e0fbe03..5a84f02 100644 --- a/ApiCore-Backend/src/public/public.module.ts +++ b/ApiCore-Backend/src/public/public.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { SiteSettingsModule } from '../site-settings/site-settings.module'; import { PublicController } from './public.controller'; import { PublicService } from './public.service'; @Module({ + imports: [SiteSettingsModule], controllers: [PublicController], providers: [PublicService], }) diff --git a/ApiCore-Backend/src/public/public.service.spec.ts b/ApiCore-Backend/src/public/public.service.spec.ts index 36a015b..3c9561d 100644 --- a/ApiCore-Backend/src/public/public.service.spec.ts +++ b/ApiCore-Backend/src/public/public.service.spec.ts @@ -1,14 +1,26 @@ import { NotFoundException } from '@nestjs/common'; import { PublicService } from './public.service'; import { PrismaService } from '../prisma/prisma.service'; +import { SiteSettingsService } from '../site-settings/site-settings.service'; describe('PublicService', () => { let service: PublicService; let prisma: { bottlingBatch: { findUnique: jest.Mock } }; + let siteSettings: { get: jest.Mock }; beforeEach(() => { prisma = { bottlingBatch: { findUnique: jest.fn() } }; - service = new PublicService(prisma as unknown as PrismaService); + siteSettings = { + get: jest.fn().mockResolvedValue({ + youtubeUrl: null, + instagramUrl: null, + tiktokUrl: null, + }), + }; + service = new PublicService( + prisma as unknown as PrismaService, + siteSettings as unknown as SiteSettingsService, + ); }); it('throws NotFoundException when the lot does not exist', async () => { @@ -66,4 +78,32 @@ describe('PublicService', () => { expect(result.apiaries).toEqual([{ name: 'Stand Wald', location: 'Wald' }]); expect(result.harvestDates).toEqual([harvestDate1, harvestDate2]); }); + + it('includes the configured social links', async () => { + siteSettings.get.mockResolvedValue({ + youtubeUrl: 'https://youtube.com/@wabenhain', + instagramUrl: 'https://instagram.com/wabenhain', + tiktokUrl: null, + }); + prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ + lotNumber: 'WH-2026-001', + date: new Date('2026-08-20'), + bestBeforeDate: new Date('2028-08-20'), + jarSize: 500, + product: null, + honeyBatch: { + honeyType: null, + waterContent: null, + extraction: { date: null, harvests: [] }, + }, + }); + + const result = await service.findBatchByLot('WH-2026-001'); + + expect(result.socialLinks).toEqual({ + youtube: 'https://youtube.com/@wabenhain', + instagram: 'https://instagram.com/wabenhain', + tiktok: null, + }); + }); }); diff --git a/ApiCore-Backend/src/public/public.service.ts b/ApiCore-Backend/src/public/public.service.ts index a66bca8..9517d43 100644 --- a/ApiCore-Backend/src/public/public.service.ts +++ b/ApiCore-Backend/src/public/public.service.ts @@ -1,11 +1,18 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { SiteSettingsService } from '../site-settings/site-settings.service'; export interface PublicApiaryOrigin { name: string; location: string | null; } +export interface PublicSocialLinks { + youtube: string | null; + instagram: string | null; + tiktok: string | null; +} + export interface PublicBatch { lotNumber: string; productName: string | null; @@ -18,11 +25,15 @@ export interface PublicBatch { extractedDate: Date | null; harvestDates: Date[]; apiaries: PublicApiaryOrigin[]; + socialLinks: PublicSocialLinks; } @Injectable() export class PublicService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly siteSettings: SiteSettingsService, + ) {} async findBatchByLot(lotNumber: string): Promise { const batch = await this.prisma.bottlingBatch.findUnique({ @@ -60,6 +71,8 @@ export class PublicService { .sort((a, b) => a - b) .map((time) => new Date(time)); + const settings = await this.siteSettings.get(); + return { lotNumber: batch.lotNumber, productName: batch.product?.name ?? null, @@ -72,6 +85,11 @@ export class PublicService { extractedDate: batch.honeyBatch.extraction.date, harvestDates, apiaries: [...apiaryByName.values()], + socialLinks: { + youtube: settings.youtubeUrl, + instagram: settings.instagramUrl, + tiktok: settings.tiktokUrl, + }, }; } } diff --git a/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts b/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts new file mode 100644 index 0000000..1ade01b --- /dev/null +++ b/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts @@ -0,0 +1,28 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUrl, MaxLength, ValidateIf } from 'class-validator'; + +// An empty string clears the link (stored as null); anything else must be a +// real URL - ValidateIf skips the @IsUrl check only for the empty-string +// "clear" case so a genuinely malformed value still fails validation. +export class UpdateSiteSettingsDto { + @ApiPropertyOptional({ description: 'Leerer String löscht den Link' }) + @IsOptional() + @ValidateIf((_, value: string) => value !== '') + @IsUrl({ require_protocol: true }) + @MaxLength(300) + youtubeUrl?: string; + + @ApiPropertyOptional({ description: 'Leerer String löscht den Link' }) + @IsOptional() + @ValidateIf((_, value: string) => value !== '') + @IsUrl({ require_protocol: true }) + @MaxLength(300) + instagramUrl?: string; + + @ApiPropertyOptional({ description: 'Leerer String löscht den Link' }) + @IsOptional() + @ValidateIf((_, value: string) => value !== '') + @IsUrl({ require_protocol: true }) + @MaxLength(300) + tiktokUrl?: string; +} diff --git a/ApiCore-Backend/src/site-settings/site-settings.controller.ts b/ApiCore-Backend/src/site-settings/site-settings.controller.ts new file mode 100644 index 0000000..c948212 --- /dev/null +++ b/ApiCore-Backend/src/site-settings/site-settings.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Get, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { UpdateSiteSettingsDto } from './dto/update-site-settings.dto'; +import { SiteSettingsService } from './site-settings.service'; + +@ApiTags('site-settings') +@ApiBearerAuth() +@Controller('site-settings') +export class SiteSettingsController { + constructor(private readonly siteSettingsService: SiteSettingsService) {} + + @RequirePermission('site-settings:read') + @Get() + get() { + return this.siteSettingsService.get(); + } + + @RequirePermission('site-settings:update') + @Patch() + update( + @Body() dto: UpdateSiteSettingsDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.siteSettingsService.update(dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/site-settings/site-settings.module.ts b/ApiCore-Backend/src/site-settings/site-settings.module.ts new file mode 100644 index 0000000..67a7ea0 --- /dev/null +++ b/ApiCore-Backend/src/site-settings/site-settings.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SiteSettingsController } from './site-settings.controller'; +import { SiteSettingsService } from './site-settings.service'; + +@Module({ + controllers: [SiteSettingsController], + providers: [SiteSettingsService], + exports: [SiteSettingsService], +}) +export class SiteSettingsModule {} diff --git a/ApiCore-Backend/src/site-settings/site-settings.service.spec.ts b/ApiCore-Backend/src/site-settings/site-settings.service.spec.ts new file mode 100644 index 0000000..46f3943 --- /dev/null +++ b/ApiCore-Backend/src/site-settings/site-settings.service.spec.ts @@ -0,0 +1,134 @@ +import { SiteSettingsService } from './site-settings.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('SiteSettingsService', () => { + let service: SiteSettingsService; + let prisma: { + siteSettings: { + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + const existing = { + id: 'singleton', + youtubeUrl: 'https://youtube.com/@wabenhain', + instagramUrl: null as string | null, + tiktokUrl: null as string | null, + updatedAt: new Date('2026-01-01'), + }; + + beforeEach(() => { + prisma = { + siteSettings: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new SiteSettingsService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + describe('get', () => { + it('returns the existing singleton row', async () => { + prisma.siteSettings.findUnique.mockResolvedValue(existing); + + const result = await service.get(); + + expect(result).toEqual(existing); + expect(prisma.siteSettings.create).not.toHaveBeenCalled(); + }); + + it('creates the singleton row on first access', async () => { + prisma.siteSettings.findUnique.mockResolvedValue(null); + prisma.siteSettings.create.mockResolvedValue({ + id: 'singleton', + youtubeUrl: null, + instagramUrl: null, + tiktokUrl: null, + updatedAt: new Date(), + }); + + const result = await service.get(); + + expect(prisma.siteSettings.create).toHaveBeenCalledWith({ + data: { id: 'singleton' }, + }); + expect(result.id).toBe('singleton'); + }); + }); + + describe('update', () => { + it('stores a valid link and records an audit entry', async () => { + prisma.siteSettings.findUnique.mockResolvedValue(existing); + prisma.siteSettings.update.mockResolvedValue({ + ...existing, + instagramUrl: 'https://instagram.com/wabenhain', + }); + + const result = await service.update( + { instagramUrl: 'https://instagram.com/wabenhain' }, + 'user-1', + ); + + expect(prisma.siteSettings.update).toHaveBeenCalledWith({ + where: { id: 'singleton' }, + data: { + youtubeUrl: undefined, + instagramUrl: 'https://instagram.com/wabenhain', + tiktokUrl: undefined, + }, + }); + expect(result.instagramUrl).toBe('https://instagram.com/wabenhain'); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'SiteSettings', + action: 'UPDATE', + userId: 'user-1', + }), + ); + }); + + it('clears a link when given an empty string', async () => { + prisma.siteSettings.findUnique.mockResolvedValue(existing); + prisma.siteSettings.update.mockResolvedValue({ + ...existing, + youtubeUrl: null, + }); + + await service.update({ youtubeUrl: '' }, 'user-1'); + + expect(prisma.siteSettings.update).toHaveBeenCalledWith({ + where: { id: 'singleton' }, + data: { + youtubeUrl: null, + instagramUrl: undefined, + tiktokUrl: undefined, + }, + }); + }); + + it('leaves fields untouched when omitted from the dto', async () => { + prisma.siteSettings.findUnique.mockResolvedValue(existing); + prisma.siteSettings.update.mockResolvedValue(existing); + + await service.update({}, 'user-1'); + + expect(prisma.siteSettings.update).toHaveBeenCalledWith({ + where: { id: 'singleton' }, + data: { + youtubeUrl: undefined, + instagramUrl: undefined, + tiktokUrl: undefined, + }, + }); + }); + }); +}); diff --git a/ApiCore-Backend/src/site-settings/site-settings.service.ts b/ApiCore-Backend/src/site-settings/site-settings.service.ts new file mode 100644 index 0000000..03de951 --- /dev/null +++ b/ApiCore-Backend/src/site-settings/site-settings.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { SiteSettings } from '../../generated/prisma'; +import { UpdateSiteSettingsDto } from './dto/update-site-settings.dto'; + +const SINGLETON_ID = 'singleton'; + +function emptyToNull(value: string | undefined): string | null { + return value === '' ? null : (value ?? null); +} + +@Injectable() +export class SiteSettingsService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + // Find-or-create rather than a migration-time seed row: keeps the + // singleton pattern self-contained in this service instead of coupling it + // to seed.ts/migration ordering. + async get(): Promise { + const existing = await this.prisma.siteSettings.findUnique({ + where: { id: SINGLETON_ID }, + }); + if (existing) { + return existing; + } + return this.prisma.siteSettings.create({ data: { id: SINGLETON_ID } }); + } + + async update( + dto: UpdateSiteSettingsDto, + actingUserId: string, + ): Promise { + const before = await this.get(); + const settings = await this.prisma.siteSettings.update({ + where: { id: SINGLETON_ID }, + data: { + youtubeUrl: + dto.youtubeUrl !== undefined + ? emptyToNull(dto.youtubeUrl) + : undefined, + instagramUrl: + dto.instagramUrl !== undefined + ? emptyToNull(dto.instagramUrl) + : undefined, + tiktokUrl: + dto.tiktokUrl !== undefined ? emptyToNull(dto.tiktokUrl) : undefined, + }, + }); + await this.auditLog.record({ + entityType: 'SiteSettings', + entityId: SINGLETON_ID, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: settings, + }); + return settings; + } +} diff --git a/ApiCore-Frontend/src/app/(app)/bottling/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/page.tsx new file mode 100644 index 0000000..11e9285 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/bottling/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { Package } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchBottlingBatches } from "@/lib/bottling/api"; +import { Button } from "@/components/ui/button"; +import { PageHeader } from "@/components/page-header"; +import { EmptyState } from "@/components/empty-state"; +import { EntityCard } from "@/components/entity-card"; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("de-DE"); +} + +export default function BottlingBatchesPage() { + const { data: bottlingBatches, isLoading, isError } = useQuery({ + queryKey: ["bottling-batches"], + queryFn: () => fetchBottlingBatches(), + }); + + return ( +
+ }> + + Neue Abfüllung + + } + /> + + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Abfüllungen konnten nicht geladen werden. +

+ ) : null} + + {bottlingBatches && bottlingBatches.length === 0 ? ( + + ) : null} + + {bottlingBatches && bottlingBatches.length > 0 ? ( +
+ {bottlingBatches.map((bottlingBatch) => ( + {bottlingBatch.lotNumber}} + subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber} + fields={[ + { + label: "Menge", + value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`, + }, + { label: "Datum", value: formatDate(bottlingBatch.date) }, + ]} + /> + ))} +
+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/website/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/website/page.tsx new file mode 100644 index 0000000..8837602 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/website/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Globe } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api"; +import { SiteSettings } from "@/lib/site-settings/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PageHeader } from "@/components/page-header"; +import { Card, CardContent } from "@/components/ui/card"; + +// Mounted only once `settings` has arrived, so its useState initial values +// already reflect the fetched data - no synchronization effect needed. +function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { + const queryClient = useQueryClient(); + const [youtubeUrl, setYoutubeUrl] = useState(settings.youtubeUrl ?? ""); + const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? ""); + const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? ""); + + const mutation = useMutation({ + mutationFn: updateSiteSettings, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["site-settings"] }); + toast.success("Website-Einstellungen gespeichert"); + }, + onError: () => toast.error("Einstellungen konnten nicht gespeichert werden"), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + mutation.mutate({ + youtubeUrl, + instagramUrl, + tiktokUrl, + }); + } + + return ( + + +
+

+ Wird auf jeder Herkunftsseite (/herkunft/…) angezeigt. Feld leer lassen, um einen Link + zu entfernen. +

+
+ + setYoutubeUrl(event.target.value)} + /> +
+
+ + setInstagramUrl(event.target.value)} + /> +
+
+ + setTiktokUrl(event.target.value)} + /> +
+ +
+
+
+ ); +} + +export default function WebsiteSettingsPage() { + const { data: settings, isLoading, isError } = useQuery({ + queryKey: ["site-settings"], + queryFn: fetchSiteSettings, + }); + + return ( +
+ + + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Einstellungen konnten nicht geladen werden. +

+ ) : null} + + {settings ? : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index b9d3209..dc3f3c1 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -9,6 +9,7 @@ import { Droplet, FileClock, FlaskConical, + Globe, Hexagon, History, LayoutDashboard, @@ -74,7 +75,7 @@ const NAV_GROUPS: NavGroup[] = [ { label: "Ernten", href: "/harvests/new", icon: Scissors }, { label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical }, { label: "Honigchargen", href: "/honey-batches", icon: Droplet }, - { label: "Abfüllungen", href: "/bottling/new", icon: Package }, + { label: "Abfüllungen", href: "/bottling", icon: Package }, { label: "Produkte", href: "/products", icon: ShoppingBag }, { label: "Lager", href: "/storage", icon: Warehouse }, { label: "Inventar", href: "/equipment", icon: Wrench }, @@ -112,6 +113,12 @@ const ADMIN_GROUP: NavGroup = { permission: "label-templates:read", }, { label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" }, + { + label: "Website", + href: "/settings/website", + icon: Globe, + permission: "site-settings:read", + }, ], }; diff --git a/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx b/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx index 3466b67..45d07e0 100644 --- a/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx +++ b/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx @@ -2,7 +2,7 @@ import { useParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; -import { Hexagon, Droplet, MapPin, CalendarDays } from "lucide-react"; +import { Hexagon, Droplet, MapPin, CalendarDays, ExternalLink } from "lucide-react"; import { fetchPublicBatch } from "@/lib/public-batch/api"; import { HoneycombPattern } from "@/components/honeycomb-pattern"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -29,6 +29,13 @@ export default function PublicBatchPage() { ? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ") : null; const apiaryNames = batch ? batch.apiaries.map((a) => a.name).join(", ") : null; + const socialLinks = batch + ? [ + { label: "YouTube", url: batch.socialLinks.youtube }, + { label: "Instagram", url: batch.socialLinks.instagram }, + { label: "TikTok", url: batch.socialLinks.tiktok }, + ].filter((link): link is { label: string; url: string } => link.url !== null) + : []; return (
@@ -107,6 +114,22 @@ export default function PublicBatchPage() {

Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde

+ {socialLinks.length > 0 ? ( +
+ {socialLinks.map((link) => ( + + + {link.label} + + ))} +
+ ) : null} ) : null} diff --git a/ApiCore-Frontend/src/lib/public-batch/types.ts b/ApiCore-Frontend/src/lib/public-batch/types.ts index 7bdd72a..918ad5c 100644 --- a/ApiCore-Frontend/src/lib/public-batch/types.ts +++ b/ApiCore-Frontend/src/lib/public-batch/types.ts @@ -3,6 +3,12 @@ export interface PublicApiaryOrigin { location: string | null; } +export interface PublicSocialLinks { + youtube: string | null; + instagram: string | null; + tiktok: string | null; +} + export interface PublicBatch { lotNumber: string; productName: string | null; @@ -15,4 +21,5 @@ export interface PublicBatch { extractedDate: string | null; harvestDates: string[]; apiaries: PublicApiaryOrigin[]; + socialLinks: PublicSocialLinks; } diff --git a/ApiCore-Frontend/src/lib/site-settings/api.ts b/ApiCore-Frontend/src/lib/site-settings/api.ts new file mode 100644 index 0000000..a221956 --- /dev/null +++ b/ApiCore-Frontend/src/lib/site-settings/api.ts @@ -0,0 +1,13 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { SiteSettings, UpdateSiteSettingsInput } from "./types"; + +export function fetchSiteSettings(): Promise { + return apiFetch("/site-settings"); +} + +export function updateSiteSettings(input: UpdateSiteSettingsInput): Promise { + return apiFetch("/site-settings", { + method: "PATCH", + body: JSON.stringify(input), + }); +} diff --git a/ApiCore-Frontend/src/lib/site-settings/types.ts b/ApiCore-Frontend/src/lib/site-settings/types.ts new file mode 100644 index 0000000..dfb6932 --- /dev/null +++ b/ApiCore-Frontend/src/lib/site-settings/types.ts @@ -0,0 +1,13 @@ +export interface SiteSettings { + id: string; + youtubeUrl: string | null; + instagramUrl: string | null; + tiktokUrl: string | null; + updatedAt: string; +} + +export interface UpdateSiteSettingsInput { + youtubeUrl?: string; + instagramUrl?: string; + tiktokUrl?: string; +}