feat: Abfüllungs-Archiv und Social-Media-Links auf der Herkunftsseite
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 <[email protected]>
This commit is contained in:
1 parent
33fa768062
commit
351f9ef34c
19 files changed
+612
-4
No files matched your search
@@ -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")
|
||||||
|
);
|
||||||
@@ -1025,3 +1025,18 @@ model AutomationRule {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
|||||||
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
||||||
import { WeatherModule } from './weather/weather.module';
|
import { WeatherModule } from './weather/weather.module';
|
||||||
import { ProfileModule } from './profile/profile.module';
|
import { ProfileModule } from './profile/profile.module';
|
||||||
|
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
@@ -87,6 +88,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
|||||||
SwarmControlModule,
|
SwarmControlModule,
|
||||||
WeatherModule,
|
WeatherModule,
|
||||||
ProfileModule,
|
ProfileModule,
|
||||||
|
SiteSettingsModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -276,6 +276,15 @@ export const PERMISSION_CATALOG = [
|
|||||||
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
|
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
|
||||||
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
|
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
|
||||||
...group('QR-Codes', 'qr', [['read', 'Ansehen']], 3000),
|
...group('QR-Codes', 'qr', [['read', 'Ansehen']], 3000),
|
||||||
|
...group(
|
||||||
|
'Website-Einstellungen',
|
||||||
|
'site-settings',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
3100,
|
||||||
|
),
|
||||||
] as const satisfies readonly PermissionCatalogEntry[];
|
] as const satisfies readonly PermissionCatalogEntry[];
|
||||||
|
|
||||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SiteSettingsModule } from '../site-settings/site-settings.module';
|
||||||
import { PublicController } from './public.controller';
|
import { PublicController } from './public.controller';
|
||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [SiteSettingsModule],
|
||||||
controllers: [PublicController],
|
controllers: [PublicController],
|
||||||
providers: [PublicService],
|
providers: [PublicService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||||
|
|
||||||
describe('PublicService', () => {
|
describe('PublicService', () => {
|
||||||
let service: PublicService;
|
let service: PublicService;
|
||||||
let prisma: { bottlingBatch: { findUnique: jest.Mock } };
|
let prisma: { bottlingBatch: { findUnique: jest.Mock } };
|
||||||
|
let siteSettings: { get: jest.Mock };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = { bottlingBatch: { findUnique: jest.fn() } };
|
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 () => {
|
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.apiaries).toEqual([{ name: 'Stand Wald', location: 'Wald' }]);
|
||||||
expect(result.harvestDates).toEqual([harvestDate1, harvestDate2]);
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||||
|
|
||||||
export interface PublicApiaryOrigin {
|
export interface PublicApiaryOrigin {
|
||||||
name: string;
|
name: string;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicSocialLinks {
|
||||||
|
youtube: string | null;
|
||||||
|
instagram: string | null;
|
||||||
|
tiktok: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicBatch {
|
export interface PublicBatch {
|
||||||
lotNumber: string;
|
lotNumber: string;
|
||||||
productName: string | null;
|
productName: string | null;
|
||||||
@@ -18,11 +25,15 @@ export interface PublicBatch {
|
|||||||
extractedDate: Date | null;
|
extractedDate: Date | null;
|
||||||
harvestDates: Date[];
|
harvestDates: Date[];
|
||||||
apiaries: PublicApiaryOrigin[];
|
apiaries: PublicApiaryOrigin[];
|
||||||
|
socialLinks: PublicSocialLinks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PublicService {
|
export class PublicService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly siteSettings: SiteSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findBatchByLot(lotNumber: string): Promise<PublicBatch> {
|
async findBatchByLot(lotNumber: string): Promise<PublicBatch> {
|
||||||
const batch = await this.prisma.bottlingBatch.findUnique({
|
const batch = await this.prisma.bottlingBatch.findUnique({
|
||||||
@@ -60,6 +71,8 @@ export class PublicService {
|
|||||||
.sort((a, b) => a - b)
|
.sort((a, b) => a - b)
|
||||||
.map((time) => new Date(time));
|
.map((time) => new Date(time));
|
||||||
|
|
||||||
|
const settings = await this.siteSettings.get();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lotNumber: batch.lotNumber,
|
lotNumber: batch.lotNumber,
|
||||||
productName: batch.product?.name ?? null,
|
productName: batch.product?.name ?? null,
|
||||||
@@ -72,6 +85,11 @@ export class PublicService {
|
|||||||
extractedDate: batch.honeyBatch.extraction.date,
|
extractedDate: batch.honeyBatch.extraction.date,
|
||||||
harvestDates,
|
harvestDates,
|
||||||
apiaries: [...apiaryByName.values()],
|
apiaries: [...apiaryByName.values()],
|
||||||
|
socialLinks: {
|
||||||
|
youtube: settings.youtubeUrl,
|
||||||
|
instagram: settings.instagramUrl,
|
||||||
|
tiktok: settings.tiktokUrl,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<SiteSettings> {
|
||||||
|
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<SiteSettings> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Package}
|
||||||
|
title="Abfüllungen"
|
||||||
|
description="Archiv aller abgefüllten Chargen"
|
||||||
|
actions={
|
||||||
|
<Button nativeButton={false} render={<Link href="/bottling/new" />}>
|
||||||
|
+ Neue Abfüllung
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Abfüllungen konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{bottlingBatches && bottlingBatches.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Package}
|
||||||
|
title="Noch keine Abfüllungen"
|
||||||
|
description="Lege eine Abfüllung an, sobald du Honig in Gläser gefüllt hast."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{bottlingBatches && bottlingBatches.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{bottlingBatches.map((bottlingBatch) => (
|
||||||
|
<EntityCard
|
||||||
|
key={bottlingBatch.id}
|
||||||
|
href={`/bottling/${bottlingBatch.id}`}
|
||||||
|
icon={Package}
|
||||||
|
title={<span className="font-mono">{bottlingBatch.lotNumber}</span>}
|
||||||
|
subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
label: "Menge",
|
||||||
|
value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`,
|
||||||
|
},
|
||||||
|
{ label: "Datum", value: formatDate(bottlingBatch.date) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
mutation.mutate({
|
||||||
|
youtubeUrl,
|
||||||
|
instagramUrl,
|
||||||
|
tiktokUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardContent>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Wird auf jeder Herkunftsseite (/herkunft/…) angezeigt. Feld leer lassen, um einen Link
|
||||||
|
zu entfernen.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="site-youtube">YouTube</Label>
|
||||||
|
<Input
|
||||||
|
id="site-youtube"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://youtube.com/@…"
|
||||||
|
value={youtubeUrl}
|
||||||
|
onChange={(event) => setYoutubeUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="site-instagram">Instagram</Label>
|
||||||
|
<Input
|
||||||
|
id="site-instagram"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://instagram.com/…"
|
||||||
|
value={instagramUrl}
|
||||||
|
onChange={(event) => setInstagramUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="site-tiktok">TikTok</Label>
|
||||||
|
<Input
|
||||||
|
id="site-tiktok"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://tiktok.com/@…"
|
||||||
|
value={tiktokUrl}
|
||||||
|
onChange={(event) => setTiktokUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebsiteSettingsPage() {
|
||||||
|
const { data: settings, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["site-settings"],
|
||||||
|
queryFn: fetchSiteSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Globe}
|
||||||
|
title="Website"
|
||||||
|
description="Social-Media-Links auf der öffentlichen Herkunftsseite"
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Einstellungen konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{settings ? <WebsiteSettingsForm settings={settings} /> : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Droplet,
|
Droplet,
|
||||||
FileClock,
|
FileClock,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
|
Globe,
|
||||||
Hexagon,
|
Hexagon,
|
||||||
History,
|
History,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -74,7 +75,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ label: "Ernten", href: "/harvests/new", icon: Scissors },
|
{ label: "Ernten", href: "/harvests/new", icon: Scissors },
|
||||||
{ label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical },
|
{ label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical },
|
||||||
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
{ 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: "Produkte", href: "/products", icon: ShoppingBag },
|
||||||
{ label: "Lager", href: "/storage", icon: Warehouse },
|
{ label: "Lager", href: "/storage", icon: Warehouse },
|
||||||
{ label: "Inventar", href: "/equipment", icon: Wrench },
|
{ label: "Inventar", href: "/equipment", icon: Wrench },
|
||||||
@@ -112,6 +113,12 @@ const ADMIN_GROUP: NavGroup = {
|
|||||||
permission: "label-templates:read",
|
permission: "label-templates:read",
|
||||||
},
|
},
|
||||||
{ label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" },
|
{ label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" },
|
||||||
|
{
|
||||||
|
label: "Website",
|
||||||
|
href: "/settings/website",
|
||||||
|
icon: Globe,
|
||||||
|
permission: "site-settings:read",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { fetchPublicBatch } from "@/lib/public-batch/api";
|
||||||
import { HoneycombPattern } from "@/components/honeycomb-pattern";
|
import { HoneycombPattern } from "@/components/honeycomb-pattern";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -29,6 +29,13 @@ export default function PublicBatchPage() {
|
|||||||
? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
|
? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
|
||||||
: null;
|
: null;
|
||||||
const apiaryNames = batch ? batch.apiaries.map((a) => a.name).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 (
|
return (
|
||||||
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
||||||
@@ -107,6 +114,22 @@ export default function PublicBatchPage() {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde
|
Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde
|
||||||
</p>
|
</p>
|
||||||
|
{socialLinks.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-3 border-t border-border pt-3">
|
||||||
|
{socialLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.label}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ export interface PublicApiaryOrigin {
|
|||||||
location: string | null;
|
location: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicSocialLinks {
|
||||||
|
youtube: string | null;
|
||||||
|
instagram: string | null;
|
||||||
|
tiktok: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicBatch {
|
export interface PublicBatch {
|
||||||
lotNumber: string;
|
lotNumber: string;
|
||||||
productName: string | null;
|
productName: string | null;
|
||||||
@@ -15,4 +21,5 @@ export interface PublicBatch {
|
|||||||
extractedDate: string | null;
|
extractedDate: string | null;
|
||||||
harvestDates: string[];
|
harvestDates: string[];
|
||||||
apiaries: PublicApiaryOrigin[];
|
apiaries: PublicApiaryOrigin[];
|
||||||
|
socialLinks: PublicSocialLinks;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { SiteSettings, UpdateSiteSettingsInput } from "./types";
|
||||||
|
|
||||||
|
export function fetchSiteSettings(): Promise<SiteSettings> {
|
||||||
|
return apiFetch<SiteSettings>("/site-settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSiteSettings(input: UpdateSiteSettingsInput): Promise<SiteSettings> {
|
||||||
|
return apiFetch<SiteSettings>("/site-settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user