mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56: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")
|
||||
@@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 { 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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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/external-links', label: 'Externe Links' },
|
||||
{ to: '/admin/customize', label: 'Anpassungen' },
|
||||
{ to: '/admin/events', label: 'Events' },
|
||||
];
|
||||
|
||||
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,
|
||||
LexwareVouchersQueryParams,
|
||||
DealVoucher,
|
||||
TradeEvent,
|
||||
CreateTradeEventPayload,
|
||||
UpdateTradeEventPayload,
|
||||
PaginatedResponse,
|
||||
SingleResponse,
|
||||
} from './types';
|
||||
|
|
@ -457,3 +460,37 @@ export const lexwareVouchersApi = {
|
|||
)
|
||||
.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,
|
||||
lexwareContactsApi,
|
||||
lexwareVouchersApi,
|
||||
tradeEventsApi,
|
||||
} from './api';
|
||||
import type {
|
||||
ContactsQueryParams,
|
||||
|
|
@ -43,6 +44,8 @@ import type {
|
|||
CreateCompanyRelationshipPayload,
|
||||
LexwareContactSearchParams,
|
||||
LexwareVouchersQueryParams,
|
||||
CreateTradeEventPayload,
|
||||
UpdateTradeEventPayload,
|
||||
} from './types';
|
||||
|
||||
// --- Query Key Factory ---
|
||||
|
|
@ -100,6 +103,12 @@ export const crmKeys = {
|
|||
all: ['crm', 'users'] 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: {
|
||||
all: ['crm', 'lexware'] as const,
|
||||
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;
|
||||
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 { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
|
||||
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
||||
import { AdminEventsPage } from '../admin/AdminEventsPage';
|
||||
import { ProfilePage } from '../profile/ProfilePage';
|
||||
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
||||
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
||||
|
|
@ -78,6 +79,7 @@ export function App() {
|
|||
<Route path="sso" element={<AdminSsoPage />} />
|
||||
<Route path="external-links" element={<AdminExternalLinksPage />} />
|
||||
<Route path="customize" element={<AdminCustomizePage />} />
|
||||
<Route path="events" element={<AdminEventsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useAuth } from '../auth/AuthContext';
|
||||
import { WeatherWidget } from '../components/WeatherWidget';
|
||||
import { EventCountdownTiles } from '../components/EventCountdownTiles';
|
||||
import styles from './DashboardPage.module.css';
|
||||
|
||||
export function DashboardPage() {
|
||||
|
|
@ -15,6 +16,9 @@ export function DashboardPage() {
|
|||
<WeatherWidget city={user?.city} />
|
||||
</div>
|
||||
|
||||
{/* Messe-Countdown-Kacheln */}
|
||||
<EventCountdownTiles />
|
||||
|
||||
<div style={{
|
||||
background: 'var(--color-bg-card)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue