From a85634a90678244ea5b600e52501106b4af62a71 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Thu, 12 Mar 2026 13:33:19 +0100 Subject: [PATCH] feat: add trade event (Messe-Timer) feature with admin CRUD and dashboard tiles Backend: TradeEvent Prisma model, NestJS CRUD module with date validation and tenant isolation. Frontend: Admin Events page with create/edit/delete modals, dashboard countdown tiles showing upcoming/ongoing/ended events with progress bars, and useEventCountdown hook for live timer updates. Co-Authored-By: Claude Opus 4.6 --- packages/crm-service/prisma/crm.schema.prisma | 31 ++ packages/crm-service/src/app.module.ts | 2 + .../dto/create-trade-event.dto.ts | 64 +++ .../dto/update-trade-event.dto.ts | 64 +++ .../trade-events/trade-events.controller.ts | 108 ++++ .../src/trade-events/trade-events.module.ts | 10 + .../src/trade-events/trade-events.service.ts | 124 +++++ .../src/admin/AdminEventsPage.module.css | 447 ++++++++++++++++ .../frontend/src/admin/AdminEventsPage.tsx | 476 ++++++++++++++++++ packages/frontend/src/admin/AdminLayout.tsx | 1 + .../components/EventCountdownTiles.module.css | 153 ++++++ .../src/components/EventCountdownTiles.tsx | 186 +++++++ packages/frontend/src/crm/api.ts | 37 ++ packages/frontend/src/crm/hooks.ts | 59 +++ packages/frontend/src/crm/types.ts | 46 ++ .../frontend/src/hooks/useEventCountdown.ts | 102 ++++ packages/frontend/src/shell/App.tsx | 2 + packages/frontend/src/shell/DashboardPage.tsx | 4 + 18 files changed, 1916 insertions(+) create mode 100644 packages/crm-service/src/trade-events/dto/create-trade-event.dto.ts create mode 100644 packages/crm-service/src/trade-events/dto/update-trade-event.dto.ts create mode 100644 packages/crm-service/src/trade-events/trade-events.controller.ts create mode 100644 packages/crm-service/src/trade-events/trade-events.module.ts create mode 100644 packages/crm-service/src/trade-events/trade-events.service.ts create mode 100644 packages/frontend/src/admin/AdminEventsPage.module.css create mode 100644 packages/frontend/src/admin/AdminEventsPage.tsx create mode 100644 packages/frontend/src/components/EventCountdownTiles.module.css create mode 100644 packages/frontend/src/components/EventCountdownTiles.tsx create mode 100644 packages/frontend/src/hooks/useEventCountdown.ts diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma index c010913..f0feb01 100644 --- a/packages/crm-service/prisma/crm.schema.prisma +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -506,3 +506,34 @@ model DealVoucher { @@map("deal_vouchers") @@schema("app_crm") } + +// -------------------------------------------------------- +// TradeEvent - Messe-/Event-Timer (admin-konfigurierbar) +// -------------------------------------------------------- +model TradeEvent { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + name String @db.VarChar(200) + description String? @db.Text + startDate DateTime @map("start_date") + endDate DateTime @map("end_date") + location String? @db.VarChar(300) + boothInfo String? @map("booth_info") @db.VarChar(200) + websiteUrl String? @map("website_url") @db.VarChar(500) + isActive Boolean @default(true) @map("is_active") + sortOrder Int @default(0) @map("sort_order") + + // Audit-Trail + createdBy String @map("created_by") @db.Uuid + updatedBy String? @map("updated_by") @db.Uuid + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([tenantId, name]) + @@index([tenantId]) + @@index([tenantId, isActive]) + @@index([tenantId, startDate]) + @@map("trade_events") + @@schema("app_crm") +} diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts index eb6ab26..3b912c1 100644 --- a/packages/crm-service/src/app.module.ts +++ b/packages/crm-service/src/app.module.ts @@ -19,6 +19,7 @@ import { IndustriesModule } from './industries/industries.module'; import { AccountTypesModule } from './account-types/account-types.module'; import { RelationshipTypesModule } from './relationship-types/relationship-types.module'; import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module'; +import { TradeEventsModule } from './trade-events/trade-events.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { CompanyRelationshipsModule } from './company-relationships/company-rela AccountTypesModule, RelationshipTypesModule, CompanyRelationshipsModule, + TradeEventsModule, ], providers: [ { diff --git a/packages/crm-service/src/trade-events/dto/create-trade-event.dto.ts b/packages/crm-service/src/trade-events/dto/create-trade-event.dto.ts new file mode 100644 index 0000000..a178695 --- /dev/null +++ b/packages/crm-service/src/trade-events/dto/create-trade-event.dto.ts @@ -0,0 +1,64 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsInt, + IsDateString, + IsUrl, + MaxLength, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateTradeEventDto { + @ApiProperty({ maxLength: 200, description: 'Name der Messe / Veranstaltung' }) + @IsString() + @MaxLength(200) + name!: string; + + @ApiPropertyOptional({ description: 'Beschreibung' }) + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @ApiProperty({ description: 'Startdatum (ISO 8601)' }) + @IsDateString() + startDate!: string; + + @ApiProperty({ description: 'Enddatum (ISO 8601)' }) + @IsDateString() + endDate!: string; + + @ApiPropertyOptional({ maxLength: 300, description: 'Standort / Stadt' }) + @IsOptional() + @IsString() + @MaxLength(300) + location?: string; + + @ApiPropertyOptional({ + maxLength: 200, + description: 'Halle / Stand-Informationen', + }) + @IsOptional() + @IsString() + @MaxLength(200) + boothInfo?: string; + + @ApiPropertyOptional({ maxLength: 500, description: 'Website-URL' }) + @IsOptional() + @IsUrl({}, { message: 'websiteUrl muss eine gueltige URL sein' }) + @MaxLength(500) + websiteUrl?: string; + + @ApiPropertyOptional({ default: true, description: 'Auf Dashboard anzeigen' }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 0, description: 'Sortierreihenfolge' }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/trade-events/dto/update-trade-event.dto.ts b/packages/crm-service/src/trade-events/dto/update-trade-event.dto.ts new file mode 100644 index 0000000..dc32441 --- /dev/null +++ b/packages/crm-service/src/trade-events/dto/update-trade-event.dto.ts @@ -0,0 +1,64 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsInt, + IsDateString, + IsUrl, + MaxLength, + Min, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateTradeEventDto { + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @ApiPropertyOptional({ description: 'Startdatum (ISO 8601)' }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ description: 'Enddatum (ISO 8601)' }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ maxLength: 300 }) + @IsOptional() + @IsString() + @MaxLength(300) + location?: string; + + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + @MaxLength(200) + boothInfo?: string; + + @ApiPropertyOptional({ maxLength: 500 }) + @IsOptional() + @IsUrl({}, { message: 'websiteUrl muss eine gueltige URL sein' }) + @MaxLength(500) + websiteUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/packages/crm-service/src/trade-events/trade-events.controller.ts b/packages/crm-service/src/trade-events/trade-events.controller.ts new file mode 100644 index 0000000..d0d133e --- /dev/null +++ b/packages/crm-service/src/trade-events/trade-events.controller.ts @@ -0,0 +1,108 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { TradeEventsService } from './trade-events.service'; +import { CreateTradeEventDto } from './dto/create-trade-event.dto'; +import { UpdateTradeEventDto } from './dto/update-trade-event.dto'; +import { CurrentUser, JwtPayload } from '../common/decorators'; +import { TenantGuard } from '../auth/guards/tenant.guard'; +import { singleResponse } from '../common/dto/pagination.dto'; + +@ApiTags('TradeEvents') +@ApiBearerAuth('access-token') +@UseGuards(TenantGuard) +@Controller('trade-events') +export class TradeEventsController { + constructor(private readonly tradeEventsService: TradeEventsService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Messe-Event erstellen' }) + async create( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateTradeEventDto, + ) { + const event = await this.tradeEventsService.create( + user.tenantId!, + user.sub, + dto, + ); + return singleResponse(event); + } + + @Get() + @ApiOperation({ summary: 'Alle Messe-Events auflisten' }) + async findAll(@CurrentUser() user: JwtPayload) { + const events = await this.tradeEventsService.findAll(user.tenantId!); + return { data: events }; + } + + // WICHTIG: /active muss VOR /:id stehen, sonst wird "active" als UUID geparst + @Get('active') + @ApiOperation({ summary: 'Aktive Messe-Events fuer Dashboard' }) + async findActive(@CurrentUser() user: JwtPayload) { + const events = await this.tradeEventsService.findActive(user.tenantId!); + return { data: events }; + } + + @Get(':id') + @ApiOperation({ summary: 'Messe-Event abrufen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async findOne( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const event = await this.tradeEventsService.findOne( + user.tenantId!, + id, + ); + return singleResponse(event); + } + + @Patch(':id') + @ApiOperation({ summary: 'Messe-Event aktualisieren' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async update( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTradeEventDto, + ) { + const event = await this.tradeEventsService.update( + user.tenantId!, + id, + user.sub, + dto, + ); + return singleResponse(event); + } + + @Delete(':id') + @ApiOperation({ summary: 'Messe-Event loeschen' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async remove( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + ) { + const event = await this.tradeEventsService.remove( + user.tenantId!, + id, + ); + return singleResponse(event); + } +} diff --git a/packages/crm-service/src/trade-events/trade-events.module.ts b/packages/crm-service/src/trade-events/trade-events.module.ts new file mode 100644 index 0000000..afde180 --- /dev/null +++ b/packages/crm-service/src/trade-events/trade-events.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TradeEventsController } from './trade-events.controller'; +import { TradeEventsService } from './trade-events.service'; + +@Module({ + controllers: [TradeEventsController], + providers: [TradeEventsService], + exports: [TradeEventsService], +}) +export class TradeEventsModule {} diff --git a/packages/crm-service/src/trade-events/trade-events.service.ts b/packages/crm-service/src/trade-events/trade-events.service.ts new file mode 100644 index 0000000..f43b65c --- /dev/null +++ b/packages/crm-service/src/trade-events/trade-events.service.ts @@ -0,0 +1,124 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { CrmPrismaService } from '../prisma/crm-prisma.service'; +import { CreateTradeEventDto } from './dto/create-trade-event.dto'; +import { UpdateTradeEventDto } from './dto/update-trade-event.dto'; + +@Injectable() +export class TradeEventsService { + constructor(private readonly prisma: CrmPrismaService) {} + + async create(tenantId: string, userId: string, dto: CreateTradeEventDto) { + // Datum-Validierung: endDate >= startDate + const start = new Date(dto.startDate); + const end = new Date(dto.endDate); + if (end < start) { + throw new BadRequestException( + 'Enddatum darf nicht vor dem Startdatum liegen', + ); + } + + // Pruefen ob Name bereits existiert + const existing = await this.prisma.tradeEvent.findUnique({ + where: { tenantId_name: { tenantId, name: dto.name } }, + }); + if (existing) { + throw new ConflictException( + `Event "${dto.name}" existiert bereits`, + ); + } + + return this.prisma.tradeEvent.create({ + data: { + tenantId, + name: dto.name, + description: dto.description, + startDate: start, + endDate: end, + location: dto.location, + boothInfo: dto.boothInfo, + websiteUrl: dto.websiteUrl, + isActive: dto.isActive ?? true, + sortOrder: dto.sortOrder ?? 0, + createdBy: userId, + }, + }); + } + + async findAll(tenantId: string) { + return this.prisma.tradeEvent.findMany({ + where: { tenantId }, + orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }], + }); + } + + async findActive(tenantId: string) { + return this.prisma.tradeEvent.findMany({ + where: { tenantId, isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }], + }); + } + + async findOne(tenantId: string, id: string) { + const event = await this.prisma.tradeEvent.findFirst({ + where: { id, tenantId }, + }); + + if (!event) { + throw new NotFoundException('Event nicht gefunden'); + } + + return event; + } + + async update( + tenantId: string, + id: string, + userId: string, + dto: UpdateTradeEventDto, + ) { + const existing = await this.findOne(tenantId, id); + + // Datum-Validierung wenn Daten geaendert werden + const start = dto.startDate + ? new Date(dto.startDate) + : existing.startDate; + const end = dto.endDate ? new Date(dto.endDate) : existing.endDate; + if (end < start) { + throw new BadRequestException( + 'Enddatum darf nicht vor dem Startdatum liegen', + ); + } + + // Name-Uniqueness pruefen falls Name geaendert wird + if (dto.name && dto.name !== existing.name) { + const duplicate = await this.prisma.tradeEvent.findFirst({ + where: { tenantId, name: dto.name, NOT: { id } }, + }); + if (duplicate) { + throw new ConflictException( + `Event "${dto.name}" existiert bereits`, + ); + } + } + + return this.prisma.tradeEvent.update({ + where: { id }, + data: { + ...dto, + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + updatedBy: userId, + }, + }); + } + + async remove(tenantId: string, id: string) { + await this.findOne(tenantId, id); + return this.prisma.tradeEvent.delete({ where: { id } }); + } +} diff --git a/packages/frontend/src/admin/AdminEventsPage.module.css b/packages/frontend/src/admin/AdminEventsPage.module.css new file mode 100644 index 0000000..53164db --- /dev/null +++ b/packages/frontend/src/admin/AdminEventsPage.module.css @@ -0,0 +1,447 @@ +/* ============================================================ + AdminEventsPage – Messe-Events CRUD + ============================================================ */ + +/* Page Header */ +.pageHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.5rem; +} + +.pageTitle { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text); + margin: 0; +} + +.pageSubtitle { + font-size: 0.875rem; + color: var(--color-text-secondary); + margin-top: 0.25rem; +} + +/* Buttons */ +.btnPrimary { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1.25rem; + background: var(--color-primary); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.btnPrimary:hover { + opacity: 0.9; +} + +.btnPrimary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btnSecondary { + padding: 0.5rem 1rem; + background: none; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + cursor: pointer; +} + +.btnSecondary:hover { + background: var(--color-bg-hover); +} + +.btnDanger { + padding: 0.5rem 1rem; + background: var(--color-error); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; +} + +.btnDanger:hover { + opacity: 0.9; +} + +.btnDanger:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Table Card */ +.tableCard { + background: var(--color-bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border); + overflow: hidden; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.table th { + text-align: left; + padding: 0.625rem 0.75rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); + background: var(--color-bg-subtle); + border-bottom: 1px solid var(--color-border); +} + +.table td { + padding: 0.625rem 0.75rem; + border-bottom: 1px solid var(--color-border-light); + vertical-align: middle; +} + +.table tbody tr:last-child td { + border-bottom: none; +} + +.table tbody tr:hover { + background: var(--color-bg-hover); +} + +/* Name Cell */ +.nameCell { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.eventName { + font-weight: 600; + color: var(--color-text); +} + +.websiteLink { + font-size: 0.6875rem; + color: var(--color-primary); + text-decoration: none; +} + +.websiteLink:hover { + text-decoration: underline; +} + +/* Date Cell */ +.dateCell { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* Location/Booth */ +.locationText { + display: block; + color: var(--color-text); +} + +.boothText { + display: block; + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +/* Status Badges */ +.statusBadge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + white-space: nowrap; +} + +.status_upcoming { + background: #dbeafe; + color: #1e40af; +} + +.status_ongoing { + background: #dcfce7; + color: #166534; +} + +.status_ended { + background: var(--color-bg-subtle); + color: var(--color-text-muted); +} + +/* Active/Inactive Badge */ +.activeBadge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; +} + +.activeYes { + background: #dcfce7; + color: #166534; +} + +.activeNo { + background: var(--color-bg-subtle); + color: var(--color-text-muted); +} + +/* Action Buttons */ +.actionBtns { + display: flex; + gap: 0.25rem; +} + +.iconBtn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--color-text-secondary); +} + +.iconBtn:hover { + background: var(--color-bg-hover); + color: var(--color-text); +} + +.iconBtnDanger:hover { + background: #fef2f2; + color: var(--color-error); + border-color: var(--color-error); +} + +/* Empty State */ +.emptyState { + text-align: center; + padding: 3rem 1.5rem; + color: var(--color-text-muted); +} + +.emptyState p { + margin: 0.75rem 0 1.25rem; + font-size: 0.875rem; +} + +/* Loading */ +.loadingText { + color: var(--color-text-secondary); + font-size: 0.875rem; + text-align: center; + padding: 2rem; +} + +/* ============================================================ + Modal + ============================================================ */ + +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal { + background: var(--color-bg-card); + border-radius: var(--radius-md); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 100%; + max-width: 560px; + max-height: 90vh; + overflow-y: auto; +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.modalTitle { + font-size: 1.125rem; + font-weight: 600; + margin: 0; +} + +.closeBtn { + background: none; + border: none; + cursor: pointer; + color: var(--color-text-muted); + padding: 0.25rem; +} + +.closeBtn:hover { + color: var(--color-text); +} + +/* Form */ +.form { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.formError { + padding: 0.625rem 0.75rem; + background: #fef2f2; + color: var(--color-error); + border: 1px solid #fecaca; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 500; +} + +.fieldGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.fieldRow { + display: flex; + gap: 1rem; +} + +.label { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.input { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-card); + color: var(--color-text); + width: 100%; +} + +.input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.textarea { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-card); + color: var(--color-text); + width: 100%; + resize: vertical; + font-family: inherit; +} + +.textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.toggleLabel { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--color-text); + cursor: pointer; + margin-top: 1.25rem; +} + +.toggleLabel input[type='checkbox'] { + width: 16px; + height: 16px; + accent-color: var(--color-primary); +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border); + margin-top: 0.5rem; +} + +/* Delete confirmation */ +.deleteText { + padding: 1rem 1.5rem; + font-size: 0.875rem; + color: var(--color-text-secondary); + line-height: 1.5; +} + +.deleteText strong { + color: var(--color-text); +} + +/* Dark Mode Overrides */ +:global([data-theme='dark']) .status_upcoming { + background: rgba(59, 130, 246, 0.15); + color: #93c5fd; +} + +:global([data-theme='dark']) .status_ongoing { + background: rgba(34, 197, 94, 0.15); + color: #86efac; +} + +:global([data-theme='dark']) .activeYes { + background: rgba(34, 197, 94, 0.15); + color: #86efac; +} + +:global([data-theme='dark']) .formError { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); +} + +:global([data-theme='dark']) .iconBtnDanger:hover { + background: rgba(239, 68, 68, 0.15); +} diff --git a/packages/frontend/src/admin/AdminEventsPage.tsx b/packages/frontend/src/admin/AdminEventsPage.tsx new file mode 100644 index 0000000..c1f6f8f --- /dev/null +++ b/packages/frontend/src/admin/AdminEventsPage.tsx @@ -0,0 +1,476 @@ +import { useState } from 'react'; +import { + useTradeEvents, + useCreateTradeEvent, + useUpdateTradeEvent, + useDeleteTradeEvent, +} from '../crm/hooks'; +import type { TradeEvent, CreateTradeEventPayload, UpdateTradeEventPayload } from '../crm/types'; +import styles from './AdminEventsPage.module.css'; + +// ============================================================ +// Helpers +// ============================================================ + +function formatDateDE(iso: string): string { + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +/** ISO date string → "YYYY-MM-DD" for */ +function toInputDate(iso: string): string { + return iso.slice(0, 10); +} + +function eventStatus(event: TradeEvent): 'upcoming' | 'ongoing' | 'ended' { + const now = new Date(); + const start = new Date(event.startDate); + const end = new Date(event.endDate); + // Set end to end of day + end.setHours(23, 59, 59, 999); + if (now < start) return 'upcoming'; + if (now <= end) return 'ongoing'; + return 'ended'; +} + +// ============================================================ +// Event Form Modal +// ============================================================ + +interface EventFormModalProps { + event?: TradeEvent | null; + onClose: () => void; + onSave: (data: CreateTradeEventPayload | UpdateTradeEventPayload) => void; + isSaving: boolean; +} + +function EventFormModal({ event, onClose, onSave, isSaving }: EventFormModalProps) { + const [name, setName] = useState(event?.name ?? ''); + const [description, setDescription] = useState(event?.description ?? ''); + const [startDate, setStartDate] = useState( + event?.startDate ? toInputDate(event.startDate) : '', + ); + const [endDate, setEndDate] = useState( + event?.endDate ? toInputDate(event.endDate) : '', + ); + const [location, setLocation] = useState(event?.location ?? ''); + const [boothInfo, setBoothInfo] = useState(event?.boothInfo ?? ''); + const [websiteUrl, setWebsiteUrl] = useState(event?.websiteUrl ?? ''); + const [isActive, setIsActive] = useState(event?.isActive ?? true); + const [sortOrder, setSortOrder] = useState(event?.sortOrder ?? 0); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!name.trim()) { + setError('Name ist erforderlich'); + return; + } + if (!startDate || !endDate) { + setError('Start- und Enddatum sind erforderlich'); + return; + } + if (new Date(endDate) < new Date(startDate)) { + setError('Enddatum darf nicht vor dem Startdatum liegen'); + return; + } + + const payload: CreateTradeEventPayload = { + name: name.trim(), + description: description.trim() || undefined, + startDate: new Date(startDate).toISOString(), + endDate: new Date(endDate).toISOString(), + location: location.trim() || undefined, + boothInfo: boothInfo.trim() || undefined, + websiteUrl: websiteUrl.trim() || undefined, + isActive, + sortOrder, + }; + + onSave(payload); + }; + + return ( +
+
e.stopPropagation()}> +
+

+ {event ? 'Event bearbeiten' : 'Neues Event erstellen'} +

+ +
+ +
+ {error &&
{error}
} + +
+ + setName(e.target.value)} + placeholder="z.B. LogiMAT 2026" + maxLength={200} + autoFocus + /> +
+ +
+ +