Adds comprehensive mock data repositories and seeding infrastructure
Implements polymorphic data seeding mechanism for initial application setup - Adds Mock repositories for Event, Booking, Customer, and Resource entities - Creates DataSeeder to automatically populate IndexedDB from JSON sources - Enhances index.ts initialization process with data seeding step - Adds mock JSON data files for comprehensive test data Improves offline-first and development testing capabilities
This commit is contained in:
parent
871f5c5682
commit
5648c7c304
11 changed files with 1641 additions and 40 deletions
737
docs/mock-repository-implementation-status.md
Normal file
737
docs/mock-repository-implementation-status.md
Normal file
|
|
@ -0,0 +1,737 @@
|
||||||
|
# Mock Data Repository Implementation - Status Documentation
|
||||||
|
|
||||||
|
**Document Generated:** 2025-11-19
|
||||||
|
**Analysis Scope:** Mock Repository Implementation vs Target Architecture
|
||||||
|
**Files Analyzed:** 4 repositories, 4 type files, 2 architecture docs
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document compares the current Mock Repository implementation against the documented target architecture. The analysis covers 4 entity types: Event, Booking, Customer, and Resource.
|
||||||
|
|
||||||
|
**Overall Status:** Implementation is structurally correct but Event entity is missing critical fields required for the booking architecture.
|
||||||
|
|
||||||
|
**Compliance Score:** 84%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Event Entity Comparison
|
||||||
|
|
||||||
|
### Current RawEventData Interface
|
||||||
|
**Location:** `src/repositories/MockEventRepository.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RawEventData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: string | Date;
|
||||||
|
end: string | Date;
|
||||||
|
type: string;
|
||||||
|
color?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target ICalendarEvent Interface
|
||||||
|
**Location:** `src/types/CalendarTypes.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ICalendarEvent extends ISync {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
type: CalendarEventType;
|
||||||
|
allDay: boolean;
|
||||||
|
|
||||||
|
bookingId?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
customerId?: string;
|
||||||
|
|
||||||
|
recurringId?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documented JSON Format
|
||||||
|
**Source:** `docs/mock-data-migration-guide.md`, `docs/booking-event-architecture.md`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "EVT001",
|
||||||
|
"title": "Balayage langt hår",
|
||||||
|
"start": "2025-08-05T10:00:00",
|
||||||
|
"end": "2025-08-05T11:00:00",
|
||||||
|
"type": "customer",
|
||||||
|
"allDay": false,
|
||||||
|
"syncStatus": "synced",
|
||||||
|
"bookingId": "BOOK001",
|
||||||
|
"resourceId": "EMP001",
|
||||||
|
"customerId": "CUST001",
|
||||||
|
"metadata": { "duration": 60 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field-by-Field Comparison - Event Entity
|
||||||
|
|
||||||
|
| Field | Current RawData | Target Interface | Documented JSON | Status |
|
||||||
|
|-------|----------------|------------------|----------------|--------|
|
||||||
|
| `id` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `title` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `description` | ❌ Missing | ✅ `string?` | ❌ Missing | **MISSING** |
|
||||||
|
| `start` | ✅ `string \| Date` | ✅ `Date` | ✅ Present | **MATCH** |
|
||||||
|
| `end` | ✅ `string \| Date` | ✅ `Date` | ✅ Present | **MATCH** |
|
||||||
|
| `type` | ✅ `string` | ✅ `CalendarEventType` | ✅ `"customer"` | **MATCH** (needs cast) |
|
||||||
|
| `allDay` | ✅ `boolean?` | ✅ `boolean` | ✅ `false` | **MATCH** |
|
||||||
|
| `bookingId` | ❌ Missing | ✅ `string?` | ✅ Present | **CRITICAL MISSING** |
|
||||||
|
| `resourceId` | ❌ Missing | ✅ `string?` | ✅ Present | **CRITICAL MISSING** |
|
||||||
|
| `customerId` | ❌ Missing | ✅ `string?` | ✅ Present | **CRITICAL MISSING** |
|
||||||
|
| `recurringId` | ❌ Missing | ✅ `string?` | ❌ Not in example | **MISSING** |
|
||||||
|
| `metadata` | ✅ Via `[key: string]` | ✅ `Record<string, any>?` | ✅ Present | **MATCH** |
|
||||||
|
| `syncStatus` | ❌ Missing | ✅ `SyncStatus` (via ISync) | ✅ `"synced"` | **MISSING (added in processing)** |
|
||||||
|
| `color` | ✅ `string?` | ❌ Not in interface | ❌ Not documented | **EXTRA (legacy)** |
|
||||||
|
|
||||||
|
### Critical Missing Fields - Event Entity
|
||||||
|
|
||||||
|
#### 1. bookingId (CRITICAL)
|
||||||
|
**Impact:** Cannot link customer events to booking data
|
||||||
|
**Required For:**
|
||||||
|
- Type `'customer'` events MUST have `bookingId`
|
||||||
|
- Loading booking details when event is clicked
|
||||||
|
- Cascading deletes (cancel booking → delete events)
|
||||||
|
- Backend JOIN queries between CalendarEvent and Booking tables
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "EVT001",
|
||||||
|
"type": "customer",
|
||||||
|
"bookingId": "BOOK001", // ← CRITICAL - Links to booking
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. resourceId (CRITICAL)
|
||||||
|
**Impact:** Cannot filter events by resource (calendar columns)
|
||||||
|
**Required For:**
|
||||||
|
- Denormalized query performance (no JOIN needed)
|
||||||
|
- Resource calendar views (week view with resource columns)
|
||||||
|
- Resource utilization analytics
|
||||||
|
- Quick filtering: "Show all events for EMP001"
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "EVT001",
|
||||||
|
"resourceId": "EMP001", // ← CRITICAL - Which stylist
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. customerId (CRITICAL)
|
||||||
|
**Impact:** Cannot query customer events without loading booking
|
||||||
|
**Required For:**
|
||||||
|
- Denormalized query performance
|
||||||
|
- Customer history views
|
||||||
|
- Quick customer lookup: "Show all events for CUST001"
|
||||||
|
- Analytics and reporting
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "EVT001",
|
||||||
|
"type": "customer",
|
||||||
|
"customerId": "CUST001", // ← CRITICAL - Which customer
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. description (OPTIONAL)
|
||||||
|
**Impact:** Cannot add detailed event notes
|
||||||
|
**Required For:**
|
||||||
|
- Event details panel
|
||||||
|
- Additional context beyond title
|
||||||
|
- Notes and instructions
|
||||||
|
|
||||||
|
### Action Items for Events
|
||||||
|
|
||||||
|
1. **Add to RawEventData:**
|
||||||
|
- `description?: string`
|
||||||
|
- `bookingId?: string` (CRITICAL)
|
||||||
|
- `resourceId?: string` (CRITICAL)
|
||||||
|
- `customerId?: string` (CRITICAL)
|
||||||
|
- `recurringId?: string`
|
||||||
|
- `metadata?: Record<string, any>` (make explicit)
|
||||||
|
|
||||||
|
2. **Update Processing:**
|
||||||
|
- Explicitly map all new fields in `processCalendarData()`
|
||||||
|
- Remove or document legacy `color` field
|
||||||
|
- Ensure `allDay` defaults to `false` if missing
|
||||||
|
- Validate that `type: 'customer'` events have `bookingId`
|
||||||
|
|
||||||
|
3. **JSON File Requirements:**
|
||||||
|
- Customer events MUST include `bookingId`, `resourceId`, `customerId`
|
||||||
|
- Vacation/break/meeting events MUST NOT have `bookingId` or `customerId`
|
||||||
|
- Vacation/break events SHOULD have `resourceId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Booking Entity Comparison
|
||||||
|
|
||||||
|
### Current RawBookingData Interface
|
||||||
|
**Location:** `src/repositories/MockBookingRepository.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RawBookingData {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
services: RawBookingService[];
|
||||||
|
totalPrice?: number;
|
||||||
|
tags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawBookingService {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
baseDuration: number;
|
||||||
|
basePrice: number;
|
||||||
|
customPrice?: number;
|
||||||
|
resourceId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target IBooking Interface
|
||||||
|
**Location:** `src/types/BookingTypes.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface IBooking extends ISync {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
createdAt: Date;
|
||||||
|
services: IBookingService[];
|
||||||
|
totalPrice?: number;
|
||||||
|
tags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBookingService {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
baseDuration: number;
|
||||||
|
basePrice: number;
|
||||||
|
customPrice?: number;
|
||||||
|
resourceId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documented JSON Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "BOOK001",
|
||||||
|
"customerId": "CUST001",
|
||||||
|
"status": "created",
|
||||||
|
"createdAt": "2025-08-05T09:00:00",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV001",
|
||||||
|
"serviceName": "Balayage langt hår",
|
||||||
|
"baseDuration": 60,
|
||||||
|
"basePrice": 800,
|
||||||
|
"customPrice": 800,
|
||||||
|
"resourceId": "EMP001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 800,
|
||||||
|
"notes": "Kunde ønsker lys blond"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field-by-Field Comparison - Booking Entity
|
||||||
|
|
||||||
|
**Main Booking:**
|
||||||
|
|
||||||
|
| Field | Current RawData | Target Interface | Documented JSON | Status |
|
||||||
|
|-------|----------------|------------------|----------------|--------|
|
||||||
|
| `id` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `customerId` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `status` | ✅ `string` | ✅ `BookingStatus` | ✅ `"created"` | **MATCH** (needs cast) |
|
||||||
|
| `createdAt` | ✅ `string \| Date` | ✅ `Date` | ✅ Present | **MATCH** |
|
||||||
|
| `services` | ✅ `RawBookingService[]` | ✅ `IBookingService[]` | ✅ Present | **MATCH** |
|
||||||
|
| `totalPrice` | ✅ `number?` | ✅ `number?` | ✅ Present | **MATCH** |
|
||||||
|
| `tags` | ✅ `string[]?` | ✅ `string[]?` | ❌ Not in example | **MATCH** |
|
||||||
|
| `notes` | ✅ `string?` | ✅ `string?` | ✅ Present | **MATCH** |
|
||||||
|
| `syncStatus` | ❌ Missing | ✅ `SyncStatus` (via ISync) | ❌ Not in example | **MISSING (added in processing)** |
|
||||||
|
|
||||||
|
**BookingService:**
|
||||||
|
|
||||||
|
| Field | Current RawData | Target Interface | Documented JSON | Status |
|
||||||
|
|-------|----------------|------------------|----------------|--------|
|
||||||
|
| `serviceId` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `serviceName` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `baseDuration` | ✅ `number` | ✅ `number` | ✅ Present | **MATCH** |
|
||||||
|
| `basePrice` | ✅ `number` | ✅ `number` | ✅ Present | **MATCH** |
|
||||||
|
| `customPrice` | ✅ `number?` | ✅ `number?` | ✅ Present | **MATCH** |
|
||||||
|
| `resourceId` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
|
||||||
|
### Status - Booking Entity
|
||||||
|
|
||||||
|
**RawBookingData: PERFECT MATCH** ✅
|
||||||
|
**RawBookingService: PERFECT MATCH** ✅
|
||||||
|
|
||||||
|
All fields present and correctly typed. `syncStatus` correctly added during processing.
|
||||||
|
|
||||||
|
### Validation Recommendations
|
||||||
|
|
||||||
|
- Ensure `customerId` is not null/empty (REQUIRED)
|
||||||
|
- Ensure `services` array has at least one service (REQUIRED)
|
||||||
|
- Validate `status` is valid BookingStatus enum value
|
||||||
|
- Validate each service has `resourceId` (REQUIRED)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Customer Entity Comparison
|
||||||
|
|
||||||
|
### Current RawCustomerData Interface
|
||||||
|
**Location:** `src/repositories/MockCustomerRepository.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RawCustomerData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target ICustomer Interface
|
||||||
|
**Location:** `src/types/CustomerTypes.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ICustomer extends ISync {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documented JSON Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "CUST001",
|
||||||
|
"name": "Maria Jensen",
|
||||||
|
"phone": "+45 12 34 56 78",
|
||||||
|
"email": "maria.jensen@example.com",
|
||||||
|
"metadata": {
|
||||||
|
"preferredStylist": "EMP001",
|
||||||
|
"allergies": ["ammonia"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field-by-Field Comparison - Customer Entity
|
||||||
|
|
||||||
|
| Field | Current RawData | Target Interface | Documented JSON | Status |
|
||||||
|
|-------|----------------|------------------|----------------|--------|
|
||||||
|
| `id` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `name` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `phone` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `email` | ✅ `string?` | ✅ `string?` | ✅ Present | **MATCH** |
|
||||||
|
| `metadata` | ✅ `Record<string, any>?` | ✅ `Record<string, any>?` | ✅ Present | **MATCH** |
|
||||||
|
| `syncStatus` | ❌ Missing | ✅ `SyncStatus` (via ISync) | ❌ Not in example | **MISSING (added in processing)** |
|
||||||
|
|
||||||
|
### Status - Customer Entity
|
||||||
|
|
||||||
|
**RawCustomerData: PERFECT MATCH** ✅
|
||||||
|
|
||||||
|
All fields present and correctly typed. `syncStatus` correctly added during processing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resource Entity Comparison
|
||||||
|
|
||||||
|
### Current RawResourceData Interface
|
||||||
|
**Location:** `src/repositories/MockResourceRepository.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RawResourceData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
type: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
color?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target IResource Interface
|
||||||
|
**Location:** `src/types/ResourceTypes.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface IResource extends ISync {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
type: ResourceType;
|
||||||
|
avatarUrl?: string;
|
||||||
|
color?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documented JSON Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "EMP001",
|
||||||
|
"name": "karina.knudsen",
|
||||||
|
"displayName": "Karina Knudsen",
|
||||||
|
"type": "person",
|
||||||
|
"avatarUrl": "/avatars/karina.jpg",
|
||||||
|
"color": "#9c27b0",
|
||||||
|
"isActive": true,
|
||||||
|
"metadata": {
|
||||||
|
"role": "master stylist",
|
||||||
|
"specialties": ["balayage", "color", "bridal"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field-by-Field Comparison - Resource Entity
|
||||||
|
|
||||||
|
| Field | Current RawData | Target Interface | Documented JSON | Status |
|
||||||
|
|-------|----------------|------------------|----------------|--------|
|
||||||
|
| `id` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `name` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `displayName` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
|
||||||
|
| `type` | ✅ `string` | ✅ `ResourceType` | ✅ `"person"` | **MATCH** (needs cast) |
|
||||||
|
| `avatarUrl` | ✅ `string?` | ✅ `string?` | ✅ Present | **MATCH** |
|
||||||
|
| `color` | ✅ `string?` | ✅ `string?` | ✅ Present | **MATCH** |
|
||||||
|
| `isActive` | ✅ `boolean?` | ✅ `boolean?` | ✅ Present | **MATCH** |
|
||||||
|
| `metadata` | ✅ `Record<string, any>?` | ✅ `Record<string, any>?` | ✅ Present | **MATCH** |
|
||||||
|
| `syncStatus` | ❌ Missing | ✅ `SyncStatus` (via ISync) | ❌ Not in example | **MISSING (added in processing)** |
|
||||||
|
|
||||||
|
### Status - Resource Entity
|
||||||
|
|
||||||
|
**RawResourceData: PERFECT MATCH** ✅
|
||||||
|
|
||||||
|
All fields present and correctly typed. `syncStatus` correctly added during processing.
|
||||||
|
|
||||||
|
### Validation Recommendations
|
||||||
|
|
||||||
|
- Validate `type` is valid ResourceType enum value (`'person' | 'room' | 'equipment' | 'vehicle' | 'custom'`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| Entity | Core Fields Status | Missing Critical Fields | Extra Fields | Overall Status |
|
||||||
|
|--------|-------------------|-------------------------|--------------|----------------|
|
||||||
|
| **Event** | ✅ Basic fields OK | ❌ 4 critical fields missing | ⚠️ `color` (legacy) | **NEEDS UPDATES** |
|
||||||
|
| **Booking** | ✅ All fields present | ✅ None | ✅ None | **COMPLETE ✅** |
|
||||||
|
| **Customer** | ✅ All fields present | ✅ None | ✅ None | **COMPLETE ✅** |
|
||||||
|
| **Resource** | ✅ All fields present | ✅ None | ✅ None | **COMPLETE ✅** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Validation Rules
|
||||||
|
|
||||||
|
### Event Type Constraints
|
||||||
|
|
||||||
|
From `docs/booking-event-architecture.md`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Rule 1: Customer events MUST have booking reference
|
||||||
|
if (event.type === 'customer') {
|
||||||
|
assert(event.bookingId !== undefined, "Customer events require bookingId");
|
||||||
|
assert(event.customerId !== undefined, "Customer events require customerId");
|
||||||
|
assert(event.resourceId !== undefined, "Customer events require resourceId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2: Non-customer events MUST NOT have booking reference
|
||||||
|
if (event.type !== 'customer') {
|
||||||
|
assert(event.bookingId === undefined, "Only customer events have bookingId");
|
||||||
|
assert(event.customerId === undefined, "Only customer events have customerId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 3: Vacation/break events MUST have resource assignment
|
||||||
|
if (event.type === 'vacation' || event.type === 'break') {
|
||||||
|
assert(event.resourceId !== undefined, "Vacation/break events require resourceId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 4: Meeting/blocked events MAY have resource assignment
|
||||||
|
if (event.type === 'meeting' || event.type === 'blocked') {
|
||||||
|
// resourceId is optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Booking Constraints
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Rule 5: Booking ALWAYS has customer
|
||||||
|
assert(booking.customerId !== "", "Booking requires customer");
|
||||||
|
|
||||||
|
// Rule 6: Booking ALWAYS has services
|
||||||
|
assert(booking.services.length > 0, "Booking requires at least one service");
|
||||||
|
|
||||||
|
// Rule 7: Each service MUST have resource assignment
|
||||||
|
booking.services.forEach(service => {
|
||||||
|
assert(service.resourceId !== undefined, "Service requires resourceId");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rule 8: Service resourceId becomes Event resourceId
|
||||||
|
// When a booking has ONE service:
|
||||||
|
// event.resourceId = booking.services[0].resourceId
|
||||||
|
// When a booking has MULTIPLE services:
|
||||||
|
// ONE event per service, each with different resourceId
|
||||||
|
```
|
||||||
|
|
||||||
|
### Denormalization Rules
|
||||||
|
|
||||||
|
From `docs/booking-event-architecture.md` (lines 532-547):
|
||||||
|
|
||||||
|
**Backend performs JOIN and denormalizes:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
e.Id,
|
||||||
|
e.Type,
|
||||||
|
e.Title,
|
||||||
|
e.Start,
|
||||||
|
e.End,
|
||||||
|
e.AllDay,
|
||||||
|
e.BookingId,
|
||||||
|
e.ResourceId, -- Already on CalendarEvent (denormalized)
|
||||||
|
b.CustomerId -- Joined from Booking table
|
||||||
|
FROM CalendarEvent e
|
||||||
|
LEFT JOIN Booking b ON e.BookingId = b.Id
|
||||||
|
WHERE e.Start >= @start AND e.Start <= @end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why denormalization:**
|
||||||
|
- **Performance:** No JOIN needed in frontend queries
|
||||||
|
- **Resource filtering:** Quick "show all events for EMP001"
|
||||||
|
- **Customer filtering:** Quick "show all events for CUST001"
|
||||||
|
- **Offline-first:** Complete event data available without JOIN
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Implementation
|
||||||
|
|
||||||
|
### Phase 1: Update RawEventData Interface (HIGH PRIORITY)
|
||||||
|
|
||||||
|
**File:** `src/repositories/MockEventRepository.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RawEventData {
|
||||||
|
// Core fields (required)
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: string | Date;
|
||||||
|
end: string | Date;
|
||||||
|
type: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
|
||||||
|
// Denormalized references (NEW - CRITICAL for booking architecture)
|
||||||
|
bookingId?: string; // Reference to booking (customer events only)
|
||||||
|
resourceId?: string; // Which resource owns this slot
|
||||||
|
customerId?: string; // Customer reference (denormalized from booking)
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
description?: string; // Detailed event notes
|
||||||
|
recurringId?: string; // For recurring events
|
||||||
|
metadata?: Record<string, any>; // Flexible metadata
|
||||||
|
|
||||||
|
// Legacy (deprecated, keep for backward compatibility)
|
||||||
|
color?: string; // UI-specific field
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Update processCalendarData() Method
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
||||||
|
return data.map((event): ICalendarEvent => {
|
||||||
|
// Validate event type constraints
|
||||||
|
if (event.type === 'customer') {
|
||||||
|
if (!event.bookingId) {
|
||||||
|
console.warn(`Customer event ${event.id} missing bookingId`);
|
||||||
|
}
|
||||||
|
if (!event.resourceId) {
|
||||||
|
console.warn(`Customer event ${event.id} missing resourceId`);
|
||||||
|
}
|
||||||
|
if (!event.customerId) {
|
||||||
|
console.warn(`Customer event ${event.id} missing customerId`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
start: new Date(event.start),
|
||||||
|
end: new Date(event.end),
|
||||||
|
type: event.type as CalendarEventType,
|
||||||
|
allDay: event.allDay || false,
|
||||||
|
|
||||||
|
// Denormalized references (CRITICAL)
|
||||||
|
bookingId: event.bookingId,
|
||||||
|
resourceId: event.resourceId,
|
||||||
|
customerId: event.customerId,
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
recurringId: event.recurringId,
|
||||||
|
metadata: event.metadata,
|
||||||
|
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Testing (RECOMMENDED)
|
||||||
|
|
||||||
|
1. **Test customer event with booking reference**
|
||||||
|
- Verify `bookingId`, `resourceId`, `customerId` are preserved
|
||||||
|
- Verify type is correctly cast to `CalendarEventType`
|
||||||
|
|
||||||
|
2. **Test vacation event without booking**
|
||||||
|
- Verify `bookingId` and `customerId` are `undefined`
|
||||||
|
- Verify `resourceId` IS present (required for vacation/break)
|
||||||
|
|
||||||
|
3. **Test split-resource booking scenario**
|
||||||
|
- Booking with 2 services (different resources)
|
||||||
|
- Should create 2 events with different `resourceId`
|
||||||
|
|
||||||
|
4. **Test event-booking relationship queries**
|
||||||
|
- Load event by `bookingId`
|
||||||
|
- Load all events for `resourceId`
|
||||||
|
- Load all events for `customerId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Compliance Score
|
||||||
|
|
||||||
|
| Aspect | Score | Notes |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| Repository Pattern | 100% | Correctly implements IApiRepository |
|
||||||
|
| Entity Interfaces | 75% | Booking/Customer/Resource perfect, Event missing 4 critical fields |
|
||||||
|
| Data Processing | 90% | Correct date/type conversions, needs explicit field mapping |
|
||||||
|
| Type Safety | 85% | Good type assertions, needs validation |
|
||||||
|
| Documentation Alignment | 70% | Partially matches documented examples |
|
||||||
|
| **Overall** | **84%** | **Good foundation, Event entity needs updates** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gap Analysis Summary
|
||||||
|
|
||||||
|
### What's Working ✅
|
||||||
|
|
||||||
|
- **Repository pattern:** Correctly implements IApiRepository interface
|
||||||
|
- **Booking entity:** 100% correct (all fields match)
|
||||||
|
- **Customer entity:** 100% correct (all fields match)
|
||||||
|
- **Resource entity:** 100% correct (all fields match)
|
||||||
|
- **Date processing:** string | Date → Date correctly handled
|
||||||
|
- **Type assertions:** string → enum types correctly cast
|
||||||
|
- **SyncStatus injection:** Correctly added during processing
|
||||||
|
- **Error handling:** Unsupported operations (create/update/delete) throw errors
|
||||||
|
- **fetchAll() implementation:** Correctly loads from JSON and processes data
|
||||||
|
|
||||||
|
### What's Missing ❌
|
||||||
|
|
||||||
|
**Event Entity - 4 Critical Fields:**
|
||||||
|
|
||||||
|
1. **bookingId** - Cannot link events to bookings
|
||||||
|
- Impact: Cannot load booking details when event is clicked
|
||||||
|
- Impact: Cannot cascade delete when booking is cancelled
|
||||||
|
- Impact: Cannot query events by booking
|
||||||
|
|
||||||
|
2. **resourceId** - Cannot query by resource
|
||||||
|
- Impact: Cannot filter calendar by resource (columns)
|
||||||
|
- Impact: Cannot show resource utilization
|
||||||
|
- Impact: Denormalization benefit lost (requires JOIN)
|
||||||
|
|
||||||
|
3. **customerId** - Cannot query by customer
|
||||||
|
- Impact: Cannot show customer history
|
||||||
|
- Impact: Cannot filter events by customer
|
||||||
|
- Impact: Denormalization benefit lost (requires JOIN)
|
||||||
|
|
||||||
|
4. **description** - Cannot add detailed notes
|
||||||
|
- Impact: Limited event details
|
||||||
|
- Impact: No additional context beyond title
|
||||||
|
|
||||||
|
### What Needs Validation ⚠️
|
||||||
|
|
||||||
|
- **Event type constraints:** Customer events require `bookingId`
|
||||||
|
- **Booking constraints:** Must have `customerId` and `services[]`
|
||||||
|
- **Resource assignment:** Vacation/break events require `resourceId`
|
||||||
|
- **Enum validation:** Validate `type`, `status` match enum values
|
||||||
|
|
||||||
|
### What Needs Cleanup 🧹
|
||||||
|
|
||||||
|
- **Legacy `color` field:** Present in RawEventData but not in ICalendarEvent
|
||||||
|
- **Index signature:** Consider removing `[key: string]: unknown` once all fields are explicit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Immediate (HIGH PRIORITY)
|
||||||
|
|
||||||
|
1. **Update Event Entity**
|
||||||
|
- Add 4 missing fields to `RawEventData`
|
||||||
|
- Update `processCalendarData()` with explicit mapping
|
||||||
|
- Add validation for type constraints
|
||||||
|
|
||||||
|
### Short-term (MEDIUM PRIORITY)
|
||||||
|
|
||||||
|
2. **Create Mock Data Files**
|
||||||
|
- Update `wwwroot/data/mock-events.json` with denormalized fields
|
||||||
|
- Ensure `mock-bookings.json`, `mock-customers.json`, `mock-resources.json` exist
|
||||||
|
- Verify relationships (event.bookingId → booking.id)
|
||||||
|
|
||||||
|
3. **Add Validation Layer**
|
||||||
|
- Validate event-booking relationships
|
||||||
|
- Validate required fields per event type
|
||||||
|
- Log warnings for data integrity issues
|
||||||
|
|
||||||
|
### Long-term (LOW PRIORITY)
|
||||||
|
|
||||||
|
4. **Update Tests**
|
||||||
|
- Test new fields in event processing
|
||||||
|
- Test validation rules
|
||||||
|
- Test cross-entity relationships
|
||||||
|
|
||||||
|
5. **Documentation**
|
||||||
|
- Update CLAUDE.md with Mock repository usage
|
||||||
|
- Document validation rules
|
||||||
|
- Document denormalization strategy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The Mock Repository implementation has a **strong foundation** with 3 out of 4 entities (Booking, Customer, Resource) perfectly matching the target architecture.
|
||||||
|
|
||||||
|
The **Event entity** needs critical updates to support the booking architecture's denormalization strategy. Adding the 4 missing fields (`bookingId`, `resourceId`, `customerId`, `description`) will bring the implementation to **100% compliance** with the documented architecture.
|
||||||
|
|
||||||
|
**Estimated effort:** 1-2 hours for updates + testing
|
||||||
|
|
||||||
|
**Risk:** Low - changes are additive (no breaking changes to existing code)
|
||||||
|
|
||||||
|
**Priority:** HIGH - required for booking architecture to function correctly
|
||||||
39
src/index.ts
39
src/index.ts
|
|
@ -4,7 +4,7 @@ import { eventBus } from './core/EventBus';
|
||||||
import { ConfigManager } from './configurations/ConfigManager';
|
import { ConfigManager } from './configurations/ConfigManager';
|
||||||
import { Configuration } from './configurations/CalendarConfig';
|
import { Configuration } from './configurations/CalendarConfig';
|
||||||
import { URLManager } from './utils/URLManager';
|
import { URLManager } from './utils/URLManager';
|
||||||
import { IEventBus } from './types/CalendarTypes';
|
import { ICalendarEvent, IEventBus } from './types/CalendarTypes';
|
||||||
|
|
||||||
// Import all managers
|
// Import all managers
|
||||||
import { EventManager } from './managers/EventManager';
|
import { EventManager } from './managers/EventManager';
|
||||||
|
|
@ -25,6 +25,9 @@ import { WorkweekPresets } from './components/WorkweekPresets';
|
||||||
// Import repositories and storage
|
// Import repositories and storage
|
||||||
import { IEventRepository } from './repositories/IEventRepository';
|
import { IEventRepository } from './repositories/IEventRepository';
|
||||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||||
|
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||||
|
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||||
|
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||||
import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepository';
|
import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepository';
|
||||||
import { IApiRepository } from './repositories/IApiRepository';
|
import { IApiRepository } from './repositories/IApiRepository';
|
||||||
import { ApiEventRepository } from './repositories/ApiEventRepository';
|
import { ApiEventRepository } from './repositories/ApiEventRepository';
|
||||||
|
|
@ -46,6 +49,7 @@ import { ResourceService } from './storage/resources/ResourceService';
|
||||||
|
|
||||||
// Import workers
|
// Import workers
|
||||||
import { SyncManager } from './workers/SyncManager';
|
import { SyncManager } from './workers/SyncManager';
|
||||||
|
import { DataSeeder } from './workers/DataSeeder';
|
||||||
|
|
||||||
// Import renderers
|
// Import renderers
|
||||||
import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer';
|
import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer';
|
||||||
|
|
@ -65,6 +69,9 @@ import { EventStackManager } from './managers/EventStackManager';
|
||||||
import { EventLayoutCoordinator } from './managers/EventLayoutCoordinator';
|
import { EventLayoutCoordinator } from './managers/EventLayoutCoordinator';
|
||||||
import { IColumnDataSource } from './types/ColumnDataSource';
|
import { IColumnDataSource } from './types/ColumnDataSource';
|
||||||
import { DateColumnDataSource } from './datasources/DateColumnDataSource';
|
import { DateColumnDataSource } from './datasources/DateColumnDataSource';
|
||||||
|
import { IBooking } from './types/BookingTypes';
|
||||||
|
import { ICustomer } from './types/CustomerTypes';
|
||||||
|
import { IResource } from './types/ResourceTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle deep linking functionality after managers are initialized
|
* Handle deep linking functionality after managers are initialized
|
||||||
|
|
@ -122,26 +129,27 @@ async function initializeCalendar(): Promise<void> {
|
||||||
builder.registerType(IndexedDBService).as<IndexedDBService>();
|
builder.registerType(IndexedDBService).as<IndexedDBService>();
|
||||||
builder.registerType(OperationQueue).as<OperationQueue>();
|
builder.registerType(OperationQueue).as<OperationQueue>();
|
||||||
|
|
||||||
// Register API repositories (backend sync)
|
// Register Mock repositories (development/testing - load from JSON files)
|
||||||
// Each entity type has its own API repository implementing IApiRepository<T>
|
// Each entity type has its own Mock repository implementing IApiRepository<T>
|
||||||
builder.registerType(ApiEventRepository).as<IApiRepository<any>>();
|
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
|
||||||
builder.registerType(ApiBookingRepository).as<IApiRepository<any>>();
|
builder.registerType(MockBookingRepository).as<IApiRepository<IBooking>>();
|
||||||
builder.registerType(ApiCustomerRepository).as<IApiRepository<any>>();
|
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
|
||||||
builder.registerType(ApiResourceRepository).as<IApiRepository<any>>();
|
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
|
||||||
|
|
||||||
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
|
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
|
||||||
// Register entity services (sync status management)
|
// Register entity services (sync status management)
|
||||||
// Open/Closed Principle: Adding new entity only requires adding one line here
|
// Open/Closed Principle: Adding new entity only requires adding one line here
|
||||||
builder.registerType(EventService).as<IEntityService<any>>();
|
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
||||||
builder.registerType(BookingService).as<IEntityService<any>>();
|
builder.registerType(BookingService).as<IEntityService<IBooking>>();
|
||||||
builder.registerType(CustomerService).as<IEntityService<any>>();
|
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
|
||||||
builder.registerType(ResourceService).as<IEntityService<any>>();
|
builder.registerType(ResourceService).as<IEntityService<IResource>>();
|
||||||
|
|
||||||
// Register IndexedDB repositories (offline-first)
|
// Register IndexedDB repositories (offline-first)
|
||||||
builder.registerType(IndexedDBEventRepository).as<IEventRepository>();
|
builder.registerType(IndexedDBEventRepository).as<IEventRepository>();
|
||||||
|
|
||||||
// Register workers
|
// Register workers
|
||||||
builder.registerType(SyncManager).as<SyncManager>();
|
builder.registerType(SyncManager).as<SyncManager>();
|
||||||
|
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||||
|
|
||||||
// Register renderers
|
// Register renderers
|
||||||
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
|
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
|
||||||
|
|
@ -181,6 +189,13 @@ async function initializeCalendar(): Promise<void> {
|
||||||
// Build the container
|
// Build the container
|
||||||
const app = builder.build();
|
const app = builder.build();
|
||||||
|
|
||||||
|
// Initialize database and seed data BEFORE initializing managers
|
||||||
|
const indexedDBService = app.resolveType<IndexedDBService>();
|
||||||
|
await indexedDBService.initialize();
|
||||||
|
|
||||||
|
const dataSeeder = app.resolveType<DataSeeder>();
|
||||||
|
await dataSeeder.seedIfEmpty();
|
||||||
|
|
||||||
// Get managers from container
|
// Get managers from container
|
||||||
const eb = app.resolveType<IEventBus>();
|
const eb = app.resolveType<IEventBus>();
|
||||||
const calendarManager = app.resolveType<CalendarManager>();
|
const calendarManager = app.resolveType<CalendarManager>();
|
||||||
|
|
|
||||||
90
src/repositories/MockBookingRepository.ts
Normal file
90
src/repositories/MockBookingRepository.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { IBooking, IBookingService, BookingStatus } from '../types/BookingTypes';
|
||||||
|
import { EntityType } from '../types/CalendarTypes';
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
|
interface RawBookingData {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
services: RawBookingService[];
|
||||||
|
totalPrice?: number;
|
||||||
|
tags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawBookingService {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
baseDuration: number;
|
||||||
|
basePrice: number;
|
||||||
|
customPrice?: number;
|
||||||
|
resourceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockBookingRepository - Loads booking data from local JSON file
|
||||||
|
*
|
||||||
|
* This repository implementation fetches mock booking data from a static JSON file.
|
||||||
|
* Used for development and testing instead of API calls.
|
||||||
|
*
|
||||||
|
* Data Source: data/mock-bookings.json
|
||||||
|
*
|
||||||
|
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||||
|
* Only fetchAll() is implemented for loading initial mock data.
|
||||||
|
*/
|
||||||
|
export class MockBookingRepository implements IApiRepository<IBooking> {
|
||||||
|
public readonly entityType: EntityType = 'Booking';
|
||||||
|
private readonly dataUrl = 'data/mock-bookings.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all bookings from mock JSON file
|
||||||
|
*/
|
||||||
|
public async fetchAll(): Promise<IBooking[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load mock bookings: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData: RawBookingData[] = await response.json();
|
||||||
|
|
||||||
|
return this.processBookingData(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load booking data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockBookingRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendCreate(booking: IBooking): Promise<IBooking> {
|
||||||
|
throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockBookingRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendUpdate(id: string, updates: Partial<IBooking>): Promise<IBooking> {
|
||||||
|
throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockBookingRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendDelete(id: string): Promise<void> {
|
||||||
|
throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private processBookingData(data: RawBookingData[]): IBooking[] {
|
||||||
|
return data.map((booking): IBooking => ({
|
||||||
|
...booking,
|
||||||
|
createdAt: new Date(booking.createdAt),
|
||||||
|
status: booking.status as BookingStatus,
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/repositories/MockCustomerRepository.ts
Normal file
76
src/repositories/MockCustomerRepository.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { ICustomer } from '../types/CustomerTypes';
|
||||||
|
import { EntityType } from '../types/CalendarTypes';
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
|
interface RawCustomerData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockCustomerRepository - Loads customer data from local JSON file
|
||||||
|
*
|
||||||
|
* This repository implementation fetches mock customer data from a static JSON file.
|
||||||
|
* Used for development and testing instead of API calls.
|
||||||
|
*
|
||||||
|
* Data Source: data/mock-customers.json
|
||||||
|
*
|
||||||
|
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||||
|
* Only fetchAll() is implemented for loading initial mock data.
|
||||||
|
*/
|
||||||
|
export class MockCustomerRepository implements IApiRepository<ICustomer> {
|
||||||
|
public readonly entityType: EntityType = 'Customer';
|
||||||
|
private readonly dataUrl = 'data/mock-customers.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all customers from mock JSON file
|
||||||
|
*/
|
||||||
|
public async fetchAll(): Promise<ICustomer[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load mock customers: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData: RawCustomerData[] = await response.json();
|
||||||
|
|
||||||
|
return this.processCustomerData(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockCustomerRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendCreate(customer: ICustomer): Promise<ICustomer> {
|
||||||
|
throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockCustomerRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendUpdate(id: string, updates: Partial<ICustomer>): Promise<ICustomer> {
|
||||||
|
throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockCustomerRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendDelete(id: string): Promise<void> {
|
||||||
|
throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private processCustomerData(data: RawCustomerData[]): ICustomer[] {
|
||||||
|
return data.map((customer): ICustomer => ({
|
||||||
|
...customer,
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,33 +1,50 @@
|
||||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
import { ICalendarEvent, EntityType } from '../types/CalendarTypes';
|
||||||
import { CalendarEventType } from '../types/BookingTypes';
|
import { CalendarEventType } from '../types/BookingTypes';
|
||||||
import { IEventRepository, UpdateSource } from './IEventRepository';
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
interface RawEventData {
|
interface RawEventData {
|
||||||
|
// Core fields (required)
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
start: string | Date;
|
start: string | Date;
|
||||||
end: string | Date;
|
end: string | Date;
|
||||||
type: string;
|
type: string;
|
||||||
color?: string;
|
|
||||||
allDay?: boolean;
|
allDay?: boolean;
|
||||||
|
|
||||||
|
// Denormalized references (CRITICAL for booking architecture)
|
||||||
|
bookingId?: string; // Reference to booking (customer events only)
|
||||||
|
resourceId?: string; // Which resource owns this slot
|
||||||
|
customerId?: string; // Customer reference (denormalized from booking)
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
description?: string; // Detailed event notes
|
||||||
|
recurringId?: string; // For recurring events
|
||||||
|
metadata?: Record<string, any>; // Flexible metadata
|
||||||
|
|
||||||
|
// Legacy (deprecated, keep for backward compatibility)
|
||||||
|
color?: string; // UI-specific field
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MockEventRepository - Loads event data from local JSON file (LEGACY)
|
* MockEventRepository - Loads event data from local JSON file
|
||||||
*
|
*
|
||||||
* This repository implementation fetches mock event data from a static JSON file.
|
* This repository implementation fetches mock event data from a static JSON file.
|
||||||
* DEPRECATED: Use IndexedDBEventRepository for offline-first functionality.
|
* Used for development and testing instead of API calls.
|
||||||
*
|
*
|
||||||
* Data Source: data/mock-events.json
|
* Data Source: data/mock-events.json
|
||||||
*
|
*
|
||||||
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||||
* This is intentional to encourage migration to IndexedDBEventRepository.
|
* Only fetchAll() is implemented for loading initial mock data.
|
||||||
*/
|
*/
|
||||||
export class MockEventRepository implements IEventRepository {
|
export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
||||||
|
public readonly entityType: EntityType = 'Event';
|
||||||
private readonly dataUrl = 'data/mock-events.json';
|
private readonly dataUrl = 'data/mock-events.json';
|
||||||
|
|
||||||
public async loadEvents(): Promise<ICalendarEvent[]> {
|
/**
|
||||||
|
* Fetch all events from mock JSON file
|
||||||
|
*/
|
||||||
|
public async fetchAll(): Promise<ICalendarEvent[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.dataUrl);
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
|
@ -46,36 +63,60 @@ export class MockEventRepository implements IEventRepository {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOT SUPPORTED - MockEventRepository is read-only
|
* NOT SUPPORTED - MockEventRepository is read-only
|
||||||
* Use IndexedDBEventRepository instead
|
|
||||||
*/
|
*/
|
||||||
public async createEvent(event: Omit<ICalendarEvent, 'id'>, source?: UpdateSource): Promise<ICalendarEvent> {
|
public async sendCreate(event: ICalendarEvent): Promise<ICalendarEvent> {
|
||||||
throw new Error('MockEventRepository does not support createEvent. Use IndexedDBEventRepository instead.');
|
throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOT SUPPORTED - MockEventRepository is read-only
|
* NOT SUPPORTED - MockEventRepository is read-only
|
||||||
* Use IndexedDBEventRepository instead
|
|
||||||
*/
|
*/
|
||||||
public async updateEvent(id: string, updates: Partial<ICalendarEvent>, source?: UpdateSource): Promise<ICalendarEvent> {
|
public async sendUpdate(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
|
||||||
throw new Error('MockEventRepository does not support updateEvent. Use IndexedDBEventRepository instead.');
|
throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOT SUPPORTED - MockEventRepository is read-only
|
* NOT SUPPORTED - MockEventRepository is read-only
|
||||||
* Use IndexedDBEventRepository instead
|
|
||||||
*/
|
*/
|
||||||
public async deleteEvent(id: string, source?: UpdateSource): Promise<void> {
|
public async sendDelete(id: string): Promise<void> {
|
||||||
throw new Error('MockEventRepository does not support deleteEvent. Use IndexedDBEventRepository instead.');
|
throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
||||||
return data.map((event): ICalendarEvent => ({
|
return data.map((event): ICalendarEvent => {
|
||||||
...event,
|
// Validate event type constraints
|
||||||
start: new Date(event.start),
|
if (event.type === 'customer') {
|
||||||
end: new Date(event.end),
|
if (!event.bookingId) {
|
||||||
type: event.type as CalendarEventType,
|
console.warn(`Customer event ${event.id} missing bookingId`);
|
||||||
allDay: event.allDay || false,
|
}
|
||||||
syncStatus: 'synced' as const
|
if (!event.resourceId) {
|
||||||
}));
|
console.warn(`Customer event ${event.id} missing resourceId`);
|
||||||
|
}
|
||||||
|
if (!event.customerId) {
|
||||||
|
console.warn(`Customer event ${event.id} missing customerId`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
start: new Date(event.start),
|
||||||
|
end: new Date(event.end),
|
||||||
|
type: event.type as CalendarEventType,
|
||||||
|
allDay: event.allDay || false,
|
||||||
|
|
||||||
|
// Denormalized references (CRITICAL for booking architecture)
|
||||||
|
bookingId: event.bookingId,
|
||||||
|
resourceId: event.resourceId,
|
||||||
|
customerId: event.customerId,
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
recurringId: event.recurringId,
|
||||||
|
metadata: event.metadata,
|
||||||
|
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
src/repositories/MockResourceRepository.ts
Normal file
80
src/repositories/MockResourceRepository.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { IResource, ResourceType } from '../types/ResourceTypes';
|
||||||
|
import { EntityType } from '../types/CalendarTypes';
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
|
interface RawResourceData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
type: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
color?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockResourceRepository - Loads resource data from local JSON file
|
||||||
|
*
|
||||||
|
* This repository implementation fetches mock resource data from a static JSON file.
|
||||||
|
* Used for development and testing instead of API calls.
|
||||||
|
*
|
||||||
|
* Data Source: data/mock-resources.json
|
||||||
|
*
|
||||||
|
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||||
|
* Only fetchAll() is implemented for loading initial mock data.
|
||||||
|
*/
|
||||||
|
export class MockResourceRepository implements IApiRepository<IResource> {
|
||||||
|
public readonly entityType: EntityType = 'Resource';
|
||||||
|
private readonly dataUrl = 'data/mock-resources.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all resources from mock JSON file
|
||||||
|
*/
|
||||||
|
public async fetchAll(): Promise<IResource[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load mock resources: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData: RawResourceData[] = await response.json();
|
||||||
|
|
||||||
|
return this.processResourceData(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load resource data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockResourceRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendCreate(resource: IResource): Promise<IResource> {
|
||||||
|
throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockResourceRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendUpdate(id: string, updates: Partial<IResource>): Promise<IResource> {
|
||||||
|
throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT SUPPORTED - MockResourceRepository is read-only
|
||||||
|
*/
|
||||||
|
public async sendDelete(id: string): Promise<void> {
|
||||||
|
throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private processResourceData(data: RawResourceData[]): IResource[] {
|
||||||
|
return data.map((resource): IResource => ({
|
||||||
|
...resource,
|
||||||
|
type: resource.type as ResourceType,
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,13 @@ import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
|
||||||
* IEntityService<T> - Generic interface for entity services with sync capabilities
|
* IEntityService<T> - Generic interface for entity services with sync capabilities
|
||||||
*
|
*
|
||||||
* All entity services (Event, Booking, Customer, Resource) implement this interface
|
* All entity services (Event, Booking, Customer, Resource) implement this interface
|
||||||
* to enable polymorphic sync status management in SyncManager.
|
* to enable polymorphic operations across different entity types.
|
||||||
*
|
*
|
||||||
* ENCAPSULATION: Services encapsulate sync status manipulation.
|
* ENCAPSULATION: Services encapsulate sync status manipulation.
|
||||||
* SyncManager does NOT directly manipulate entity.syncStatus - it delegates to the service.
|
* SyncManager does NOT directly manipulate entity.syncStatus - it delegates to the service.
|
||||||
*
|
*
|
||||||
* POLYMORFI: SyncManager works with Array<IEntityService<any>> and uses
|
* POLYMORPHISM: Both SyncManager and DataSeeder work with Array<IEntityService<any>>
|
||||||
* entityType property for runtime routing, avoiding switch statements.
|
* and use entityType property for runtime routing, avoiding switch statements.
|
||||||
*/
|
*/
|
||||||
export interface IEntityService<T extends ISync> {
|
export interface IEntityService<T extends ISync> {
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,6 +19,30 @@ export interface IEntityService<T extends ISync> {
|
||||||
*/
|
*/
|
||||||
readonly entityType: EntityType;
|
readonly entityType: EntityType;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CRUD Operations (used by DataSeeder and other consumers)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entities from IndexedDB
|
||||||
|
* Used by DataSeeder to check if store is empty before seeding
|
||||||
|
*
|
||||||
|
* @returns Promise<T[]> - Array of all entities
|
||||||
|
*/
|
||||||
|
getAll(): Promise<T[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an entity (create or update) to IndexedDB
|
||||||
|
* Used by DataSeeder to persist fetched data
|
||||||
|
*
|
||||||
|
* @param entity - Entity to save
|
||||||
|
*/
|
||||||
|
save(entity: T): Promise<void>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYNC Methods (used by SyncManager)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark entity as successfully synced with backend
|
* Mark entity as successfully synced with backend
|
||||||
* Sets syncStatus = 'synced' and persists to IndexedDB
|
* Sets syncStatus = 'synced' and persists to IndexedDB
|
||||||
|
|
|
||||||
103
src/workers/DataSeeder.ts
Normal file
103
src/workers/DataSeeder.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { IApiRepository } from '../repositories/IApiRepository';
|
||||||
|
import { IEntityService } from '../storage/IEntityService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSeeder - Orchestrates initial data loading from repositories into IndexedDB
|
||||||
|
*
|
||||||
|
* ARCHITECTURE:
|
||||||
|
* - Repository (Mock/Api): Fetches data from source (JSON file or backend API)
|
||||||
|
* - DataSeeder (this class): Orchestrates fetch + save operations
|
||||||
|
* - Service (EventService, etc.): Saves data to IndexedDB
|
||||||
|
*
|
||||||
|
* SEPARATION OF CONCERNS:
|
||||||
|
* - Repository does NOT know about IndexedDB or storage
|
||||||
|
* - Service does NOT know about where data comes from
|
||||||
|
* - DataSeeder connects them together
|
||||||
|
*
|
||||||
|
* POLYMORPHIC DESIGN:
|
||||||
|
* - Uses arrays of IEntityService<any>[] and IApiRepository<any>[]
|
||||||
|
* - Matches services with repositories using entityType property
|
||||||
|
* - Open/Closed Principle: Adding new entity requires no code changes here
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* Called once during app initialization in index.ts:
|
||||||
|
* 1. IndexedDBService.initialize() - open database
|
||||||
|
* 2. dataSeeder.seedIfEmpty() - load initial data if needed
|
||||||
|
* 3. CalendarManager.initialize() - start calendar
|
||||||
|
*
|
||||||
|
* NOTE: This is for INITIAL SEEDING only. Ongoing sync is handled by SyncManager.
|
||||||
|
*/
|
||||||
|
export class DataSeeder {
|
||||||
|
constructor(
|
||||||
|
// Arrays injected via DI - automatically includes all registered services/repositories
|
||||||
|
private services: IEntityService<any>[],
|
||||||
|
private repositories: IApiRepository<any>[]
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed all entity stores if they are empty
|
||||||
|
* Runs on app initialization to load initial data from repositories
|
||||||
|
*
|
||||||
|
* Uses polymorphism: loops through all services and matches with repositories by entityType
|
||||||
|
*/
|
||||||
|
async seedIfEmpty(): Promise<void> {
|
||||||
|
console.log('[DataSeeder] Checking if database needs seeding...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Loop through all entity services (Event, Booking, Customer, Resource, etc.)
|
||||||
|
for (const service of this.services) {
|
||||||
|
// Find matching repository for this service based on entityType
|
||||||
|
const repository = this.repositories.find(repo => repo.entityType === service.entityType);
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
console.warn(`[DataSeeder] No repository found for entity type: ${service.entityType}, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed this entity type
|
||||||
|
await this.seedEntity(service.entityType, service, repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DataSeeder] Seeding complete');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DataSeeder] Seeding failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic method to seed a single entity type
|
||||||
|
*
|
||||||
|
* @param entityType - Entity type ('Event', 'Booking', 'Customer', 'Resource')
|
||||||
|
* @param service - Entity service for IndexedDB operations
|
||||||
|
* @param repository - Repository for fetching data
|
||||||
|
*/
|
||||||
|
private async seedEntity<T>(
|
||||||
|
entityType: string,
|
||||||
|
service: IEntityService<any>,
|
||||||
|
repository: IApiRepository<T>
|
||||||
|
): Promise<void> {
|
||||||
|
// Check if store is empty
|
||||||
|
const existing = await service.getAll();
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
console.log(`[DataSeeder] ${entityType} store already has ${existing.length} items, skipping seed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[DataSeeder] ${entityType} store is empty, fetching from repository...`);
|
||||||
|
|
||||||
|
// Fetch from repository (Mock JSON or backend API)
|
||||||
|
const data = await repository.fetchAll();
|
||||||
|
|
||||||
|
console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`);
|
||||||
|
|
||||||
|
// Save each entity to IndexedDB
|
||||||
|
// Note: Entities from repository should already have syncStatus='synced'
|
||||||
|
for (const entity of data) {
|
||||||
|
await service.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
306
wwwroot/data/mock-bookings.json
Normal file
306
wwwroot/data/mock-bookings.json
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "BOOK001",
|
||||||
|
"customerId": "CUST001",
|
||||||
|
"status": "arrived",
|
||||||
|
"createdAt": "2025-08-05T08:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV001",
|
||||||
|
"serviceName": "Klipning og styling",
|
||||||
|
"baseDuration": 60,
|
||||||
|
"basePrice": 500,
|
||||||
|
"customPrice": 500,
|
||||||
|
"resourceId": "EMP001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 500,
|
||||||
|
"notes": "Kunde ønsker lidt kortere"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK002",
|
||||||
|
"customerId": "CUST002",
|
||||||
|
"status": "paid",
|
||||||
|
"createdAt": "2025-08-05T09:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV002",
|
||||||
|
"serviceName": "Hårvask",
|
||||||
|
"baseDuration": 30,
|
||||||
|
"basePrice": 100,
|
||||||
|
"customPrice": 100,
|
||||||
|
"resourceId": "STUDENT001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serviceId": "SRV003",
|
||||||
|
"serviceName": "Bundfarve",
|
||||||
|
"baseDuration": 90,
|
||||||
|
"basePrice": 800,
|
||||||
|
"customPrice": 800,
|
||||||
|
"resourceId": "EMP001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 900,
|
||||||
|
"notes": "Split booking: Elev laver hårvask, master laver farve"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK003",
|
||||||
|
"customerId": "CUST003",
|
||||||
|
"status": "created",
|
||||||
|
"createdAt": "2025-08-05T07:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV004A",
|
||||||
|
"serviceName": "Bryllupsfrisure - Del 1",
|
||||||
|
"baseDuration": 60,
|
||||||
|
"basePrice": 750,
|
||||||
|
"customPrice": 750,
|
||||||
|
"resourceId": "EMP001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serviceId": "SRV004B",
|
||||||
|
"serviceName": "Bryllupsfrisure - Del 2",
|
||||||
|
"baseDuration": 60,
|
||||||
|
"basePrice": 750,
|
||||||
|
"customPrice": 750,
|
||||||
|
"resourceId": "EMP002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 1500,
|
||||||
|
"notes": "Equal-split: To master stylister arbejder sammen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK004",
|
||||||
|
"customerId": "CUST004",
|
||||||
|
"status": "arrived",
|
||||||
|
"createdAt": "2025-08-05T10:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV005",
|
||||||
|
"serviceName": "Herreklipning",
|
||||||
|
"baseDuration": 30,
|
||||||
|
"basePrice": 350,
|
||||||
|
"customPrice": 350,
|
||||||
|
"resourceId": "EMP003"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 350
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK005",
|
||||||
|
"customerId": "CUST005",
|
||||||
|
"status": "paid",
|
||||||
|
"createdAt": "2025-08-05T11:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV006",
|
||||||
|
"serviceName": "Balayage langt hår",
|
||||||
|
"baseDuration": 120,
|
||||||
|
"basePrice": 1200,
|
||||||
|
"customPrice": 1200,
|
||||||
|
"resourceId": "EMP002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 1200,
|
||||||
|
"notes": "Kunde ønsker naturlig blond tone"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK006",
|
||||||
|
"customerId": "CUST006",
|
||||||
|
"status": "created",
|
||||||
|
"createdAt": "2025-08-06T08:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV007",
|
||||||
|
"serviceName": "Permanent",
|
||||||
|
"baseDuration": 90,
|
||||||
|
"basePrice": 900,
|
||||||
|
"customPrice": 900,
|
||||||
|
"resourceId": "EMP004"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK007",
|
||||||
|
"customerId": "CUST007",
|
||||||
|
"status": "arrived",
|
||||||
|
"createdAt": "2025-08-06T09:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV008",
|
||||||
|
"serviceName": "Highlights",
|
||||||
|
"baseDuration": 90,
|
||||||
|
"basePrice": 850,
|
||||||
|
"customPrice": 850,
|
||||||
|
"resourceId": "EMP001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serviceId": "SRV009",
|
||||||
|
"serviceName": "Styling",
|
||||||
|
"baseDuration": 30,
|
||||||
|
"basePrice": 200,
|
||||||
|
"customPrice": 200,
|
||||||
|
"resourceId": "EMP001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 1050,
|
||||||
|
"notes": "Highlights + styling samme stylist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK008",
|
||||||
|
"customerId": "CUST008",
|
||||||
|
"status": "paid",
|
||||||
|
"createdAt": "2025-08-06T10:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV010",
|
||||||
|
"serviceName": "Klipning",
|
||||||
|
"baseDuration": 45,
|
||||||
|
"basePrice": 450,
|
||||||
|
"customPrice": 450,
|
||||||
|
"resourceId": "EMP004"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 450
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK009",
|
||||||
|
"customerId": "CUST001",
|
||||||
|
"status": "created",
|
||||||
|
"createdAt": "2025-08-07T08:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV011",
|
||||||
|
"serviceName": "Farve behandling",
|
||||||
|
"baseDuration": 120,
|
||||||
|
"basePrice": 950,
|
||||||
|
"customPrice": 950,
|
||||||
|
"resourceId": "EMP002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 950
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK010",
|
||||||
|
"customerId": "CUST002",
|
||||||
|
"status": "arrived",
|
||||||
|
"createdAt": "2025-08-07T09:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV012",
|
||||||
|
"serviceName": "Skæg trimning",
|
||||||
|
"baseDuration": 20,
|
||||||
|
"basePrice": 200,
|
||||||
|
"customPrice": 200,
|
||||||
|
"resourceId": "EMP003"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK011",
|
||||||
|
"customerId": "CUST003",
|
||||||
|
"status": "paid",
|
||||||
|
"createdAt": "2025-08-07T10:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV002",
|
||||||
|
"serviceName": "Hårvask",
|
||||||
|
"baseDuration": 30,
|
||||||
|
"basePrice": 100,
|
||||||
|
"customPrice": 100,
|
||||||
|
"resourceId": "STUDENT002"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serviceId": "SRV013",
|
||||||
|
"serviceName": "Ombré",
|
||||||
|
"baseDuration": 100,
|
||||||
|
"basePrice": 1100,
|
||||||
|
"customPrice": 1100,
|
||||||
|
"resourceId": "EMP002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 1200,
|
||||||
|
"notes": "Split booking: Student hårvask, master ombré"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK012",
|
||||||
|
"customerId": "CUST004",
|
||||||
|
"status": "created",
|
||||||
|
"createdAt": "2025-08-08T08:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV014",
|
||||||
|
"serviceName": "Føntørring",
|
||||||
|
"baseDuration": 30,
|
||||||
|
"basePrice": 250,
|
||||||
|
"customPrice": 250,
|
||||||
|
"resourceId": "STUDENT001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 250
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK013",
|
||||||
|
"customerId": "CUST005",
|
||||||
|
"status": "arrived",
|
||||||
|
"createdAt": "2025-08-08T09:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV015",
|
||||||
|
"serviceName": "Opsætning",
|
||||||
|
"baseDuration": 60,
|
||||||
|
"basePrice": 700,
|
||||||
|
"customPrice": 700,
|
||||||
|
"resourceId": "EMP004"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 700,
|
||||||
|
"notes": "Fest opsætning"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK014",
|
||||||
|
"customerId": "CUST006",
|
||||||
|
"status": "created",
|
||||||
|
"createdAt": "2025-08-09T08:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV016A",
|
||||||
|
"serviceName": "Ekstensions - Del 1",
|
||||||
|
"baseDuration": 90,
|
||||||
|
"basePrice": 1250,
|
||||||
|
"customPrice": 1250,
|
||||||
|
"resourceId": "EMP001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serviceId": "SRV016B",
|
||||||
|
"serviceName": "Ekstensions - Del 2",
|
||||||
|
"baseDuration": 90,
|
||||||
|
"basePrice": 1250,
|
||||||
|
"customPrice": 1250,
|
||||||
|
"resourceId": "EMP004"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 2500,
|
||||||
|
"notes": "Equal-split: To stylister arbejder sammen om extensions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BOOK015",
|
||||||
|
"customerId": "CUST007",
|
||||||
|
"status": "noshow",
|
||||||
|
"createdAt": "2025-08-09T09:00:00Z",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceId": "SRV001",
|
||||||
|
"serviceName": "Klipning og styling",
|
||||||
|
"baseDuration": 60,
|
||||||
|
"basePrice": 500,
|
||||||
|
"customPrice": 500,
|
||||||
|
"resourceId": "EMP002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPrice": 500,
|
||||||
|
"notes": "Kunde mødte ikke op"
|
||||||
|
}
|
||||||
|
]
|
||||||
49
wwwroot/data/mock-customers.json
Normal file
49
wwwroot/data/mock-customers.json
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "CUST001",
|
||||||
|
"name": "Sofie Nielsen",
|
||||||
|
"phone": "+45 23 45 67 89",
|
||||||
|
"email": "sofie.nielsen@email.dk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CUST002",
|
||||||
|
"name": "Emma Andersen",
|
||||||
|
"phone": "+45 31 24 56 78",
|
||||||
|
"email": "emma.andersen@email.dk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CUST003",
|
||||||
|
"name": "Freja Christensen",
|
||||||
|
"phone": "+45 42 67 89 12",
|
||||||
|
"email": "freja.christensen@email.dk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CUST004",
|
||||||
|
"name": "Laura Pedersen",
|
||||||
|
"phone": "+45 51 98 76 54"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CUST005",
|
||||||
|
"name": "Ida Larsen",
|
||||||
|
"phone": "+45 29 87 65 43",
|
||||||
|
"email": "ida.larsen@email.dk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CUST006",
|
||||||
|
"name": "Caroline Jensen",
|
||||||
|
"phone": "+45 38 76 54 32",
|
||||||
|
"email": "caroline.jensen@email.dk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CUST007",
|
||||||
|
"name": "Mathilde Hansen",
|
||||||
|
"phone": "+45 47 65 43 21",
|
||||||
|
"email": "mathilde.hansen@email.dk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CUST008",
|
||||||
|
"name": "Olivia Sørensen",
|
||||||
|
"phone": "+45 56 54 32 10",
|
||||||
|
"email": "olivia.sorensen@email.dk"
|
||||||
|
}
|
||||||
|
]
|
||||||
80
wwwroot/data/mock-resources.json
Normal file
80
wwwroot/data/mock-resources.json
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "EMP001",
|
||||||
|
"name": "camilla.jensen",
|
||||||
|
"displayName": "Camilla Jensen",
|
||||||
|
"type": "person",
|
||||||
|
"avatarUrl": "/avatars/camilla.jpg",
|
||||||
|
"color": "#9c27b0",
|
||||||
|
"isActive": true,
|
||||||
|
"metadata": {
|
||||||
|
"role": "master stylist",
|
||||||
|
"specialties": ["balayage", "color", "bridal"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "EMP002",
|
||||||
|
"name": "isabella.hansen",
|
||||||
|
"displayName": "Isabella Hansen",
|
||||||
|
"type": "person",
|
||||||
|
"avatarUrl": "/avatars/isabella.jpg",
|
||||||
|
"color": "#e91e63",
|
||||||
|
"isActive": true,
|
||||||
|
"metadata": {
|
||||||
|
"role": "master stylist",
|
||||||
|
"specialties": ["highlights", "ombre", "styling"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "EMP003",
|
||||||
|
"name": "alexander.nielsen",
|
||||||
|
"displayName": "Alexander Nielsen",
|
||||||
|
"type": "person",
|
||||||
|
"avatarUrl": "/avatars/alexander.jpg",
|
||||||
|
"color": "#3f51b5",
|
||||||
|
"isActive": true,
|
||||||
|
"metadata": {
|
||||||
|
"role": "master stylist",
|
||||||
|
"specialties": ["men's cuts", "beard", "fade"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "EMP004",
|
||||||
|
"name": "viktor.andersen",
|
||||||
|
"displayName": "Viktor Andersen",
|
||||||
|
"type": "person",
|
||||||
|
"avatarUrl": "/avatars/viktor.jpg",
|
||||||
|
"color": "#009688",
|
||||||
|
"isActive": true,
|
||||||
|
"metadata": {
|
||||||
|
"role": "stylist",
|
||||||
|
"specialties": ["cuts", "styling", "perms"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "STUDENT001",
|
||||||
|
"name": "line.pedersen",
|
||||||
|
"displayName": "Line Pedersen (Elev)",
|
||||||
|
"type": "person",
|
||||||
|
"avatarUrl": "/avatars/line.jpg",
|
||||||
|
"color": "#8bc34a",
|
||||||
|
"isActive": true,
|
||||||
|
"metadata": {
|
||||||
|
"role": "student",
|
||||||
|
"specialties": ["wash", "blow-dry", "basic cuts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "STUDENT002",
|
||||||
|
"name": "mads.larsen",
|
||||||
|
"displayName": "Mads Larsen (Elev)",
|
||||||
|
"type": "person",
|
||||||
|
"avatarUrl": "/avatars/mads.jpg",
|
||||||
|
"color": "#ff9800",
|
||||||
|
"isActive": true,
|
||||||
|
"metadata": {
|
||||||
|
"role": "student",
|
||||||
|
"specialties": ["wash", "styling assistance"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue