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:
Thomas Reitz 2026-03-12 13:33:19 +01:00
parent 923e6bc127
commit a85634a906
18 changed files with 1916 additions and 0 deletions

View file

@ -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")
}

View file

@ -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: [
{

View file

@ -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;
}

View file

@ -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;
}

View 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);
}
}

View 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 {}

View 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 } });
}
}

View 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);
}

View 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>
);
}

View file

@ -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() {

View 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;
}
}

View 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>
);
}

View file

@ -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),
};

View file

@ -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 });
},
});
}

View file

@ -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;
}

View 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]);
}

View file

@ -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>

View file

@ -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)',