mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26:40 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
923e6bc127
commit
a85634a906
18 changed files with 1916 additions and 0 deletions
|
|
@ -506,3 +506,34 @@ model DealVoucher {
|
||||||
@@map("deal_vouchers")
|
@@map("deal_vouchers")
|
||||||
@@schema("app_crm")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { IndustriesModule } from './industries/industries.module';
|
||||||
import { AccountTypesModule } from './account-types/account-types.module';
|
import { AccountTypesModule } from './account-types/account-types.module';
|
||||||
import { RelationshipTypesModule } from './relationship-types/relationship-types.module';
|
import { RelationshipTypesModule } from './relationship-types/relationship-types.module';
|
||||||
import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module';
|
import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module';
|
||||||
|
import { TradeEventsModule } from './trade-events/trade-events.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -41,6 +42,7 @@ import { CompanyRelationshipsModule } from './company-relationships/company-rela
|
||||||
AccountTypesModule,
|
AccountTypesModule,
|
||||||
RelationshipTypesModule,
|
RelationshipTypesModule,
|
||||||
CompanyRelationshipsModule,
|
CompanyRelationshipsModule,
|
||||||
|
TradeEventsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
108
packages/crm-service/src/trade-events/trade-events.controller.ts
Normal file
108
packages/crm-service/src/trade-events/trade-events.controller.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/crm-service/src/trade-events/trade-events.module.ts
Normal file
10
packages/crm-service/src/trade-events/trade-events.module.ts
Normal file
|
|
@ -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 {}
|
||||||
124
packages/crm-service/src/trade-events/trade-events.service.ts
Normal file
124
packages/crm-service/src/trade-events/trade-events.service.ts
Normal file
|
|
@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
447
packages/frontend/src/admin/AdminEventsPage.module.css
Normal file
447
packages/frontend/src/admin/AdminEventsPage.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
476
packages/frontend/src/admin/AdminEventsPage.tsx
Normal file
476
packages/frontend/src/admin/AdminEventsPage.tsx
Normal file
|
|
@ -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 <input type="date"> */
|
||||||
|
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 (
|
||||||
|
<div className={styles.overlay} onClick={onClose}>
|
||||||
|
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h2 className={styles.modalTitle}>
|
||||||
|
{event ? 'Event bearbeiten' : 'Neues Event erstellen'}
|
||||||
|
</h2>
|
||||||
|
<button className={styles.closeBtn} onClick={onClose}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M4 4l8 8M12 4l-8 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
{error && <div className={styles.formError}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label}>Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="z.B. LogiMAT 2026"
|
||||||
|
maxLength={200}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label}>Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className={styles.textarea}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Optionale Beschreibung..."
|
||||||
|
rows={2}
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label}>Startdatum *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={styles.input}
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label}>Enddatum *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={styles.input}
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label}>Standort</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
placeholder="z.B. Stuttgart"
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label}>Halle / Stand</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={boothInfo}
|
||||||
|
onChange={(e) => setBoothInfo(e.target.value)}
|
||||||
|
placeholder="z.B. Halle 7, Stand B42"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label}>Website</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className={styles.input}
|
||||||
|
value={websiteUrl}
|
||||||
|
onChange={(e) => setWebsiteUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.label}>Sortierung</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(parseInt(e.target.value, 10) || 0)}
|
||||||
|
min={0}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<label className={styles.toggleLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Auf Dashboard anzeigen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button type="button" className={styles.btnSecondary} onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.btnPrimary} disabled={isSaving}>
|
||||||
|
{isSaving ? 'Speichern...' : event ? 'Aktualisieren' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Delete Confirmation Modal
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function DeleteConfirmModal({
|
||||||
|
eventName,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
eventName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={styles.modal}
|
||||||
|
style={{ maxWidth: 420 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h2 className={styles.modalTitle}>Event loeschen</h2>
|
||||||
|
<button className={styles.closeBtn} onClick={onClose}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M4 4l8 8M12 4l-8 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className={styles.deleteText}>
|
||||||
|
Soll das Event <strong>{eventName}</strong> wirklich geloescht werden?
|
||||||
|
Diese Aktion kann nicht rueckgaengig gemacht werden.
|
||||||
|
</p>
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button className={styles.btnSecondary} onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.btnDanger}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Loeschen...' : 'Endgueltig loeschen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AdminEventsPage — Main Component
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function AdminEventsPage() {
|
||||||
|
const { data, isLoading } = useTradeEvents();
|
||||||
|
const createEvent = useCreateTradeEvent();
|
||||||
|
const updateEvent = useUpdateTradeEvent();
|
||||||
|
const deleteEvent = useDeleteTradeEvent();
|
||||||
|
|
||||||
|
const [editingEvent, setEditingEvent] = useState<TradeEvent | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [deletingEvent, setDeletingEvent] = useState<TradeEvent | null>(null);
|
||||||
|
|
||||||
|
const events: TradeEvent[] = data?.data ?? [];
|
||||||
|
|
||||||
|
const handleCreate = (payload: CreateTradeEventPayload | UpdateTradeEventPayload) => {
|
||||||
|
createEvent.mutate(payload as CreateTradeEventPayload, {
|
||||||
|
onSuccess: () => setIsCreating(false),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = (payload: CreateTradeEventPayload | UpdateTradeEventPayload) => {
|
||||||
|
if (!editingEvent) return;
|
||||||
|
updateEvent.mutate(
|
||||||
|
{ id: editingEvent.id, data: payload as UpdateTradeEventPayload },
|
||||||
|
{ onSuccess: () => setEditingEvent(null) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!deletingEvent) return;
|
||||||
|
deleteEvent.mutate(deletingEvent.id, {
|
||||||
|
onSuccess: () => setDeletingEvent(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.pageTitle}>Messe-Events</h1>
|
||||||
|
<p className={styles.pageSubtitle}>
|
||||||
|
Verwalten Sie Messen und Veranstaltungen. Aktive Events werden als
|
||||||
|
Countdown-Kacheln auf dem Dashboard angezeigt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className={styles.btnPrimary} onClick={() => setIsCreating(true)}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M8 3v10M3 8h10" />
|
||||||
|
</svg>
|
||||||
|
Neues Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className={styles.loadingText}>Events werden geladen...</p>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<svg width="40" height="40" viewBox="0 0 16 16" fill="none" stroke="var(--color-text-muted)" strokeWidth="1" strokeLinecap="round">
|
||||||
|
<rect x="2" y="3" width="12" height="11" rx="1" />
|
||||||
|
<path d="M5 1v4M11 1v4M2 7h12" />
|
||||||
|
</svg>
|
||||||
|
<p>Noch keine Events vorhanden</p>
|
||||||
|
<button className={styles.btnPrimary} onClick={() => setIsCreating(true)}>
|
||||||
|
Erstes Event erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.tableCard}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Zeitraum</th>
|
||||||
|
<th>Ort / Stand</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Dashboard</th>
|
||||||
|
<th style={{ width: 100 }}>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map((ev) => {
|
||||||
|
const status = eventStatus(ev);
|
||||||
|
return (
|
||||||
|
<tr key={ev.id}>
|
||||||
|
<td className={styles.nameCell}>
|
||||||
|
<span className={styles.eventName}>{ev.name}</span>
|
||||||
|
{ev.websiteUrl && (
|
||||||
|
<a
|
||||||
|
href={ev.websiteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.websiteLink}
|
||||||
|
>
|
||||||
|
Website
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={styles.dateCell}>
|
||||||
|
{formatDateDE(ev.startDate)} – {formatDateDE(ev.endDate)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{ev.location && (
|
||||||
|
<span className={styles.locationText}>{ev.location}</span>
|
||||||
|
)}
|
||||||
|
{ev.boothInfo && (
|
||||||
|
<span className={styles.boothText}>{ev.boothInfo}</span>
|
||||||
|
)}
|
||||||
|
{!ev.location && !ev.boothInfo && '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`${styles.statusBadge} ${styles[`status_${status}`]}`}>
|
||||||
|
{status === 'upcoming'
|
||||||
|
? 'Bevorstehend'
|
||||||
|
: status === 'ongoing'
|
||||||
|
? 'Laeuft'
|
||||||
|
: 'Beendet'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`${styles.activeBadge} ${
|
||||||
|
ev.isActive ? styles.activeYes : styles.activeNo
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ev.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.actionBtns}>
|
||||||
|
<button
|
||||||
|
className={styles.iconBtn}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => setEditingEvent(ev)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||||
|
title="Loeschen"
|
||||||
|
onClick={() => setDeletingEvent(ev)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 4h10M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M6 7v5M10 7v5M4 4l1 9a1 1 0 001 1h4a1 1 0 001-1l1-9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{isCreating && (
|
||||||
|
<EventFormModal
|
||||||
|
onClose={() => setIsCreating(false)}
|
||||||
|
onSave={handleCreate}
|
||||||
|
isSaving={createEvent.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingEvent && (
|
||||||
|
<EventFormModal
|
||||||
|
event={editingEvent}
|
||||||
|
onClose={() => setEditingEvent(null)}
|
||||||
|
onSave={handleUpdate}
|
||||||
|
isSaving={updateEvent.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deletingEvent && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
eventName={deletingEvent.name}
|
||||||
|
onClose={() => setDeletingEvent(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
isDeleting={deleteEvent.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ const tabs = [
|
||||||
{ to: '/admin/sso', label: 'SSO-Konfiguration' },
|
{ to: '/admin/sso', label: 'SSO-Konfiguration' },
|
||||||
{ to: '/admin/external-links', label: 'Externe Links' },
|
{ to: '/admin/external-links', label: 'Externe Links' },
|
||||||
{ to: '/admin/customize', label: 'Anpassungen' },
|
{ to: '/admin/customize', label: 'Anpassungen' },
|
||||||
|
{ to: '/admin/events', label: 'Events' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
|
|
|
||||||
153
packages/frontend/src/components/EventCountdownTiles.module.css
Normal file
153
packages/frontend/src/components/EventCountdownTiles.module.css
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/* ============================================================
|
||||||
|
EventCountdownTiles — Dashboard Messe-Timer Kacheln
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Single Tile */
|
||||||
|
.tile {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tile Header */
|
||||||
|
.tileHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileName {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Chip */
|
||||||
|
.statusChip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip_upcoming {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip_ongoing {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip_ended {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown Row */
|
||||||
|
.countdownRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownLabel {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownLabelMuted {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progressBar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressFill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta Info */
|
||||||
|
.tileMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaItem svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Website Link */
|
||||||
|
.websiteLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.websiteLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode */
|
||||||
|
:global([data-theme='dark']) .chip_upcoming {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .chip_ongoing {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
packages/frontend/src/components/EventCountdownTiles.tsx
Normal file
186
packages/frontend/src/components/EventCountdownTiles.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { useActiveTradeEvents } from '../crm/hooks';
|
||||||
|
import { useEventCountdown, type EventCountdown } from '../hooks/useEventCountdown';
|
||||||
|
import type { TradeEvent } from '../crm/types';
|
||||||
|
import styles from './EventCountdownTiles.module.css';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function formatDateRange(start: string, end: string): string {
|
||||||
|
const s = new Date(start).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
const e = new Date(end).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
return `${s} – ${e}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide events that ended more than 24h ago */
|
||||||
|
function isStillRelevant(event: TradeEvent): boolean {
|
||||||
|
const endDay = new Date(event.endDate);
|
||||||
|
endDay.setHours(23, 59, 59, 999);
|
||||||
|
const cutoff = new Date(endDay.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
return new Date() < cutoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Single Tile
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function CountdownTile({ event }: { event: TradeEvent }) {
|
||||||
|
const countdown: EventCountdown = useEventCountdown(event);
|
||||||
|
|
||||||
|
const borderColor =
|
||||||
|
countdown.status === 'upcoming'
|
||||||
|
? '#3b82f6'
|
||||||
|
: countdown.status === 'ongoing'
|
||||||
|
? '#22c55e'
|
||||||
|
: '#9ca3af';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tile} style={{ borderLeftColor: borderColor }}>
|
||||||
|
<div className={styles.tileHeader}>
|
||||||
|
<h3 className={styles.tileName}>{event.name}</h3>
|
||||||
|
<span
|
||||||
|
className={`${styles.statusChip} ${styles[`chip_${countdown.status}`]}`}
|
||||||
|
>
|
||||||
|
{countdown.status === 'upcoming'
|
||||||
|
? 'Bevorstehend'
|
||||||
|
: countdown.status === 'ongoing'
|
||||||
|
? 'Laeuft'
|
||||||
|
: 'Beendet'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown / Progress */}
|
||||||
|
<div className={styles.countdownRow}>
|
||||||
|
{countdown.status === 'upcoming' && (
|
||||||
|
<span className={styles.countdownLabel}>{countdown.label}</span>
|
||||||
|
)}
|
||||||
|
{countdown.status === 'ongoing' && (
|
||||||
|
<>
|
||||||
|
<span className={styles.countdownLabel}>
|
||||||
|
{countdown.label} — laeuft!
|
||||||
|
</span>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
className={styles.progressFill}
|
||||||
|
style={{ width: `${countdown.progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{countdown.status === 'ended' && (
|
||||||
|
<span className={styles.countdownLabelMuted}>Beendet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta Info */}
|
||||||
|
<div className={styles.tileMeta}>
|
||||||
|
<div className={styles.metaItem}>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="3" width="12" height="11" rx="1" />
|
||||||
|
<path d="M5 1v4M11 1v4M2 7h12" />
|
||||||
|
</svg>
|
||||||
|
<span>{formatDateRange(event.startDate, event.endDate)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.location && (
|
||||||
|
<div className={styles.metaItem}>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 1C5.2 1 3 3.2 3 6c0 4 5 9 5 9s5-5 5-9c0-2.8-2.2-5-5-5z" />
|
||||||
|
<circle cx="8" cy="6" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{event.location}
|
||||||
|
{event.boothInfo && ` · ${event.boothInfo}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!event.location && event.boothInfo && (
|
||||||
|
<div className={styles.metaItem}>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<rect x="1" y="4" width="14" height="10" rx="1" />
|
||||||
|
<path d="M1 7h14" />
|
||||||
|
</svg>
|
||||||
|
<span>{event.boothInfo}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.websiteUrl && (
|
||||||
|
<a
|
||||||
|
href={event.websiteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.websiteLink}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M6 3H3a1 1 0 00-1 1v9a1 1 0 001 1h9a1 1 0 001-1v-3M10 2h4v4M7 9l7-7" />
|
||||||
|
</svg>
|
||||||
|
Website
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// EventCountdownTiles — Grid
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function EventCountdownTiles() {
|
||||||
|
const { data, isLoading } = useActiveTradeEvents();
|
||||||
|
const events = (data?.data ?? []).filter(isStillRelevant);
|
||||||
|
|
||||||
|
if (isLoading || events.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<CountdownTile key={ev.id} event={ev} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,9 @@ import type {
|
||||||
LexwareVoucher,
|
LexwareVoucher,
|
||||||
LexwareVouchersQueryParams,
|
LexwareVouchersQueryParams,
|
||||||
DealVoucher,
|
DealVoucher,
|
||||||
|
TradeEvent,
|
||||||
|
CreateTradeEventPayload,
|
||||||
|
UpdateTradeEventPayload,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SingleResponse,
|
SingleResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
@ -457,3 +460,37 @@ export const lexwareVouchersApi = {
|
||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Trade Events (Messe-Timer) ---
|
||||||
|
|
||||||
|
export const tradeEventsApi = {
|
||||||
|
list: () =>
|
||||||
|
api
|
||||||
|
.get<{ data: TradeEvent[] }>('/crm/trade-events')
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
listActive: () =>
|
||||||
|
api
|
||||||
|
.get<{ data: TradeEvent[] }>('/crm/trade-events/active')
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
api
|
||||||
|
.get<SingleResponse<TradeEvent>>(`/crm/trade-events/${id}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: CreateTradeEventPayload) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<TradeEvent>>('/crm/trade-events', data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateTradeEventPayload) =>
|
||||||
|
api
|
||||||
|
.patch<SingleResponse<TradeEvent>>(`/crm/trade-events/${id}`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<TradeEvent>>(`/crm/trade-events/${id}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
usersApi,
|
usersApi,
|
||||||
lexwareContactsApi,
|
lexwareContactsApi,
|
||||||
lexwareVouchersApi,
|
lexwareVouchersApi,
|
||||||
|
tradeEventsApi,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type {
|
import type {
|
||||||
ContactsQueryParams,
|
ContactsQueryParams,
|
||||||
|
|
@ -43,6 +44,8 @@ import type {
|
||||||
CreateCompanyRelationshipPayload,
|
CreateCompanyRelationshipPayload,
|
||||||
LexwareContactSearchParams,
|
LexwareContactSearchParams,
|
||||||
LexwareVouchersQueryParams,
|
LexwareVouchersQueryParams,
|
||||||
|
CreateTradeEventPayload,
|
||||||
|
UpdateTradeEventPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Query Key Factory ---
|
// --- Query Key Factory ---
|
||||||
|
|
@ -100,6 +103,12 @@ export const crmKeys = {
|
||||||
all: ['crm', 'users'] as const,
|
all: ['crm', 'users'] as const,
|
||||||
list: () => ['crm', 'users', 'list'] as const,
|
list: () => ['crm', 'users', 'list'] as const,
|
||||||
},
|
},
|
||||||
|
tradeEvents: {
|
||||||
|
all: ['crm', 'tradeEvents'] as const,
|
||||||
|
list: () => ['crm', 'tradeEvents', 'list'] as const,
|
||||||
|
active: () => ['crm', 'tradeEvents', 'active'] as const,
|
||||||
|
detail: (id: string) => ['crm', 'tradeEvents', 'detail', id] as const,
|
||||||
|
},
|
||||||
lexware: {
|
lexware: {
|
||||||
all: ['crm', 'lexware'] as const,
|
all: ['crm', 'lexware'] as const,
|
||||||
contactSearch: (params: LexwareContactSearchParams) =>
|
contactSearch: (params: LexwareContactSearchParams) =>
|
||||||
|
|
@ -878,3 +887,53 @@ export function useRefreshContactVouchers() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Trade Events (Messe-Timer)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useTradeEvents() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.tradeEvents.list(),
|
||||||
|
queryFn: () => tradeEventsApi.list(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveTradeEvents() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.tradeEvents.active(),
|
||||||
|
queryFn: () => tradeEventsApi.listActive(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTradeEvent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateTradeEventPayload) =>
|
||||||
|
tradeEventsApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.tradeEvents.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTradeEvent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateTradeEventPayload }) =>
|
||||||
|
tradeEventsApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.tradeEvents.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteTradeEvent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => tradeEventsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.tradeEvents.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -533,3 +533,49 @@ export interface LexwareVouchersQueryParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Trade Events (Messe-Timer)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface TradeEvent {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
location: string | null;
|
||||||
|
boothInfo: string | null;
|
||||||
|
websiteUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTradeEventPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
location?: string;
|
||||||
|
boothInfo?: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTradeEventPayload {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
location?: string;
|
||||||
|
boothInfo?: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
102
packages/frontend/src/hooks/useEventCountdown.ts
Normal file
102
packages/frontend/src/hooks/useEventCountdown.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import type { TradeEvent } from '../crm/types';
|
||||||
|
|
||||||
|
export type EventStatus = 'upcoming' | 'ongoing' | 'ended';
|
||||||
|
|
||||||
|
export interface EventCountdown {
|
||||||
|
status: EventStatus;
|
||||||
|
label: string;
|
||||||
|
daysUntil: number;
|
||||||
|
hoursUntil: number;
|
||||||
|
currentDay: number;
|
||||||
|
totalDays: number;
|
||||||
|
progressPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcCountdown(event: TradeEvent, now: Date): EventCountdown {
|
||||||
|
const start = new Date(event.startDate);
|
||||||
|
const end = new Date(event.endDate);
|
||||||
|
|
||||||
|
// Normalize to start-of-day for day calculations
|
||||||
|
const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate());
|
||||||
|
const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate());
|
||||||
|
endDay.setHours(23, 59, 59, 999);
|
||||||
|
const nowDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
const msPerDay = 86_400_000;
|
||||||
|
const totalDays = Math.round((endDay.getTime() - startDay.getTime()) / msPerDay) + 1;
|
||||||
|
|
||||||
|
if (now < startDay) {
|
||||||
|
// Upcoming
|
||||||
|
const diffMs = startDay.getTime() - now.getTime();
|
||||||
|
const daysUntil = Math.ceil(diffMs / msPerDay);
|
||||||
|
const hoursUntil = Math.ceil(diffMs / 3_600_000);
|
||||||
|
|
||||||
|
let label: string;
|
||||||
|
if (daysUntil > 2) {
|
||||||
|
label = `Noch ${daysUntil} Tage`;
|
||||||
|
} else if (daysUntil === 2) {
|
||||||
|
label = `Noch 2 Tage`;
|
||||||
|
} else if (daysUntil === 1) {
|
||||||
|
label = 'Morgen';
|
||||||
|
} else {
|
||||||
|
label = `Noch ${hoursUntil} Std.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'upcoming',
|
||||||
|
label,
|
||||||
|
daysUntil,
|
||||||
|
hoursUntil,
|
||||||
|
currentDay: 0,
|
||||||
|
totalDays,
|
||||||
|
progressPercent: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now <= endDay) {
|
||||||
|
// Ongoing
|
||||||
|
const currentDay =
|
||||||
|
Math.floor((nowDay.getTime() - startDay.getTime()) / msPerDay) + 1;
|
||||||
|
const progressPercent = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((currentDay / totalDays) * 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ongoing',
|
||||||
|
label: `Tag ${currentDay} von ${totalDays}`,
|
||||||
|
daysUntil: 0,
|
||||||
|
hoursUntil: 0,
|
||||||
|
currentDay,
|
||||||
|
totalDays,
|
||||||
|
progressPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ended
|
||||||
|
return {
|
||||||
|
status: 'ended',
|
||||||
|
label: 'Beendet',
|
||||||
|
daysUntil: 0,
|
||||||
|
hoursUntil: 0,
|
||||||
|
currentDay: totalDays,
|
||||||
|
totalDays,
|
||||||
|
progressPercent: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that computes a live countdown for a trade event.
|
||||||
|
* Updates every 60 seconds.
|
||||||
|
*/
|
||||||
|
export function useEventCountdown(event: TradeEvent): EventCountdown {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(new Date()), 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMemo(() => calcCountdown(event, now), [event, now]);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { AdminTenantsPage } from '../admin/AdminTenantsPage';
|
||||||
import { AdminSsoPage } from '../admin/AdminSsoPage';
|
import { AdminSsoPage } from '../admin/AdminSsoPage';
|
||||||
import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
|
import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
|
||||||
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
||||||
|
import { AdminEventsPage } from '../admin/AdminEventsPage';
|
||||||
import { ProfilePage } from '../profile/ProfilePage';
|
import { ProfilePage } from '../profile/ProfilePage';
|
||||||
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
||||||
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
||||||
|
|
@ -78,6 +79,7 @@ export function App() {
|
||||||
<Route path="sso" element={<AdminSsoPage />} />
|
<Route path="sso" element={<AdminSsoPage />} />
|
||||||
<Route path="external-links" element={<AdminExternalLinksPage />} />
|
<Route path="external-links" element={<AdminExternalLinksPage />} />
|
||||||
<Route path="customize" element={<AdminCustomizePage />} />
|
<Route path="customize" element={<AdminCustomizePage />} />
|
||||||
|
<Route path="events" element={<AdminEventsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { WeatherWidget } from '../components/WeatherWidget';
|
import { WeatherWidget } from '../components/WeatherWidget';
|
||||||
|
import { EventCountdownTiles } from '../components/EventCountdownTiles';
|
||||||
import styles from './DashboardPage.module.css';
|
import styles from './DashboardPage.module.css';
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
|
@ -15,6 +16,9 @@ export function DashboardPage() {
|
||||||
<WeatherWidget city={user?.city} />
|
<WeatherWidget city={user?.city} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Messe-Countdown-Kacheln */}
|
||||||
|
<EventCountdownTiles />
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--color-bg-card)',
|
background: 'var(--color-bg-card)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue