Compare commits
2
Commits
390ec32f50
...
d998f611e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d998f611e2 | ||
|
|
2f223b6941 |
No files matched your search
@@ -41,6 +41,7 @@ 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 { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||||
|
import { BackupModule } from './backup/backup.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';
|
||||||
@@ -89,6 +90,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
|||||||
WeatherModule,
|
WeatherModule,
|
||||||
ProfileModule,
|
ProfileModule,
|
||||||
SiteSettingsModule,
|
SiteSettingsModule,
|
||||||
|
BackupModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -285,6 +285,15 @@ export const PERMISSION_CATALOG = [
|
|||||||
],
|
],
|
||||||
3100,
|
3100,
|
||||||
),
|
),
|
||||||
|
...group(
|
||||||
|
'Backup',
|
||||||
|
'backup',
|
||||||
|
[
|
||||||
|
['export', 'Exportieren'],
|
||||||
|
['import', 'Importieren'],
|
||||||
|
],
|
||||||
|
3200,
|
||||||
|
),
|
||||||
] 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'];
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
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 { BackupBundle, BackupService } from './backup.service';
|
||||||
|
|
||||||
|
// A full backup bundle can be much larger than the 5mb CSV import cap
|
||||||
|
// elsewhere (dozens of tables, potentially years of records, plus embedded
|
||||||
|
// label-template images) - 50mb comfortably covers a real dataset without
|
||||||
|
// raising the app-wide JSON body limit for every other route.
|
||||||
|
const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
@ApiTags('backup')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('backup')
|
||||||
|
export class BackupController {
|
||||||
|
constructor(private readonly backupService: BackupService) {}
|
||||||
|
|
||||||
|
@RequirePermission('backup:export')
|
||||||
|
@Get('export')
|
||||||
|
async export(@Res() res: Response): Promise<void> {
|
||||||
|
const bundle = await this.backupService.exportAll();
|
||||||
|
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(JSON.stringify(bundle, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('backup:import')
|
||||||
|
@Post('import')
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_BACKUP_FILE_SIZE_BYTES },
|
||||||
|
fileFilter: (_req, file, callback) => {
|
||||||
|
if (!file.originalname.toLowerCase().endsWith('.json')) {
|
||||||
|
callback(
|
||||||
|
new BadRequestException('Nur JSON-Dateien werden unterstützt'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async import(
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('No file uploaded');
|
||||||
|
}
|
||||||
|
let bundle: BackupBundle;
|
||||||
|
try {
|
||||||
|
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle;
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Datei ist kein gültiges JSON');
|
||||||
|
}
|
||||||
|
return this.backupService.importAll(bundle, actor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BackupController } from './backup.controller';
|
||||||
|
import { BackupService } from './backup.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BackupController],
|
||||||
|
providers: [BackupService],
|
||||||
|
})
|
||||||
|
export class BackupModule {}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { BackupService } from './backup.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
|
||||||
|
const ENTITY_KEYS = [
|
||||||
|
'apiary',
|
||||||
|
'queen',
|
||||||
|
'colony',
|
||||||
|
'colonyApiaryHistory',
|
||||||
|
'colonyLineage',
|
||||||
|
'queenEvaluation',
|
||||||
|
'queenColonyHistory',
|
||||||
|
'inspection',
|
||||||
|
'taskTemplate',
|
||||||
|
'task',
|
||||||
|
'varroaMeasurement',
|
||||||
|
'swarmControlCheck',
|
||||||
|
'treatment',
|
||||||
|
'feeding',
|
||||||
|
'harvest',
|
||||||
|
'harvestColony',
|
||||||
|
'extraction',
|
||||||
|
'extractionHarvest',
|
||||||
|
'storageContainer',
|
||||||
|
'honeyBatch',
|
||||||
|
'honeyMeasurement',
|
||||||
|
'honeyBatchRelocation',
|
||||||
|
'stockItem',
|
||||||
|
'stockMovement',
|
||||||
|
'product',
|
||||||
|
'labelTemplate',
|
||||||
|
'bottlingBatch',
|
||||||
|
'equipment',
|
||||||
|
'webhookSubscription',
|
||||||
|
'automationRule',
|
||||||
|
'siteSettings',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe('BackupService', () => {
|
||||||
|
let service: BackupService;
|
||||||
|
let prisma: Record<string, { findMany: jest.Mock }> & {
|
||||||
|
user: { findMany: jest.Mock };
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
};
|
||||||
|
let tx: Record<
|
||||||
|
string,
|
||||||
|
{ upsert: jest.Mock; update?: jest.Mock }
|
||||||
|
>;
|
||||||
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx = {};
|
||||||
|
for (const key of ENTITY_KEYS) {
|
||||||
|
tx[key] = { upsert: jest.fn().mockResolvedValue({}) };
|
||||||
|
}
|
||||||
|
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
prisma = {
|
||||||
|
user: { findMany: jest.fn().mockResolvedValue([{ id: 'user-1' }]) },
|
||||||
|
$transaction: jest.fn(
|
||||||
|
(callback: (txArg: typeof tx) => unknown) => callback(tx),
|
||||||
|
),
|
||||||
|
} as never;
|
||||||
|
for (const key of ENTITY_KEYS) {
|
||||||
|
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
service = new BackupService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
auditLog as unknown as AuditLogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportAll', () => {
|
||||||
|
it('returns a versioned bundle with every entity key', async () => {
|
||||||
|
const bundle = await service.exportAll();
|
||||||
|
|
||||||
|
expect(bundle.version).toBe(1);
|
||||||
|
expect(typeof bundle.exportedAt).toBe('string');
|
||||||
|
for (const key of ENTITY_KEYS) {
|
||||||
|
expect(bundle.data).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importAll', () => {
|
||||||
|
it('rejects a bundle with no data object', async () => {
|
||||||
|
await expect(
|
||||||
|
service.importAll({ version: 1 } as never, 'admin-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links a self-referencing motherQueenId only after every queen exists', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
queen: [
|
||||||
|
{ id: 'queen-daughter', motherQueenId: 'queen-mother' },
|
||||||
|
{ id: 'queen-mother', motherQueenId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
// First pass: both queens upserted with motherQueenId stripped, so a
|
||||||
|
// daughter appearing before her mother in the array never violates
|
||||||
|
// the foreign key.
|
||||||
|
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
|
||||||
|
for (const call of tx.queen.upsert.mock.calls as {
|
||||||
|
create: { motherQueenId: string | null };
|
||||||
|
}[][]) {
|
||||||
|
expect(call[0].create.motherQueenId).toBeNull();
|
||||||
|
}
|
||||||
|
// Second pass: only the daughter's real motherQueenId is patched back.
|
||||||
|
expect(tx.queen.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.queen.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'queen-daughter' },
|
||||||
|
data: { motherQueenId: 'queen-mother' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remaps a required userId that does not exist in the target database to the importing admin', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
||||||
|
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
inspection: [
|
||||||
|
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'ghost-user' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||||
|
[{ create: { userId: string } }],
|
||||||
|
];
|
||||||
|
expect(call.create.userId).toBe('admin-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a required userId that does exist in the target database', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
||||||
|
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
inspection: [
|
||||||
|
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'known-user' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||||
|
[{ create: { userId: string } }],
|
||||||
|
];
|
||||||
|
expect(call.create.userId).toBe('known-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an optional userId as null rather than substituting the admin', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
stockMovement: [
|
||||||
|
{ id: 'movement-1', stockItemId: 'stock-1', userId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[call]] = tx.stockMovement.upsert.mock.calls as [
|
||||||
|
[{ create: { userId: string | null } }],
|
||||||
|
];
|
||||||
|
expect(call.create.userId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts automationRule by triggerType, not id, and drops the source id', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: {
|
||||||
|
automationRule: [
|
||||||
|
{
|
||||||
|
id: 'source-db-id',
|
||||||
|
triggerType: 'VARROA_THRESHOLD',
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[call]] = tx.automationRule.upsert.mock.calls as [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
where: { triggerType: string };
|
||||||
|
create: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
expect(call.where).toEqual({ triggerType: 'VARROA_THRESHOLD' });
|
||||||
|
expect(call.create).not.toHaveProperty('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records an audit log entry with the per-entity import counts', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'Backup',
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: 'admin-1',
|
||||||
|
afterData: expect.objectContaining({ apiary: 1 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
|
||||||
|
const BACKUP_VERSION = 1;
|
||||||
|
const IMPORT_TRANSACTION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface BackupBundle {
|
||||||
|
version: number;
|
||||||
|
exportedAt: string;
|
||||||
|
data: Record<string, unknown[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupImportSummary {
|
||||||
|
imported: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excluded on purpose:
|
||||||
|
// - User/Role/Permission/RolePermission/RefreshToken: access control is
|
||||||
|
// managed live via the Benutzer & Rollen page, not round-tripped through a
|
||||||
|
// JSON file - Permission rows are also code-derived (re-synced from the
|
||||||
|
// catalog on every boot) and would just conflict with that.
|
||||||
|
// - AuditLog: an immutable historical record, not data to restore.
|
||||||
|
// - Notification: ephemeral, not worth backing up.
|
||||||
|
// - Document: only the DB row (filename/path/size) would be backed up, not
|
||||||
|
// the actual uploaded file bytes on disk - a restored row pointing at a
|
||||||
|
// file that was never copied is worse than no row at all. File storage
|
||||||
|
// needs its own (volume-level) backup strategy.
|
||||||
|
//
|
||||||
|
// Every model below keeps a `userId`-shaped column pointing at the (excluded)
|
||||||
|
// User table. On import, an id that doesn't exist in the target database is
|
||||||
|
// replaced with the id of the admin performing the import - see
|
||||||
|
// resolveUserId/resolveOptionalUserId.
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
function asString(row: Row, key: string): string | null {
|
||||||
|
const value = row[key];
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BackupService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async exportAll(): Promise<BackupBundle> {
|
||||||
|
const data: Record<string, unknown[]> = {};
|
||||||
|
data.apiary = await this.prisma.apiary.findMany();
|
||||||
|
data.queen = await this.prisma.queen.findMany();
|
||||||
|
data.colony = await this.prisma.colony.findMany();
|
||||||
|
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
|
||||||
|
data.colonyLineage = await this.prisma.colonyLineage.findMany();
|
||||||
|
data.queenEvaluation = await this.prisma.queenEvaluation.findMany();
|
||||||
|
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
|
||||||
|
data.inspection = await this.prisma.inspection.findMany();
|
||||||
|
data.taskTemplate = await this.prisma.taskTemplate.findMany();
|
||||||
|
data.task = await this.prisma.task.findMany();
|
||||||
|
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||||
|
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||||
|
data.treatment = await this.prisma.treatment.findMany();
|
||||||
|
data.feeding = await this.prisma.feeding.findMany();
|
||||||
|
data.harvest = await this.prisma.harvest.findMany();
|
||||||
|
data.harvestColony = await this.prisma.harvestColony.findMany();
|
||||||
|
data.extraction = await this.prisma.extraction.findMany();
|
||||||
|
data.extractionHarvest = await this.prisma.extractionHarvest.findMany();
|
||||||
|
data.storageContainer = await this.prisma.storageContainer.findMany();
|
||||||
|
data.honeyBatch = await this.prisma.honeyBatch.findMany();
|
||||||
|
data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany();
|
||||||
|
data.honeyBatchRelocation =
|
||||||
|
await this.prisma.honeyBatchRelocation.findMany();
|
||||||
|
data.stockItem = await this.prisma.stockItem.findMany();
|
||||||
|
data.stockMovement = await this.prisma.stockMovement.findMany();
|
||||||
|
data.product = await this.prisma.product.findMany();
|
||||||
|
data.labelTemplate = await this.prisma.labelTemplate.findMany();
|
||||||
|
data.bottlingBatch = await this.prisma.bottlingBatch.findMany();
|
||||||
|
data.equipment = await this.prisma.equipment.findMany();
|
||||||
|
data.webhookSubscription = await this.prisma.webhookSubscription.findMany();
|
||||||
|
data.automationRule = await this.prisma.automationRule.findMany();
|
||||||
|
data.siteSettings = await this.prisma.siteSettings.findMany();
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: BACKUP_VERSION,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async importAll(
|
||||||
|
bundle: BackupBundle,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<BackupImportSummary> {
|
||||||
|
if (
|
||||||
|
typeof bundle !== 'object' ||
|
||||||
|
bundle === null ||
|
||||||
|
typeof bundle.data !== 'object' ||
|
||||||
|
bundle.data === null
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Invalid backup file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownUserIds = new Set(
|
||||||
|
(await this.prisma.user.findMany({ select: { id: true } })).map(
|
||||||
|
(user) => user.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const resolveUserId = (rawUserId: string | null): string =>
|
||||||
|
rawUserId !== null && knownUserIds.has(rawUserId)
|
||||||
|
? rawUserId
|
||||||
|
: actingUserId;
|
||||||
|
const resolveOptionalUserId = (rawUserId: string | null): string | null =>
|
||||||
|
rawUserId === null ? null : resolveUserId(rawUserId);
|
||||||
|
|
||||||
|
const rowsOf = (key: string): Row[] => {
|
||||||
|
const value = bundle.data[key];
|
||||||
|
return Array.isArray(value) ? (value as Row[]) : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const imported: Record<string, number> = {};
|
||||||
|
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
for (const row of rowsOf('apiary')) {
|
||||||
|
await tx.apiary.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.apiary = rowsOf('apiary').length;
|
||||||
|
|
||||||
|
// Inserted without motherQueenId first (a daughter queen can appear
|
||||||
|
// before her mother in export order) - patched in a second pass
|
||||||
|
// below once every queen row exists.
|
||||||
|
for (const row of rowsOf('queen')) {
|
||||||
|
const withoutMother: Row = { ...row, motherQueenId: null };
|
||||||
|
await tx.queen.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: withoutMother as never,
|
||||||
|
update: withoutMother as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.queen = rowsOf('queen').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('colony')) {
|
||||||
|
await tx.colony.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.colony = rowsOf('colony').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('queen')) {
|
||||||
|
if (row.motherQueenId) {
|
||||||
|
await tx.queen.update({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
data: { motherQueenId: row.motherQueenId as string },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rowsOf('colonyApiaryHistory')) {
|
||||||
|
await tx.colonyApiaryHistory.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
movedByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'movedByUserId'),
|
||||||
|
),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
movedByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'movedByUserId'),
|
||||||
|
),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('colonyLineage')) {
|
||||||
|
await tx.colonyLineage.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.colonyLineage = rowsOf('colonyLineage').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('queenEvaluation')) {
|
||||||
|
await tx.queenEvaluation.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.queenEvaluation = rowsOf('queenEvaluation').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('queenColonyHistory')) {
|
||||||
|
await tx.queenColonyHistory.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('inspection')) {
|
||||||
|
await tx.inspection.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.inspection = rowsOf('inspection').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('taskTemplate')) {
|
||||||
|
await tx.taskTemplate.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
assignedUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'assignedUserId'),
|
||||||
|
),
|
||||||
|
createdByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'createdByUserId'),
|
||||||
|
),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
assignedUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'assignedUserId'),
|
||||||
|
),
|
||||||
|
createdByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'createdByUserId'),
|
||||||
|
),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.taskTemplate = rowsOf('taskTemplate').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('task')) {
|
||||||
|
await tx.task.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
assignedUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'assignedUserId'),
|
||||||
|
),
|
||||||
|
createdByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'createdByUserId'),
|
||||||
|
),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
assignedUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'assignedUserId'),
|
||||||
|
),
|
||||||
|
createdByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'createdByUserId'),
|
||||||
|
),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.task = rowsOf('task').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('varroaMeasurement')) {
|
||||||
|
await tx.varroaMeasurement.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.varroaMeasurement = rowsOf('varroaMeasurement').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('swarmControlCheck')) {
|
||||||
|
await tx.swarmControlCheck.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('treatment')) {
|
||||||
|
await tx.treatment.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.treatment = rowsOf('treatment').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('feeding')) {
|
||||||
|
await tx.feeding.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.feeding = rowsOf('feeding').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('harvest')) {
|
||||||
|
await tx.harvest.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.harvest = rowsOf('harvest').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('harvestColony')) {
|
||||||
|
await tx.harvestColony.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.harvestColony = rowsOf('harvestColony').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('extraction')) {
|
||||||
|
await tx.extraction.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.extraction = rowsOf('extraction').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('extractionHarvest')) {
|
||||||
|
await tx.extractionHarvest.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.extractionHarvest = rowsOf('extractionHarvest').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('storageContainer')) {
|
||||||
|
await tx.storageContainer.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.storageContainer = rowsOf('storageContainer').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('honeyBatch')) {
|
||||||
|
await tx.honeyBatch.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.honeyBatch = rowsOf('honeyBatch').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('honeyMeasurement')) {
|
||||||
|
await tx.honeyMeasurement.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.honeyMeasurement = rowsOf('honeyMeasurement').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('honeyBatchRelocation')) {
|
||||||
|
await tx.honeyBatchRelocation.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('stockItem')) {
|
||||||
|
await tx.stockItem.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.stockItem = rowsOf('stockItem').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('stockMovement')) {
|
||||||
|
await tx.stockMovement.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.stockMovement = rowsOf('stockMovement').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('product')) {
|
||||||
|
await tx.product.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.product = rowsOf('product').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('labelTemplate')) {
|
||||||
|
await tx.labelTemplate.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.labelTemplate = rowsOf('labelTemplate').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('bottlingBatch')) {
|
||||||
|
await tx.bottlingBatch.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.bottlingBatch = rowsOf('bottlingBatch').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('equipment')) {
|
||||||
|
await tx.equipment.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.equipment = rowsOf('equipment').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('webhookSubscription')) {
|
||||||
|
await tx.webhookSubscription.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
createdByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'createdByUserId'),
|
||||||
|
),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
createdByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'createdByUserId'),
|
||||||
|
),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.webhookSubscription = rowsOf('webhookSubscription').length;
|
||||||
|
|
||||||
|
// Keyed by triggerType (its real unique key), not id: these rows are
|
||||||
|
// fixed, code-known trigger definitions rather than freely created
|
||||||
|
// data, and the target database's own row for the same triggerType
|
||||||
|
// may already exist under a different id.
|
||||||
|
for (const row of rowsOf('automationRule')) {
|
||||||
|
const withoutId: Row = { ...row };
|
||||||
|
delete withoutId.id;
|
||||||
|
await tx.automationRule.upsert({
|
||||||
|
where: { triggerType: row.triggerType as never },
|
||||||
|
create: withoutId as never,
|
||||||
|
update: withoutId as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.automationRule = rowsOf('automationRule').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('siteSettings')) {
|
||||||
|
const { id, ...withoutId } = row;
|
||||||
|
await tx.siteSettings.upsert({
|
||||||
|
where: { id: (id as string) ?? 'singleton' },
|
||||||
|
create: row as never,
|
||||||
|
update: withoutId as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.siteSettings = rowsOf('siteSettings').length;
|
||||||
|
},
|
||||||
|
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'Backup',
|
||||||
|
entityId: 'import',
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
afterData: imported,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { imported };
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-12
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Zap } from "lucide-react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
|
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
|
||||||
@@ -10,7 +9,6 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
|
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
|
||||||
@@ -115,21 +113,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutomationRulesPage() {
|
export function AutomationRulesTab() {
|
||||||
const { data: rules, isLoading, isError } = useQuery({
|
const { data: rules, isLoading, isError } = useQuery({
|
||||||
queryKey: ["automation-rules"],
|
queryKey: ["automation-rules"],
|
||||||
queryFn: fetchAutomationRules,
|
queryFn: fetchAutomationRules,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
|
||||||
icon={Zap}
|
|
||||||
title="Automatisierungen"
|
|
||||||
description="Fest hinterlegte Regeln aktivieren, deaktivieren und anpassen"
|
|
||||||
gradient="var(--gradient-sky)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
@@ -142,6 +133,6 @@ export default function AutomationRulesPage() {
|
|||||||
<RuleCard key={rule.id} rule={rule} />
|
<RuleCard key={rule.id} rule={rule} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+5
-12
@@ -7,11 +7,10 @@ import { toast } from "sonner";
|
|||||||
import { deactivateLabelTemplate, fetchLabelTemplates } from "@/lib/label-templates/api";
|
import { deactivateLabelTemplate, fetchLabelTemplates } from "@/lib/label-templates/api";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { EntityCard } from "@/components/entity-card";
|
import { EntityCard } from "@/components/entity-card";
|
||||||
|
|
||||||
export default function LabelTemplatesPage() {
|
export function LabelTemplatesTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: templates, isLoading, isError } = useQuery({
|
const { data: templates, isLoading, isError } = useQuery({
|
||||||
queryKey: ["label-templates"],
|
queryKey: ["label-templates"],
|
||||||
@@ -30,19 +29,13 @@ export default function LabelTemplatesPage() {
|
|||||||
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
<div className="flex justify-end">
|
||||||
icon={Tag}
|
|
||||||
title="Etikettenvorlagen"
|
|
||||||
description="Vorlagen mit Platzhaltern für den Etikettendruck verwalten"
|
|
||||||
gradient="var(--gradient-sky)"
|
|
||||||
actions={
|
|
||||||
<Button render={<Link href="/settings/label-templates/new" />}>
|
<Button render={<Link href="/settings/label-templates/new" />}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Neue Etikettenvorlage
|
Neue Etikettenvorlage
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
{isError ? (
|
{isError ? (
|
||||||
@@ -96,6 +89,6 @@ export default function LabelTemplatesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Zap } from "lucide-react";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { TabSwitcher } from "@/components/tab-switcher";
|
||||||
|
import { WebhooksTab } from "./webhooks-tab";
|
||||||
|
import { TaskTemplatesTab } from "./task-templates-tab";
|
||||||
|
import { LabelTemplatesTab } from "./label-templates-tab";
|
||||||
|
import { SocialMediaTab } from "./social-media-tab";
|
||||||
|
import { AutomationRulesTab } from "./automation-rules-tab";
|
||||||
|
|
||||||
|
type Tab = "webhooks" | "task-templates" | "label-templates" | "social-media" | "rules";
|
||||||
|
|
||||||
|
const TABS: { value: Tab; label: string }[] = [
|
||||||
|
{ value: "webhooks", label: "Webhooks" },
|
||||||
|
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
|
||||||
|
{ value: "label-templates", label: "Etikettenvorlagen" },
|
||||||
|
{ value: "social-media", label: "Social Media" },
|
||||||
|
{ value: "rules", label: "Regeln" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isTab(value: string | null): value is Tab {
|
||||||
|
return TABS.some((tab) => tab.value === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationPageContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tab: Tab = isTab(searchParams.get("tab")) ? (searchParams.get("tab") as Tab) : "webhooks";
|
||||||
|
|
||||||
|
function setTab(next: Tab) {
|
||||||
|
router.replace(`/settings/automation?tab=${next}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Zap}
|
||||||
|
title="Automatisierung"
|
||||||
|
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media und Regeln"
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
{tab === "webhooks" ? <WebhooksTab /> : null}
|
||||||
|
{tab === "task-templates" ? <TaskTemplatesTab /> : null}
|
||||||
|
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
|
||||||
|
{tab === "social-media" ? <SocialMediaTab /> : null}
|
||||||
|
{tab === "rules" ? <AutomationRulesTab /> : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutomationPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AutomationPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-12
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { Globe } from "lucide-react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
|
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
|
||||||
@@ -9,7 +8,6 @@ import { SiteSettings } from "@/lib/site-settings/types";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
// Mounted only once `settings` has arrived, so its useState initial values
|
// Mounted only once `settings` has arrived, so its useState initial values
|
||||||
@@ -85,21 +83,14 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WebsiteSettingsPage() {
|
export function SocialMediaTab() {
|
||||||
const { data: settings, isLoading, isError } = useQuery({
|
const { data: settings, isLoading, isError } = useQuery({
|
||||||
queryKey: ["site-settings"],
|
queryKey: ["site-settings"],
|
||||||
queryFn: fetchSiteSettings,
|
queryFn: fetchSiteSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<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}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
@@ -108,6 +99,6 @@ export default function WebsiteSettingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{settings ? <WebsiteSettingsForm settings={settings} /> : null}
|
{settings ? <WebsiteSettingsForm settings={settings} /> : null}
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+6
-12
@@ -8,7 +8,6 @@ import { deactivateTaskTemplate, fetchTaskTemplates } from "@/lib/task-templates
|
|||||||
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
|
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { EntityCard } from "@/components/entity-card";
|
import { EntityCard } from "@/components/entity-card";
|
||||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||||
@@ -35,7 +34,7 @@ function formatDate(value: string): string {
|
|||||||
return new Date(value).toLocaleDateString("de-DE");
|
return new Date(value).toLocaleDateString("de-DE");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskTemplatesPage() {
|
export function TaskTemplatesTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<EntityLifecycleTab>("active");
|
const [tab, setTab] = useState<EntityLifecycleTab>("active");
|
||||||
const { data: templates, isLoading, isError } = useQuery({
|
const { data: templates, isLoading, isError } = useQuery({
|
||||||
@@ -57,21 +56,16 @@ export default function TaskTemplatesPage() {
|
|||||||
const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates;
|
const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
icon={Repeat}
|
|
||||||
title="Wiederkehrende Aufgaben"
|
|
||||||
description="Vorlagen für regelmäßig automatisch angelegte Aufgaben verwalten"
|
|
||||||
gradient="var(--gradient-sky)"
|
|
||||||
actions={<CreateTaskTemplateDialog />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActiveHistoryTabs
|
<ActiveHistoryTabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
activeCount={activeTemplates.length}
|
activeCount={activeTemplates.length}
|
||||||
historyCount={historyTemplates.length}
|
historyCount={historyTemplates.length}
|
||||||
/>
|
/>
|
||||||
|
<CreateTaskTemplateDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
{isError ? (
|
{isError ? (
|
||||||
@@ -127,6 +121,6 @@ export default function TaskTemplatesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+6
-12
@@ -1,12 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Webhook } from "lucide-react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api";
|
import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -17,7 +15,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { CreateWebhookDialog } from "./create-webhook-dialog";
|
import { CreateWebhookDialog } from "./create-webhook-dialog";
|
||||||
|
|
||||||
export default function WebhooksPage() {
|
export function WebhooksTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: webhooks, isLoading, isError } = useQuery({
|
const { data: webhooks, isLoading, isError } = useQuery({
|
||||||
queryKey: ["webhooks"],
|
queryKey: ["webhooks"],
|
||||||
@@ -34,14 +32,10 @@ export default function WebhooksPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
<div className="flex justify-end">
|
||||||
icon={Webhook}
|
<CreateWebhookDialog />
|
||||||
title="Webhooks"
|
</div>
|
||||||
description="Externe Systeme über Ereignisse in ApiCore benachrichtigen"
|
|
||||||
gradient="var(--gradient-sky)"
|
|
||||||
actions={<CreateWebhookDialog />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
{isError ? (
|
{isError ? (
|
||||||
@@ -101,6 +95,6 @@ export default function WebhooksPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+111
-11
@@ -1,15 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChangeEvent, useState } from "react";
|
import { ChangeEvent, useState } from "react";
|
||||||
import { Upload } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { exportBackup, importBackup } from "@/lib/backup/api";
|
||||||
import { importCsv } from "@/lib/import/api";
|
import { importCsv } from "@/lib/import/api";
|
||||||
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
|
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -27,6 +29,90 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackupSection() {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
mutationFn: exportBackup,
|
||||||
|
onSuccess: ({ blob, filename }) => {
|
||||||
|
downloadBlob(blob, filename);
|
||||||
|
toast.success("Backup heruntergeladen");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Backup konnte nicht exportiert werden"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!file) {
|
||||||
|
throw new Error("Keine Datei ausgewählt");
|
||||||
|
}
|
||||||
|
return importBackup(file);
|
||||||
|
},
|
||||||
|
onSuccess: (summary) => {
|
||||||
|
const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0);
|
||||||
|
toast.success(`Backup eingespielt: ${total} Datensätze verarbeitet`);
|
||||||
|
setFile(null);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Backup konnte nicht eingespielt werden"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
setFile(event.target.files?.[0] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Backup</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4 text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Sichert Bienenstände bis Etikettenvorlagen (Betriebsdaten) in einer JSON-Datei -
|
||||||
|
für Backups und zur Übertragung auf ein anderes Deployment. Benutzer, Rollen,
|
||||||
|
Audit-Log und hochgeladene Dokumente sind nicht enthalten.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
onClick={() => exportMutation.mutate()}
|
||||||
|
>
|
||||||
|
{exportMutation.isPending ? "Wird exportiert…" : "Backup exportieren"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border/60 pt-4">
|
||||||
|
<Label htmlFor="backup-file">Backup-Datei einspielen</Label>
|
||||||
|
<input
|
||||||
|
id="backup-file"
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
triggerLabel="Backup importieren"
|
||||||
|
triggerDisabled={!file || importMutation.isPending}
|
||||||
|
title="Backup einspielen?"
|
||||||
|
description="Datensätze aus der Datei werden angelegt oder mit bestehenden gleicher ID überschrieben. Bereits vorhandene Daten, die in der Datei nicht enthalten sind, bleiben unverändert. Das kann bei einer großen Datei einen Moment dauern."
|
||||||
|
confirmLabel="Einspielen"
|
||||||
|
isPending={importMutation.isPending}
|
||||||
|
onConfirm={() => importMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<ImportEntity, string> = {
|
const ENTITY_LABELS: Record<ImportEntity, string> = {
|
||||||
apiaries: "Bienenstände",
|
apiaries: "Bienenstände",
|
||||||
colonies: "Völker",
|
colonies: "Völker",
|
||||||
@@ -53,7 +139,7 @@ const ROW_STATUS_LABELS: Record<ImportRowStatus, string> = {
|
|||||||
error: "Fehler",
|
error: "Fehler",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ImportPage() {
|
function CsvImportSection() {
|
||||||
const [entity, setEntity] = useState<ImportEntity>("apiaries");
|
const [entity, setEntity] = useState<ImportEntity>("apiaries");
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [report, setReport] = useState<ImportReport | null>(null);
|
const [report, setReport] = useState<ImportReport | null>(null);
|
||||||
@@ -87,19 +173,15 @@ export default function ImportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
|
||||||
icon={Upload}
|
|
||||||
title="CSV-Import"
|
|
||||||
description="Stammdaten aus einer CSV-Datei anlegen"
|
|
||||||
gradient="var(--gradient-sky)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Datei hochladen</CardTitle>
|
<CardTitle>CSV-Import</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4 text-sm">
|
<CardContent className="flex flex-col gap-4 text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Einzelne Stammdaten (kein Backup) aus einer CSV-Tabelle neu anlegen.
|
||||||
|
</p>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="import-entity">Datentyp</Label>
|
<Label htmlFor="import-entity">Datentyp</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -173,6 +255,24 @@ export default function ImportPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackupPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Database}
|
||||||
|
title="Backup"
|
||||||
|
description="Daten sichern, wiederherstellen und übertragen"
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<BackupSection />
|
||||||
|
<CsvImportSection />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
gapYMm,
|
gapYMm,
|
||||||
isDefault,
|
isDefault,
|
||||||
});
|
});
|
||||||
router.push("/settings/label-templates");
|
router.push("/settings/automation?tab=label-templates");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Vorlage konnte nicht gespeichert werden.");
|
toast.error("Vorlage konnte nicht gespeichert werden.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ const SETTINGS_AREA_PERMISSIONS = [
|
|||||||
"webhooks:read",
|
"webhooks:read",
|
||||||
"automation:read",
|
"automation:read",
|
||||||
"task-templates:read",
|
"task-templates:read",
|
||||||
|
"label-templates:read",
|
||||||
|
"site-settings:read",
|
||||||
"import:execute",
|
"import:execute",
|
||||||
|
"backup:export",
|
||||||
|
"backup:import",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { TabSwitcher } from "@/components/tab-switcher";
|
||||||
|
import { UsersTab } from "./users-tab";
|
||||||
|
import { RolesTab } from "./roles-tab";
|
||||||
|
|
||||||
|
type Tab = "users" | "roles";
|
||||||
|
|
||||||
|
const TABS: { value: Tab; label: string }[] = [
|
||||||
|
{ value: "users", label: "Benutzer" },
|
||||||
|
{ value: "roles", label: "Rollen & Rechte" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isTab(value: string | null): value is Tab {
|
||||||
|
return value === "users" || value === "roles";
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersRolesPageContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tab: Tab = isTab(searchParams.get("tab")) ? searchParams.get("tab") as Tab : "users";
|
||||||
|
|
||||||
|
function setTab(next: Tab) {
|
||||||
|
router.replace(`/settings/users-roles?tab=${next}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Users}
|
||||||
|
title="Benutzer & Rollen"
|
||||||
|
description="Zugänge, Rollen und Berechtigungen verwalten"
|
||||||
|
gradient="var(--gradient-sage)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
{tab === "users" ? <UsersTab /> : <RolesTab />}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersRolesPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<UsersRolesPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
File renamed without changes.
+6
-12
@@ -14,14 +14,13 @@ import { RoleSummary } from "@/lib/roles/types";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { EntityCard } from "@/components/entity-card";
|
import { EntityCard } from "@/components/entity-card";
|
||||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||||
import { CreateRoleDialog } from "./create-role-dialog";
|
import { CreateRoleDialog } from "./create-role-dialog";
|
||||||
import { EditRoleDialog } from "./edit-role-dialog";
|
import { EditRoleDialog } from "./edit-role-dialog";
|
||||||
|
|
||||||
export default function RolesPage() {
|
export function RolesTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<EntityLifecycleTab>("active");
|
const [tab, setTab] = useState<EntityLifecycleTab>("active");
|
||||||
const [editingRole, setEditingRole] = useState<RoleSummary | null>(null);
|
const [editingRole, setEditingRole] = useState<RoleSummary | null>(null);
|
||||||
@@ -59,21 +58,16 @@ export default function RolesPage() {
|
|||||||
const catalogLength = catalog?.length ?? 0;
|
const catalogLength = catalog?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
icon={Shield}
|
|
||||||
title="Rollen & Rechte"
|
|
||||||
description="Wer darf auf welcher Seite was tun"
|
|
||||||
gradient="var(--gradient-sky)"
|
|
||||||
actions={<CreateRoleDialog />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActiveHistoryTabs
|
<ActiveHistoryTabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
activeCount={activeRoles.length}
|
activeCount={activeRoles.length}
|
||||||
historyCount={historyRoles.length}
|
historyCount={historyRoles.length}
|
||||||
/>
|
/>
|
||||||
|
<CreateRoleDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
{isError ? (
|
{isError ? (
|
||||||
@@ -169,6 +163,6 @@ export default function RolesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
|
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+4
-4
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import UsersPage from "./page";
|
import { UsersTab } from "./users-tab";
|
||||||
import { deactivateUser, fetchUsers } from "@/lib/users/api";
|
import { deactivateUser, fetchUsers } from "@/lib/users/api";
|
||||||
|
|
||||||
vi.mock("@/lib/users/api", () => ({
|
vi.mock("@/lib/users/api", () => ({
|
||||||
@@ -18,7 +18,7 @@ function renderWithClient(ui: ReactElement) {
|
|||||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("UsersPage", () => {
|
describe("UsersTab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(fetchUsers).mockReset();
|
vi.mocked(fetchUsers).mockReset();
|
||||||
vi.mocked(deactivateUser).mockReset();
|
vi.mocked(deactivateUser).mockReset();
|
||||||
@@ -40,7 +40,7 @@ describe("UsersPage", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderWithClient(<UsersPage />);
|
renderWithClient(<UsersTab />);
|
||||||
|
|
||||||
expect(await screen.findByText("[email protected]")).toBeInTheDocument();
|
expect(await screen.findByText("[email protected]")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Aktiv")).toBeInTheDocument();
|
expect(screen.getByText("Aktiv")).toBeInTheDocument();
|
||||||
@@ -49,7 +49,7 @@ describe("UsersPage", () => {
|
|||||||
it("shows an error message when loading users fails", async () => {
|
it("shows an error message when loading users fails", async () => {
|
||||||
vi.mocked(fetchUsers).mockRejectedValueOnce(new Error("boom"));
|
vi.mocked(fetchUsers).mockRejectedValueOnce(new Error("boom"));
|
||||||
|
|
||||||
renderWithClient(<UsersPage />);
|
renderWithClient(<UsersTab />);
|
||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(/nicht geladen/i);
|
expect(await screen.findByRole("alert")).toHaveTextContent(/nicht geladen/i);
|
||||||
});
|
});
|
||||||
+6
-11
@@ -7,7 +7,6 @@ import { activateUser, deactivateUser, fetchUsers, resetUserMfa } from "@/lib/us
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -19,7 +18,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { CreateUserDialog } from "./create-user-dialog";
|
import { CreateUserDialog } from "./create-user-dialog";
|
||||||
|
|
||||||
export default function UsersPage() {
|
export function UsersTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: users, isLoading, isError } = useQuery({
|
const { data: users, isLoading, isError } = useQuery({
|
||||||
queryKey: ["users"],
|
queryKey: ["users"],
|
||||||
@@ -56,14 +55,10 @@ export default function UsersPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
<div className="flex justify-end">
|
||||||
icon={Users}
|
<CreateUserDialog />
|
||||||
title="Benutzerverwaltung"
|
</div>
|
||||||
description="Zugänge und Rollen verwalten"
|
|
||||||
gradient="var(--gradient-sage)"
|
|
||||||
actions={<CreateUserDialog />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
{isError ? (
|
{isError ? (
|
||||||
@@ -147,6 +142,6 @@ export default function UsersPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,10 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Crown,
|
Crown,
|
||||||
|
Database,
|
||||||
Droplet,
|
Droplet,
|
||||||
FileClock,
|
FileClock,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Globe,
|
|
||||||
Hexagon,
|
Hexagon,
|
||||||
History,
|
History,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -18,16 +18,11 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Repeat,
|
|
||||||
Scissors,
|
Scissors,
|
||||||
Search,
|
Search,
|
||||||
Shield,
|
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Tag,
|
|
||||||
Upload,
|
|
||||||
Users,
|
Users,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
Webhook,
|
|
||||||
Wrench,
|
Wrench,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -38,7 +33,10 @@ interface NavItem {
|
|||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
permission?: string;
|
// Any one of these grants visibility - used for hub pages that fold
|
||||||
|
// together several previously-separate settings pages, each gated by its
|
||||||
|
// own permission.
|
||||||
|
permission?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
@@ -90,35 +88,31 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
const ADMIN_GROUP: NavGroup = {
|
const ADMIN_GROUP: NavGroup = {
|
||||||
label: "Verwaltung",
|
label: "Verwaltung",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Benutzer", href: "/settings/users", icon: Users, permission: "users:read" },
|
|
||||||
{ label: "Rollen & Rechte", href: "/settings/roles", icon: Shield, permission: "roles:read" },
|
|
||||||
{ label: "Audit-Log", href: "/settings/audit-log", icon: History, permission: "audit:read" },
|
|
||||||
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook, permission: "webhooks:read" },
|
|
||||||
{
|
{
|
||||||
label: "Automatisierungen",
|
label: "Benutzer & Rollen",
|
||||||
href: "/settings/automation-rules",
|
href: "/settings/users-roles",
|
||||||
|
icon: Users,
|
||||||
|
permission: ["users:read", "roles:read"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Automatisierung",
|
||||||
|
href: "/settings/automation",
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
permission: "automation:read",
|
permission: [
|
||||||
|
"webhooks:read",
|
||||||
|
"automation:read",
|
||||||
|
"task-templates:read",
|
||||||
|
"label-templates:read",
|
||||||
|
"site-settings:read",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Wiederkehrende Aufgaben",
|
label: "Backup",
|
||||||
href: "/settings/task-templates",
|
href: "/settings/backup",
|
||||||
icon: Repeat,
|
icon: Database,
|
||||||
permission: "task-templates:read",
|
permission: ["backup:export", "backup:import", "import:execute"],
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Etikettenvorlagen",
|
|
||||||
href: "/settings/label-templates",
|
|
||||||
icon: Tag,
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
|
{ label: "Audit-Log", href: "/settings/audit-log", icon: History, permission: "audit:read" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,10 +141,18 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasNavPermission(user: AuthUser, permission: NavItem["permission"]): boolean {
|
||||||
|
if (!permission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const required = Array.isArray(permission) ? permission : [permission];
|
||||||
|
return required.some((key) => user.permissions.includes(key));
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar({ user }: { user: AuthUser }) {
|
export function Sidebar({ user }: { user: AuthUser }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const visibleAdminItems = ADMIN_GROUP.items.filter(
|
const visibleAdminItems = ADMIN_GROUP.items.filter((item) =>
|
||||||
(item) => !item.permission || user.permissions.includes(item.permission),
|
hasNavPermission(user, item.permission),
|
||||||
);
|
);
|
||||||
const groups =
|
const groups =
|
||||||
visibleAdminItems.length > 0
|
visibleAdminItems.length > 0
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface TabSwitcherProps<T extends string> {
|
||||||
|
tabs: { value: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabSwitcher<T extends string>({ tabs, value, onChange }: TabSwitcherProps<T>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
className="inline-flex w-fit flex-wrap gap-1 rounded-xl border border-border/60 bg-muted/40 p-1"
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === tab.value}
|
||||||
|
onClick={() => onChange(tab.value)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
value === tab.value
|
||||||
|
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { apiFetchBlob, apiFetchFormData, BlobDownload } from "@/lib/auth/api-client";
|
||||||
|
import { BackupImportSummary } from "./types";
|
||||||
|
|
||||||
|
export function exportBackup(): Promise<BlobDownload> {
|
||||||
|
return apiFetchBlob("/backup/export", "apicore-backup.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importBackup(file: File): Promise<BackupImportSummary> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return apiFetchFormData<BackupImportSummary>("/backup/import", formData);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface BackupImportSummary {
|
||||||
|
imported: Record<string, number>;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user