# Solution Design Document

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user**
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design
- [x] **All database fields and variable names use camelCase**
- [x] **Syncfusion EJ2 Schedule integration documented**

---

## Constraints

CON-1 **Technology Stack**: PHP 8.x backend (Slim 2.6.2), Twig 1.44.8 templates, vanilla JavaScript with Bootstrap 5.3.3 CSS framework. MySQL (MariaDB) databases with multi-store architecture.

CON-2 **Existing Provider Architecture**: Must extend existing `ScheduleProvider` abstract class and integrate with `ScheduleManager` resolver. Cannot break existing WhenIWork/Homebase provider contracts.

CON-3 **Unified User System**: Requires 007-unified-users-auth for employee records. Employee data comes from central `users` table with `userStoreAssignments` for store-specific data including `clockPin`.

CON-4 **Multi-Store Architecture**: All scheduling data stored in store-specific databases via `dbConnectByName($store->getDbName())`. Provider selection per-store via store settings.

CON-5 **Schedule Panel Compatibility**: Must power existing schedule panel (spec 010) without UI changes. Same API response format as WhenIWork provider.

CON-6 **Naming Convention**: All database columns and PHP/JS variables MUST use camelCase (e.g., `shiftStart`, `userId`, `clockedInAt`). Exception: ALL timestamp columns use snake_case (`created_at`, `updated_at`, `deleted_at`, `approved_at`, `exported_at`, `edited_at`, `unlocked_at`). This includes soft-delete timestamps.

CON-7 **Real-Time**: Must publish to existing Ably infrastructure with channel naming pattern `{typeNum}` and event prefix `workbook:timepunch:*`.

CON-8 **Syncfusion EJ2 Schedule**: Use Syncfusion EJ2 Schedule (Community License) for all calendar UI. Timeline Week view as primary, with Day/Week/Month views. No custom calendar grid implementation.

CON-9 **Time Storage**: All shift and punch datetimes are stored in UTC in the database. All UI presentation and "day/week boundaries" calculations convert to the store's configured timezone.

## Implementation Context

**IMPORTANT**: You MUST read and analyze ALL listed context sources to understand constraints, patterns, and existing architecture.

### Required Context Sources

- ICO-1 **Existing Provider Architecture**
```yaml
# Core scheduling provider system
- file: userfrosting/src/BuyerKiosk/Workbook/ScheduleProvider.php
  relevance: CRITICAL
  why: "Abstract base class - our BuyerKioskSchedule must extend this"

- file: userfrosting/src/BuyerKiosk/Workbook/ScheduleManager.php
  relevance: CRITICAL
  why: "Provider resolver - must add BuyerKiosk as third option"

- file: userfrosting/src/BuyerKiosk/Workbook/WhenIWorkSchedule.php
  relevance: HIGH
  why: "Reference implementation - follow same patterns"

- file: userfrosting/src/BuyerKiosk/Workbook/Controllers/TimePunchController.php
  relevance: CRITICAL
  why: "Existing clock actions - extend for native provider"
```

- ICO-2 **Unified Users System (007)**
```yaml
- doc: docs/specs/007-unified-users-auth/solution-design.md
  relevance: CRITICAL
  why: "Employee data source - users table with store assignments"

- file: userfrosting/src/BuyerKiosk/Auth/Models/UnifiedUser.php
  relevance: HIGH
  why: "User model with clockPin field"
```

- ICO-3 **Schedule Panel (010)**
```yaml
- doc: docs/specs/010-employee-schedule-panel/solution-design.md
  relevance: CRITICAL
  why: "Consumer of schedule data - must maintain API compatibility"

- file: public_html/js/workspace/modules/schedule-panel/
  relevance: HIGH
  why: "Frontend components that consume schedule API"
```

- ICO-4 **Database Patterns**
```yaml
- doc: CLAUDE.md
  relevance: HIGH
  sections: [Database Conventions]
  why: "camelCase column naming requirement"

- file: userfrosting/migrations/input/
  relevance: MEDIUM
  why: "Migration JSON format patterns"
```

- ICO-5 **Syncfusion EJ2 Schedule Documentation**
```yaml
- url: https://ej2.syncfusion.com/javascript/documentation/schedule/
  relevance: CRITICAL
  sections: [getting-started, resources, appointments, views]
  why: "Primary UI component for calendar interface"

- url: https://ej2.syncfusion.com/javascript/documentation/schedule/resources
  relevance: HIGH
  why: "Employee resource configuration for Timeline view"

- url: https://ej2.syncfusion.com/javascript/documentation/schedule/appointments
  relevance: HIGH
  why: "Shift event configuration and CRUD operations"
```

### Implementation Boundaries

- **Must Preserve**:
  - `ScheduleProvider` abstract class interface (do not modify base class)
  - `ScheduleManager::resolveProvider()` contract (only extend, don't break)
  - Existing `TimePunchController` endpoints for WhenIWork
  - Schedule panel API response format
  - `WorkbookAbly` event naming convention

- **Can Modify**:
  - `ScheduleManager::resolveProvider()` - add BuyerKiosk as third provider option
  - Store settings - add `schedulingProvider` field
  - Add new tables for native scheduling data
  - Add new endpoints for schedule management (admin)

- **Must Not Touch**:
  - `WhenIWorkSchedule` class (external provider)
  - `HomebaseSchedule` class (external provider)
  - Existing employee sync providers (`WhenIWorkProvider`, `HomebaseProvider`)
  - Schedule panel UI components (010)

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    subgraph BuyerKiosk["BuyerKiosk System"]
        SM[ScheduleManager]
        BKS[BuyerKioskSchedule]
        WIW[WhenIWorkSchedule]
        HB[HomebaseSchedule]
        TPC[TimePunchController]
        SC[SchedulingController]
        WBA[WorkbookAbly]
        SF[Syncfusion EJ2 Schedule]
    end

    Manager[Store Manager] --> SC
    Manager --> SF
    SF --> SC
    Employee[Employee] --> Panel[Schedule Panel]

    Panel --> TPC
    SC --> SM
    TPC --> SM
    SM --> BKS
    SM --> WIW
    SM --> HB

    WIW --> WIWAPI[WhenIWork API]
    HB --> HBAPI[Homebase API]

    BKS --> StoreDB[(Store DB)]
    TPC --> WBA
    WBA --> Ably[Ably Realtime]
    Ably --> Panel
```

#### Interface Specifications

```yaml
# Inbound Interfaces (what calls this system)
inbound:
  - name: "Schedule Admin UI (Syncfusion)"
    type: HTTPS
    format: REST JSON
    authentication: Session-based (UserFrosting)
    data_flow: "Shift CRUD, timesheet management"

  - name: "Schedule Panel (Workbook)"
    type: HTTPS
    format: REST JSON
    authentication: Session-based
    data_flow: "Clock actions, schedule viewing"

  - name: "Employee Schedule View"
    type: HTTPS
    format: REST JSON
    authentication: Session-based (employee login)
    data_flow: "Read-only schedule access"

# Outbound Interfaces (what this system calls)
outbound:
  - name: "Ably Publisher"
    type: HTTPS REST
    format: JSON
    authentication: ABLY_KEY from $_ENV
    channel: "{typeNum}"
    events_published:
      - "workbook:timepunch:clockin"
      - "workbook:timepunch:clockout"
      - "workbook:timepunch:breakstart"
      - "workbook:timepunch:breakend"
    criticality: MEDIUM

# Data Interfaces
data:
	  - name: "Store Database"
	    type: MySQL (MariaDB)
	    connection: "dbConnectByName($store->getDbName())"
	    tables_used:
	      - "scheduleShifts" (NEW - shift definitions)
	      - "scheduleTimePunches" (NEW - clock records)
	      - "scheduleTimesheets" (NEW - weekly approval)
	      - "schedulePositions" (NEW - store positions + colors)
	      - "scheduleShiftAudit" (NEW - shift audit trail)
	      - "scheduleTimePunchAudit" (NEW - punch audit trail)
	      - "workbook_schedule_cache" (existing - provider cache)
	    data_flow: "Native scheduling data persistence"

	  - name: "Central Database"
	    type: MySQL (MariaDB)
	    connection: "dbConnectByName('kiosk_users')"
	    tables_used:
	      - "users" (from 007 - employee identity)
	      - "userStoreAssignments" (from 007 - store-specific data)
	      - "userPayRates" (NEW - hourly rate history)
	      - "scheduleOvertimeRules" (NEW - store overtime config history)
	    data_flow: "Cross-store configuration"

  - name: "Redis Cache"
    type: Redis
    connection: "Predis\\Client($_ENV['REDIS_URL'])"
    keys_used:
      - "{typeNum}_schedule_{Y-m-d}" (5-min TTL)
      - "{typeNum}_clockstatus_{employeeId}" (1-min TTL)
    data_flow: "Schedule caching for performance"
```

### Project Commands

```bash
# Testing Commands
./test.sh                           # Run all tests (1598 tests)
./test.sh --testsuite unit          # Run unit tests only
./test.sh --testsuite integration   # Run integration tests only
./test.sh --coverage                # Run with coverage report
./test.sh --stan                    # Run tests + PHPStan analysis

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse
cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Scheduling/

# Database Migrations
php userfrosting/conductor run      # Run pending migrations

# Dependencies
cd userfrosting && composer install # Install PHP dependencies
# JavaScript dependencies (Syncfusion + bundler) are installed per the separate bundler specification
# implemented before this spec.

# Deployment
./deploy.sh                         # Test + deploy

# Development Server
# Access via https://dev2.buyerkiosk.com (Chrome DevTools MCP available)
```

## Solution Strategy

- **Architecture Pattern**: Provider extension with Syncfusion UI - Add `BuyerKioskSchedule` as a third provider option, using Syncfusion EJ2 Schedule for the admin calendar interface.

- **Integration Approach**:
  - **Backend**: New `BuyerKioskSchedule` extends `ScheduleProvider`, stores data in native MySQL tables
  - **Admin UI**: Syncfusion EJ2 Schedule in Timeline Week view for schedule management
  - **Panel Integration**: `ScheduleManager` resolves to the active provider (BuyerKiosk/WhenIWork/Homebase) and returns a consistent response format for the workbook schedule panel
  - **Time Clock (Workbook)**: `TimePunchController` routes clock/break actions to the active provider backend (BuyerKiosk native tables vs external provider APIs), keeping workbook UI behavior consistent across providers
  - **Timesheets/Export**: Only available for BuyerKiosk native provider; for external providers, timesheets and exports remain managed externally and are hidden in BuyerKiosk UI

- **Justification**:
  - Leverages existing provider architecture (minimal new patterns)
  - Maintains schedule panel compatibility (no UI changes)
  - Syncfusion provides 80%+ of calendar UI requirements out-of-the-box
  - Native data enables future features (forecasting, event integration)
  - Clear separation between admin (scheduling) and kiosk (clock actions)
  - Rapid development: weeks instead of months for custom calendar

- **Key Decisions**:
  1. **UI Framework**: Syncfusion EJ2 Schedule for calendar (not custom implementation)
  2. **Primary View**: Timeline Week with employees as resources
  3. **Provider Selection**: Store setting `schedulingProvider` determines active provider
  4. **Data Storage**: Store-specific tables for scalability and isolation
  5. **Overtime Rules**: Central table for cross-store configuration sharing
  6. **Timesheet Approval**: Weekly approval workflow with audit trail
  7. **API Compatibility**: Same response format as WhenIWork for panel

## Building Block View

### Components

```mermaid
graph TB
    subgraph Admin["Admin UI (Syncfusion EJ2)"]
        SFS[Syncfusion Schedule]
        TSD[TimesheetDashboard.js]
        CFG[OvertimeConfig.js]
    end

    subgraph Controllers["Backend Controllers"]
        SC[SchedulingController]
        TSC[TimesheetController]
        TPC[TimePunchController]
    end

    subgraph Providers["Schedule Providers"]
        SM[ScheduleManager]
        BKS[BuyerKioskSchedule]
        WIW[WhenIWorkSchedule]
        HB[HomebaseSchedule]
    end

	    subgraph Services["Business Logic"]
	        OTC[OvertimeCalculator]
	        LCC[LaborCostCalculator]
	    end

    subgraph Data["Data Layer"]
        SR[ShiftRepository]
        TPR[TimePunchRepository]
        TSR[TimesheetRepository]
    end

    SFS --> SC
    TSD --> TSC
    CFG --> SC

    SC --> SM
    TSC --> SM
    TPC --> SM

    SM --> BKS
    SM --> WIW
    SM --> HB

    BKS --> SR
	    BKS --> TPR

	    TSC --> OTC
	    TSC --> LCC

    OTC --> TSR
    LCC --> TSR
```

### Directory Map

**Backend (PHP) - New Files**
```
userfrosting/src/BuyerKiosk/
├── Scheduling/                              # NEW: Module directory
│   ├── BuyerKioskSchedule.php              # NEW: Native schedule provider
│   ├── Controllers/
│   │   ├── SchedulingController.php        # NEW: Admin schedule CRUD (Syncfusion API)
│   │   └── TimesheetController.php         # NEW: Timesheet management
│   ├── Services/
│   │   ├── OvertimeCalculator.php          # NEW: Overtime rule engine
│   │   ├── LaborCostCalculator.php         # NEW: Cost calculations
│   │   └── TimesheetExporter.php           # NEW: CSV/Excel export
│   ├── Repositories/
│   │   ├── ShiftRepository.php             # NEW: Shift data access
│   │   ├── TimePunchRepository.php         # NEW: Time punch data access
│   │   ├── PositionRepository.php          # NEW: Store positions + colors
│   │   ├── ShiftAuditRepository.php        # NEW: Shift audit trail writes/reads
│   │   └── TimePunchAuditRepository.php    # NEW: Punch audit trail writes/reads
│   │   └── TimesheetRepository.php         # NEW: Timesheet data access (BuyerKiosk provider only)
│   └── Models/
│       ├── Shift.php                       # NEW: Shift entity
│       ├── TimePunch.php                   # NEW: Time punch entity
│       ├── Position.php                    # NEW: Store position entity
│       └── Timesheet.php                   # NEW: Timesheet entity
├── Workbook/
│   └── ScheduleManager.php                 # MODIFY: Add BuyerKiosk provider
```

**Backend (PHP) - Modified Files**
```
userfrosting/
├── src/BuyerKiosk/Workbook/
│   ├── ScheduleManager.php                 # MODIFY: Add BuyerKiosk to resolver
│   └── Controllers/
│       └── TimePunchController.php         # MODIFY: Route to active provider backend
├── routes/
│   ├── scheduling.php                      # NEW: Admin scheduling routes
│   └── workbook/
│       └── timepunch.php                   # MODIFY: Native provider support
├── templates/themes/default/
│   └── scheduling/                         # NEW: Admin templates
│       ├── calendar.html                   # NEW: Syncfusion Schedule container
│       ├── timesheets.html                 # NEW: Timesheet dashboard
│       └── settings.html                   # NEW: Overtime/provider settings
```

**Frontend (JavaScript) - New Files**
```
public_html/js/
├── scheduling/                             # NEW: Admin module
│   ├── ScheduleCalendar.js                # NEW: Syncfusion Schedule initialization
│   ├── TimesheetDashboard.js              # NEW: Timesheet management
│   ├── TimesheetDetail.js                 # NEW: Employee timesheet view
│   └── OvertimeConfig.js                  # NEW: Overtime rule config
```

**Syncfusion Assets**
```
public_html/
├── js/vendor/
│   └── syncfusion/                         # NEW: Syncfusion EJ2 bundle
│       └── ej2.min.js                     # Full EJ2 bundle (or schedule-specific)
├── css/vendor/
│   └── syncfusion/                         # NEW: Syncfusion styles
│       └── bootstrap5.css                 # Bootstrap 5 theme for EJ2
```

**Styles (CSS) - New Files**
```
public_html/css/
├── scheduling/                             # NEW: Admin styles
│   ├── schedule-customizations.css        # NEW: Syncfusion theme overrides
│   └── timesheets.css                     # NEW: Timesheet styles
```

**Database Migrations**
```
userfrosting/migrations/input/
├── 20251220_013_001_schedule_shifts.json       # NEW: Shifts table
├── 20251220_013_002_schedule_time_punches.json # NEW: Time punches table
├── 20251220_013_003_schedule_timesheets.json   # NEW: Timesheets table
├── 20251220_013_004_schedule_positions.json    # NEW: Store positions + colors
├── 20251220_013_005_schedule_shift_audit.json  # NEW: Shift audit trail
├── 20251220_013_006_schedule_punch_audit.json  # NEW: Punch audit trail
├── 20251220_013_007_user_pay_rates.json        # NEW: Hourly rate history (central)
├── 20251220_013_008_overtime_rules.json        # NEW: Overtime config history (central)
├── 20251220_013_009_store_scheduling_config.json # NEW: Store provider + clock/rounding settings
```

## Syncfusion EJ2 Schedule Integration

### UI Reference Screenshots

These screenshots are UI references for the Syncfusion schedule views and the BuyerKiosk native timesheet approval UI.

- Day view: `docs/specs/013-employee-scheduling/day_view.png`
- Week view: `docs/specs/013-employee-scheduling/week_view.png`
- Month view: `docs/specs/013-employee-scheduling/month_view.png`
- Timesheet approval: `docs/specs/013-employee-scheduling/timesheet_approval.png`

![Scheduling Day View](day_view.png)

![Scheduling Week View](week_view.png)

![Scheduling Month View](month_view.png)

![Timesheet Approval](timesheet_approval.png)

### Primary View: Timeline Week with Employee Resources

The primary scheduling interface uses Syncfusion's Timeline Week view with employees configured as resources.

#### Syncfusion Schedule Initialization

```javascript
// public_html/js/scheduling/ScheduleCalendar.js

// NOTE: Exact module imports / asset loading are defined by the bundler specification implemented
// before this spec. This example focuses on EJ2 Schedule configuration and API wiring.
//
// Example (bundler):
// import { Schedule, TimelineViews, Day, Week, Month, DragAndDrop, Resize } from '@syncfusion/ej2-schedule';
// Schedule.Inject(TimelineViews, Day, Week, Month, DragAndDrop, Resize);

class ScheduleCalendar {
    constructor(containerId, typeNum, options = {}) {
        this.typeNum = typeNum;
        this.containerId = containerId;
        this.schedule = null;
        this.positions = [];
        this.options = options;
    }

    async initialize() {
        const employees = await this.fetchEmployees();
        this.positions = await this.fetchPositions();

        this.schedule = new Schedule({
            // View Configuration
            currentView: 'TimelineWeek',
            views: [
                { option: 'Day' },
                { option: 'Week' },
                { option: 'TimelineWeek', displayName: 'Schedule' },
                { option: 'Month' }
            ],

            // Time Configuration
            startHour: '06:00',
            endHour: '23:00',
            timeScale: {
                enable: true,
                interval: 60,
                slotCount: 2  // 30-minute slots
            },
            firstDayOfWeek: 1, // Monday

            // Resource Configuration (Employees)
            group: {
                resources: ['Employees']
            },
            resources: [{
                field: 'employeeId',
                title: 'Employee',
                name: 'Employees',
                dataSource: employees,
                textField: 'name',
                idField: 'id'
            }],

            // Data Binding
            eventSettings: {
                dataSource: [],
                fields: {
                    id: 'shiftId',
                    subject: { name: 'position', title: 'Position' },
                    startTime: { name: 'shiftStart' },
                    endTime: { name: 'shiftEnd' },
                    resourceId: { name: 'employeeId' }
                }
            },

            // Behavior Settings
            allowDragAndDrop: true,
            allowResizing: true,
            allowMultiDrag: true,
            allowOverlap: false,  // Prevent double-booking

            // Event Handlers
            actionBegin: this.onActionBegin.bind(this),
            actionComplete: this.onActionComplete.bind(this),
            eventRendered: this.onEventRendered.bind(this),
            popupOpen: this.onPopupOpen.bind(this),

            // Templates
            eventTemplate: this.getEventTemplate(),
            resourceHeaderTemplate: this.getResourceHeaderTemplate()
        });

        this.schedule.appendTo(`#${this.containerId}`);
        await this.loadShifts();
    }

    getEventTemplate() {
        return `
            <div class="shift-block" style="background: \${positionColor}">
                <div class="shift-time">\${formatTime(shiftStart)} - \${formatTime(shiftEnd)}</div>
                <div class="shift-position">\${position}</div>
            </div>
        `;
    }

    getResourceHeaderTemplate() {
        return `
            <div class="employee-resource">
                <div class="employee-avatar" style="background: \${positionColor}">
                    \${name.charAt(0)}
                </div>
                <div class="employee-info">
                    <div class="employee-name">\${name}</div>
                    <div class="employee-hours">\${weeklyHours} hrs</div>
                </div>
            </div>
        `;
    }

    async fetchEmployees() {
        const response = await fetch(`/api/${this.typeNum}/schedule/employees`);
        return response.json();
    }

    async fetchPositions() {
        const response = await fetch(`/api/${this.typeNum}/schedule/positions`);
        return response.json();
    }

    async loadShifts() {
        const currentDate = this.schedule.selectedDate;
        const weekStart = this.getWeekStart(currentDate);
        const weekEnd = this.getWeekEnd(currentDate);

        const response = await fetch(
            `/api/${this.typeNum}/schedule/shifts?start=${weekStart}&end=${weekEnd}`
        );
        const shifts = await response.json();

        this.schedule.eventSettings.dataSource = shifts;
        this.schedule.refreshEvents();
    }

    // CRUD Event Handlers
    async onActionBegin(args) {
        if (args.requestType === 'eventCreate') {
            args.cancel = true; // Cancel default, use our API
            await this.createShift(args.data[0]);
        } else if (args.requestType === 'eventChange') {
            args.cancel = true;
            await this.updateShift(args.data);
        } else if (args.requestType === 'eventRemove') {
            args.cancel = true;
            await this.deleteShift(args.data[0].shiftId);
        }
    }

    async createShift(shiftData) {
        const response = await fetch(`/api/${this.typeNum}/schedule/shifts`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                employeeId: shiftData.employeeId,
                shiftStart: shiftData.shiftStart,
                shiftEnd: shiftData.shiftEnd,
                positionId: shiftData.positionId ?? null
            })
        });

        if (response.ok) {
            await this.loadShifts();
            this.updateLaborCosts();
        } else {
            this.showError(await response.text());
        }
    }

    async updateShift(shiftData) {
        const response = await fetch(`/api/${this.typeNum}/schedule/shifts/${shiftData.shiftId}`, {
            method: 'PUT',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                employeeId: shiftData.employeeId,
                shiftStart: shiftData.shiftStart,
                shiftEnd: shiftData.shiftEnd,
                positionId: shiftData.positionId ?? null,
                updatedAt: shiftData.updatedAt
            })
        });

        if (response.ok) {
            await this.loadShifts();
            this.updateLaborCosts();
        } else {
            this.showError(await response.text());
        }
    }

    async deleteShift(shiftId) {
        const response = await fetch(`/api/${this.typeNum}/schedule/shifts/${shiftId}`, {
            method: 'DELETE'
        });

        if (response.ok) {
            await this.loadShifts();
            this.updateLaborCosts();
        }
    }
}

export default ScheduleCalendar;
```

### Position-Based Color Scheme

Position colors are configured per-store via the `schedulePositions` table and served through the Positions API.

Shifts reference a `positionId`, and shift responses include `position` and `positionColor` derived from that position so the Syncfusion `eventTemplate` can render consistent colors.

```javascript
// Positions are loaded from the API and managed by store admins
const positions = await fetch(`/api/${typeNum}/schedule/positions`).then(r => r.json());

// Shifts include positionId and can be rendered using the derived positionColor
const shifts = await fetch(`/api/${typeNum}/schedule/shifts?start=${start}&end=${end}`).then(r => r.json());
// shift.positionColor is used by the eventTemplate background.
```

### Syncfusion Theme Customizations

```css
/* public_html/css/scheduling/schedule-customizations.css */

/* Match BuyerKiosk admin theme */
.e-schedule {
    --schedule-header-bg: var(--bs-light);
    --schedule-border-color: var(--bs-border-color);
}

/* Resource header (employee row) styling */
.e-schedule .e-resource-cells {
    border-right: 2px solid var(--bs-border-color);
}

.employee-resource {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px;
}

.employee-avatar {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: 600;
}

.employee-name {
    font-weight: 500;
    font-size: 14px;
}

.employee-hours {
    font-size: 12px;
    color: var(--bs-secondary);
}

/* Shift block styling */
.shift-block {
    padding: 4px 8px;
    border-radius: 4px;
    height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
}

.shift-time {
    font-weight: 500;
    font-size: 13px;
    color: white;
}

.shift-position {
    font-size: 11px;
    text-transform: uppercase;
    color: rgba(255, 255, 255, 0.8);
}

/* Timeline header styling */
.e-schedule .e-timeline-view .e-date-header-wrap table td {
    background: var(--bs-light);
}

/* Labor cost summary row */
.labor-cost-summary {
    background: var(--bs-light);
    border-top: 2px solid var(--bs-border-color);
    padding: 12px 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.labor-cost-total {
    font-size: 16px;
    font-weight: 600;
}

.labor-cost-warning {
    color: var(--bs-danger);
}
```

### Copy Previous Week Implementation

```javascript
// Copy previous week functionality (custom, not Syncfusion)
async copyPreviousWeek() {
    const targetWeekStart = this.getWeekStart(this.schedule.selectedDate);
    const sourceWeekStart = new Date(targetWeekStart);
    sourceWeekStart.setDate(sourceWeekStart.getDate() - 7);

    // Fetch shifts from previous week
    const response = await fetch(
        `/api/${this.typeNum}/schedule/shifts/copy-preview?` +
        `sourceStart=${sourceWeekStart.toISOString()}&` +
        `targetStart=${targetWeekStart.toISOString()}`
    );

    const preview = await response.json();

    // Show confirmation modal with conflict preview
    this.showCopyPreviewModal(preview, async (overwriteConflicts) => {
        const copyResponse = await fetch(`/api/${this.typeNum}/schedule/shifts/copy`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                sourceWeekStart: sourceWeekStart.toISOString(),
                targetWeekStart: targetWeekStart.toISOString(),
                overwriteConflicts
            })
        });

        if (copyResponse.ok) {
            await this.loadShifts();
            this.showSuccess(`Copied ${preview.shiftCount} shifts`);
        }
    });
}
```

## API Specifications

### Shift Management API

```yaml
# Get shifts for date range
Endpoint: GET /api/:typeNum/schedule/shifts
Parameters:
  start: ISO date (required)
  end: ISO date (required)
Response:
  - shiftId: int
    employeeId: int
    employeeName: string
    shiftStart: ISO datetime
    shiftEnd: ISO datetime
    positionId: int|null
    position: string|null
    positionColor: string|null
    updatedAt: ISO datetime

# Create shift
Endpoint: POST /api/:typeNum/schedule/shifts
Request:
  employeeId: int (required)
  shiftStart: ISO datetime (required)
  shiftEnd: ISO datetime (required)
  positionId: int (optional)
Response:
  shiftId: int
  success: boolean
  laborCost: { regular: float, overtime: float, total: float }
Error:
  code: "OVERLAP"
  message: "Employee already has a shift during this time"

# Update shift
Endpoint: PUT /api/:typeNum/schedule/shifts/:shiftId
Request:
  employeeId: int
  shiftStart: ISO datetime
  shiftEnd: ISO datetime
  positionId: int|null
  updatedAt: ISO datetime (required for optimistic concurrency)
Response:
  success: boolean
  laborCost: { regular: float, overtime: float, total: float }
Error:
  code: "STALE_WRITE"
  httpStatus: 409
  message: "Shift changed since you loaded it. Please reload and try again."

# Delete shift (soft delete)
Endpoint: DELETE /api/:typeNum/schedule/shifts/:shiftId
Response:
  success: boolean

# Copy previous week preview (conflicts)
Endpoint: GET /api/:typeNum/schedule/shifts/copy-preview
Parameters:
  sourceStart: ISO date (required)
  targetStart: ISO date (required)
Response:
  shiftCount: int
  conflicts: [object]

# Copy previous week
Endpoint: POST /api/:typeNum/schedule/shifts/copy
Request:
  sourceWeekStart: ISO date
  targetWeekStart: ISO date
  overwriteConflicts: boolean
Response:
  success: boolean
  copiedCount: int
  skippedCount: int
```

### Employee Resources API

```yaml
# Get employees for scheduling
Endpoint: GET /api/:typeNum/schedule/employees
Response:
  - id: int
    name: string
    position: string
    positionColor: string
    hourlyRate: float (effective rate used for current scheduling context)
    weeklyHours: float (current week scheduled)
```

### Positions API

```yaml
# Get store positions
Endpoint: GET /api/:typeNum/schedule/positions
Response:
  - positionId: int
    name: string
    color: string
    isActive: boolean
    sortOrder: int
```

### Labor Cost API

```yaml
# Get labor cost summary for week
Endpoint: GET /api/:typeNum/schedule/labor-cost
Parameters:
  weekStart: ISO date
Response:
  regularHours: float
  overtimeHours: float
  doubletimeHours: float
  regularCost: float
  overtimeCost: float
  doubletimeCost: float
  totalCost: float
  employeeBreakdown:
    - employeeId: int
      name: string
      regular: float
      overtime: float
      doubletime: float
      totalCost: float
      warnings: [string]
```

### Store Scheduling Config API

This API is the source of truth for provider selection and store-level scheduling policy defaults.

```yaml
# Get store scheduling configuration
Endpoint: GET /api/:typeNum/schedule/config
Response:
  schedulingProvider: string # 'none' | 'buyerkiosk' | 'wheniwork' | 'homebase'
  clockInEarlyMinutes: int
  clockInLateMinutes: int
  clockOutLateMinutes: int
  requireManagerOverrideOutsideClockWindow: boolean
  requireManagerOverrideForUnscheduledClockIn: boolean
  payrollRoundingIncrementMinutes: int
  payrollRoundingMode: string # 'none' | 'nearest' | 'up' | 'down'
  workWeekStartDay: string # 'sun'..'sat'
  workWeekStartTimeLocal: string # 'HH:MM:SS'
  statutoryHolidaysMode: string # 'none' | 'manual_dates'
  statutoryHolidayDates: [string] # ['YYYY-MM-DD'] local dates
  schedulePermissions: object|null # role -> [capability]
  externalProviderManualPunchFallbackEnabled: boolean

# Update store scheduling configuration
Endpoint: PUT /api/:typeNum/schedule/config
Request: same fields as GET (partial update allowed)
Response:
  success: boolean
```

Access control:
- `GET`: Store Manager+ (and Owner/GM)
- `PUT`: Owner/GM/Store Manager by default (configurable via `schedulePermissions`)

### Overtime Configuration API (BuyerKiosk Provider Only)

```yaml
# Get overtime configuration for store
Endpoint: GET /api/:typeNum/schedule/overtime/config
Response:
  countryCode: string  # 'US' or 'CA'
  regionCode: string   # e.g., 'CA', 'ON', 'DC'
  ruleType: string     # 'preset' or 'custom'
  presetKey: string    # e.g., 'US-CA', 'CA-ON'
  effectiveStartDate: ISO date
  ruleSchemaVersion: int
  ruleJson: object

# Set overtime configuration for store (future-dated by effectiveStartDate)
Endpoint: PUT /api/:typeNum/schedule/overtime/config
Request:
  countryCode: string
  regionCode: string
  ruleType: string
  presetKey: string
  effectiveStartDate: ISO date
  ruleSchemaVersion: int
  ruleJson: object
Response:
  success: boolean
```

### Workbook Time Clock (Provider-Agnostic)

The workbook time clock UI behaves the same for all providers. `TimePunchController` routes requests to the active provider backend:
- **BuyerKiosk provider**: persist to `scheduleTimePunches` + write audit rows, then publish Ably events.
- **WhenIWork/Homebase provider**: call the provider API to record punches, then publish Ably events.

**Provider Failure Handling (Default + Configurable)**
- Default behavior: if the provider API call fails, show a blocking error and do not create a local punch record for external providers (source-of-truth is external).
- Store-configurable fallback (default off): allow manager to create a “manual punch” record locally for audit/visibility only, marked `isManualEntry=true` with a required `manualEntryNote` like “External provider outage”. This record is excluded from exports and timesheets when using an external provider (since those are hidden), but can help operationally when the provider is down.
- Always log provider errors with correlation IDs and surface a lightweight “provider outage” banner to managers.

**Clock Window + Manager Approval Policy (Store-Configurable)**
- Store settings define the allowed clock window relative to scheduled shift start/end:
  - `clockInEarlyMinutes` (e.g., 15)
  - `clockInLateMinutes` (e.g., 15)
  - `clockOutLateMinutes` (e.g., 30)
- Clock-in outside the configured window requires manager override (manager PIN approval).
- Unscheduled clock-in requires manager override (manager approval at time of clock-in).

**Payroll Rounding (BuyerKiosk Provider Only)**
- Timesheets and exports use store-configured rounding rules:
  - `payrollRoundingIncrementMinutes` (e.g., 6, 15)
  - `payrollRoundingMode` (`nearest`, `up`, `down`, `none`)
- Raw punch times remain unrounded and are the audit source of truth.
- Default: `payrollRoundingMode='none'` (no rounding).

**Workweek / Pay Period Settings (Store-Configurable; Weekly Only in MVP)**
- Store defines the workweek boundary used for timesheets and overtime calculation when the jurisdiction does not mandate a fixed week:
  - `workWeekStartDay` (`mon` default)
  - `workWeekStartTimeLocal` (`00:00` default, store timezone)
- Jurisdiction presets may override the workweek for overtime calculation when explicitly defined (e.g., BC weekly overtime uses Sunday–Saturday).
- Timesheet UI defaults to aligning its week boundary to the effective overtime workweek (store config + any jurisdiction override).

**Statutory Holidays (Optional; Manual in MVP)**
- Some jurisdictions adjust weekly overtime thresholds when a statutory holiday occurs (e.g., NWT/YT deem 32-hour weeks in certain cases).
- Default: BuyerKiosk does not attempt to automatically determine statutory holidays/eligibility.
- Optional store config (default off) to support the overtime rule engine:
  - `statutoryHolidaysMode` (`none` | `manual_dates`)
  - `statutoryHolidayDates` (array of `YYYY-MM-DD` local dates when `manual_dates`)
- When enabled, the calculator may apply `ruleJson.holidayWeek.weeklyThresholdHours` for jurisdictions that define it, but stores must verify eligibility rules.

**Role-Based Permissions (Store-Configurable; Solid Default)**
Default capability matrix (customizable in store settings):
- Owner / General Manager / Store Manager: full access (schedule CRUD, copy week, positions, overtime config, punch edits, timesheet approval, export)
- Shift Lead: schedule CRUD + manager override at clock-in/out; no overtime config; no export by default
- Buyer / Sales Associate: view schedule + self clock-in/out/breaks only

Store settings should represent this as a structured policy (e.g., `schedulePermissions`) to avoid hardcoding role names in code.

## Database Schema

### Store Scheduling Settings (Central DB: `stores` Table Additions)

Scheduling configuration is stored alongside other store integration settings in the central `stores` table (database `kiosk_buykiosk`).

Migration artifact:
- `userfrosting/migrations/input/20251220_013_009_store_scheduling_config.json`

#### Columns (with defaults)

```sql
-- Central DB: kiosk_buykiosk.stores
ALTER TABLE stores
  ADD COLUMN schedulingProvider ENUM('none','buyerkiosk','wheniwork','homebase') NOT NULL DEFAULT 'none',
  ADD COLUMN clockInEarlyMinutes SMALLINT NOT NULL DEFAULT 15,
  ADD COLUMN clockInLateMinutes SMALLINT NOT NULL DEFAULT 15,
  ADD COLUMN clockOutLateMinutes SMALLINT NOT NULL DEFAULT 30,
  ADD COLUMN requireManagerOverrideOutsideClockWindow TINYINT(1) NOT NULL DEFAULT 1,
  ADD COLUMN requireManagerOverrideForUnscheduledClockIn TINYINT(1) NOT NULL DEFAULT 1,
  ADD COLUMN payrollRoundingIncrementMinutes SMALLINT NOT NULL DEFAULT 15,
  ADD COLUMN payrollRoundingMode ENUM('none','nearest','up','down') NOT NULL DEFAULT 'none',
  ADD COLUMN workWeekStartDay ENUM('sun','mon','tue','wed','thu','fri','sat') NOT NULL DEFAULT 'mon',
  ADD COLUMN workWeekStartTimeLocal TIME NOT NULL DEFAULT '00:00:00',
  ADD COLUMN statutoryHolidaysMode ENUM('none','manual_dates') NOT NULL DEFAULT 'none',
  ADD COLUMN statutoryHolidayDates TEXT NULL,
  ADD COLUMN schedulePermissions TEXT NULL,
  ADD COLUMN externalProviderManualPunchFallbackEnabled TINYINT(1) NOT NULL DEFAULT 0;
```

#### Field Notes

- `workWeekStartDay` + `workWeekStartTimeLocal` are interpreted in the store’s configured timezone.
- `statutoryHolidayDates` is JSON text when `statutoryHolidaysMode='manual_dates'`, e.g. `["2025-12-25","2026-01-01"]` (store-local dates).
- `schedulePermissions` is JSON text. If `NULL`, the application uses the built-in default role matrix (Owner/GM/Store Manager full access; Shift Lead limited; Buyer/Sales Associate self-only).

Example `schedulePermissions` JSON (shape):

```json
{
  "Owner": ["schedule_manage","timesheets_approve","timesheets_export","punches_edit","overtime_config_manage","positions_manage","provider_manage"],
  "General Manager": ["schedule_manage","timesheets_approve","timesheets_export","punches_edit","overtime_config_manage","positions_manage","provider_manage"],
  "Store Manager": ["schedule_manage","timesheets_approve","timesheets_export","punches_edit","overtime_config_manage","positions_manage"],
  "Shift Lead": ["schedule_manage","timesheets_view","punches_manager_override"],
  "Buyer": ["schedule_view","punches_self"],
  "Sales Associate": ["schedule_view","punches_self"]
}
```

### scheduleShifts Table (Store DB)

```sql
CREATE TABLE scheduleShifts (
    shiftId INT AUTO_INCREMENT PRIMARY KEY,
    employeeId INT NOT NULL,
    shiftStart DATETIME NOT NULL, -- UTC
    shiftEnd DATETIME NOT NULL,   -- UTC
    positionId INT NULL,
    notes TEXT,
    createdByUserId INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL,

    INDEX idx_employee_start (employeeId, shiftStart),
    INDEX idx_shiftStart (shiftStart),
    INDEX idx_deleted (deleted_at)
);
```

### scheduleTimePunches Table (Store DB)

```sql
CREATE TABLE scheduleTimePunches (
    punchId INT AUTO_INCREMENT PRIMARY KEY,
    employeeId INT NOT NULL,
    shiftId INT NULL,  -- May be null for unscheduled punches
    punchType ENUM('clockIn', 'clockOut', 'breakStart', 'breakEnd') NOT NULL,
    punchTime DATETIME NOT NULL, -- UTC
    breakType ENUM('paid', 'unpaid') NULL,  -- Only for break punches
    isManualEntry BOOLEAN DEFAULT FALSE,
    manualEntryNote TEXT,
    isUnscheduled BOOLEAN DEFAULT FALSE,
    isManagerOverride BOOLEAN DEFAULT FALSE,
    approvedByUserId INT NULL,
    approved_at TIMESTAMP NULL,
    approvalNote TEXT,
    enteredByUserId INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    edited_at TIMESTAMP NULL,
    editedByUserId INT NULL,
    editNote TEXT,

    INDEX idx_employee_time (employeeId, punchTime),
    INDEX idx_shift (shiftId)
);
```

### scheduleTimesheets Table (Store DB)

```sql
CREATE TABLE scheduleTimesheets (
    timesheetId INT AUTO_INCREMENT PRIMARY KEY,
    employeeId INT NOT NULL,
    weekStartDate DATE NOT NULL,
    weekEndDate DATE NOT NULL,
    scheduledTotalHours DECIMAL(6,2) NOT NULL DEFAULT 0,
    scheduledRegularHours DECIMAL(6,2) NOT NULL DEFAULT 0,
    scheduledOvertimeHours DECIMAL(6,2) NOT NULL DEFAULT 0,
    scheduledDoubletimeHours DECIMAL(6,2) NOT NULL DEFAULT 0,
    totalHours DECIMAL(6,2) NOT NULL DEFAULT 0,
    regularHours DECIMAL(6,2) NOT NULL DEFAULT 0,
    overtimeHours DECIMAL(6,2) NOT NULL DEFAULT 0,
    doubletimeHours DECIMAL(6,2) NOT NULL DEFAULT 0,
    totalPay DECIMAL(10,2) NOT NULL DEFAULT 0,
    status ENUM('pending', 'approved', 'exported') DEFAULT 'pending',
    approvedByUserId INT NULL,
    approved_at TIMESTAMP NULL,
    exported_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE INDEX idx_employee_week (employeeId, weekStartDate),
    INDEX idx_status (status),
    INDEX idx_week (weekStartDate)
);
```

### scheduleOvertimeRules Table (Central DB)

```sql
CREATE TABLE scheduleOvertimeRules (
    ruleId INT AUTO_INCREMENT PRIMARY KEY,
    typeNum VARCHAR(10) NOT NULL,
    countryCode CHAR(2) NOT NULL,  -- 'US' or 'CA'
    regionCode VARCHAR(3) NOT NULL, -- US state (incl. 'DC') or Canadian province/territory
    ruleType ENUM('preset', 'custom') NOT NULL DEFAULT 'preset',
    presetKey VARCHAR(20) NOT NULL, -- e.g., 'US-CA', 'CA-ON'
    effectiveStartDate DATE NOT NULL,
    ruleSchemaVersion INT NOT NULL DEFAULT 1,
    ruleJson TEXT NOT NULL, -- JSON rule definition for jurisdiction preset or custom override
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE INDEX idx_typenum_effective (typeNum, effectiveStartDate)
);
```

#### Overtime Jurisdiction Preset Library

Overtime rules vary by US state and Canadian province/territory, and may change over time. BuyerKiosk supports this by:
- Storing the store's selected jurisdiction (`countryCode` + `regionCode`) and the applied rules as JSON (`ruleJson`) with an effective start date.
- Providing a preset library keyed by `presetKey` (e.g., `US-CA`, `CA-ON`) that can be updated over time.
- Allowing a per-store "Custom" override to cover edge cases and exceptions.

Preset library artifact:
- `docs/specs/013-employee-scheduling/overtime-presets.json`

The preset library should live in versioned code (or a migration-seeded table) and be treated as product data with change tracking and tests.

**Coverage**
- US: all states + District of Columbia (DC)
- Canada: all provinces + territories

**Jurisdiction Codes**
- US states (plus DC): `AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC`
- Canada provinces/territories: `AB BC MB NB NL NS NT NU ON PE QC SK YT`

**Compliance Note**
This system provides configurable calculations and presets as an aid. Stores must verify their configuration for compliance.

#### Overtime Rule JSON (Rule Engine Contract)

The preset library `ruleJson` values and store-level Custom overrides must conform to a stable schema so the `OvertimeCalculator` can compute hours consistently.

Minimal v1 schema (extensible):

```yaml
ruleJson:
  overtimeMultiplier: number         # typically 1.5
  doubletimeMultiplier: number|null  # typically 2.0 when supported
  weeklyOvertimeThresholdHours: number|null  # e.g., 40, 44, 48
  dailyRulesMode: string             # 'absolute' | 'scheduledDailyHours' (default 'absolute')
  dailyRules:                        # ordered low -> high thresholds
    - thresholdHours: number         # e.g., 8, 12
      multiplier: number             # 1.5 or 2.0
  consecutiveDayRules:               # optional (e.g., CA 7th day)
    - dayNumber: number              # e.g., 7
      thresholdHours: number         # 0 or 8
      multiplier: number
      notes: string
  workWeekStartDay: string|null      # 'sun'..'sat' (optional; otherwise store config)
  weeklyCountableHoursPerDayCap: number|null  # e.g., 8 for BC: only first 8h/day count toward weekly OT
  holidayWeek:                       # optional jurisdiction-specific adjustments
    weeklyThresholdHours: number|null  # e.g., 32 (NWT/YT statutory-holiday week)
    notes: [string]
  notes: [string]
```

Custom rules UI should allow editing only supported fields, and preserve unknown fields for forward-compatibility.

#### Overtime Calculation Strategy (Avoid Double Counting)

Overtime can be defined by daily, weekly, and other rules (e.g., consecutive-day rules). The calculator must ensure hours are not paid as overtime twice.

Recommended strategy:
- Compute worked time as per-shift segments in store local time for day/week boundaries, but store all timestamps in UTC.
- For each employee/week:
  - Compute **daily overtime buckets** using `dailyRules` (and `dailyRulesMode` where applicable).
  - Compute **weekly eligible hours** as the sum of **countable daily regular hours** (apply `weeklyCountableHoursPerDayCap` when defined; otherwise use total worked hours that are not already daily overtime).
  - If `weeklyOvertimeThresholdHours` is set, allocate weekly overtime hours above the threshold from the remaining regular hours pool.
  - Apply `consecutiveDayRules` as an additional daily overlay (e.g., CA 7th consecutive day) before weekly allocation, so weekly OT is computed from what remains.
- For jurisdictions with statutory-holiday week adjustments (e.g., NWT/YT), support an optional `holidayWeek.weeklyThresholdHours` override when the configured workweek contains a statutory holiday that qualifies for the rule.

If the rule engine cannot fully model a jurisdiction's special cases (agreements, exemptions, banking, holiday definitions), require stores to use Custom overrides and show a compliance disclaimer in UI.

### schedulePositions Table (Store DB)

```sql
CREATE TABLE schedulePositions (
    positionId INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    color VARCHAR(20) NOT NULL,
    sortOrder INT NOT NULL DEFAULT 0,
    isActive BOOLEAN NOT NULL DEFAULT TRUE,
    createdByUserId INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL,

    UNIQUE INDEX idx_name (name),
    INDEX idx_active (isActive)
);
```

### userPayRates Table (Central DB)

```sql
CREATE TABLE userPayRates (
    payRateId INT AUTO_INCREMENT PRIMARY KEY,
    userId INT NOT NULL,
    typeNum VARCHAR(10) NULL, -- Optional store-specific override; NULL = global default
    hourlyRate DECIMAL(10,2) NOT NULL,
    effectiveAt DATETIME NOT NULL, -- UTC
    createdByUserId INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    INDEX idx_user_effective (userId, effectiveAt),
    INDEX idx_typenum_user_effective (typeNum, userId, effectiveAt)
);
```

### scheduleShiftAudit Table (Store DB)

```sql
CREATE TABLE scheduleShiftAudit (
    auditId INT AUTO_INCREMENT PRIMARY KEY,
    shiftId INT NOT NULL,
    action ENUM('create', 'update', 'delete') NOT NULL,
    actorUserId INT NOT NULL,
    occurredAt DATETIME NOT NULL, -- UTC
    oldValueJson TEXT NULL,
    newValueJson TEXT NULL,
    note TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    INDEX idx_shift (shiftId),
    INDEX idx_occurred (occurredAt)
);
```

### scheduleTimePunchAudit Table (Store DB)

```sql
CREATE TABLE scheduleTimePunchAudit (
    auditId INT AUTO_INCREMENT PRIMARY KEY,
    punchId INT NOT NULL,
    action ENUM('create', 'update', 'delete') NOT NULL,
    actorUserId INT NOT NULL,
    occurredAt DATETIME NOT NULL, -- UTC
    oldValueJson TEXT NULL,
    newValueJson TEXT NULL,
    note TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    INDEX idx_punch (punchId),
    INDEX idx_occurred (occurredAt)
);
```

## Runtime View

### Primary Flow: Create Shift via Syncfusion

```mermaid
sequenceDiagram
    actor Manager
    participant SFS as Syncfusion Schedule
    participant SC as SchedulingController
    participant SR as ShiftRepository
    participant LCC as LaborCostCalculator
    participant DB as Store Database

    Manager->>SFS: Click empty cell, enter shift details
    SFS->>SFS: Validate no overlap (allowOverlap: false)
    SFS->>SC: POST /api/:typeNum/schedule/shifts
    SC->>SR: checkOverlap(employeeId, start, end)
    SR->>DB: SELECT where overlap
    DB-->>SR: Result
    SR-->>SC: No overlap
    SC->>SR: createShift(data)
    SR->>DB: INSERT scheduleShifts
    DB-->>SR: shiftId
    SC->>LCC: calculateWeekCost(weekStart)
    LCC->>DB: SELECT shifts, timePunches
    LCC-->>SC: { regular, overtime, total }
    SC-->>SFS: { shiftId, laborCost }
    SFS->>SFS: Add event to schedule
    SFS-->>Manager: Shift displayed, cost updated
```

### Drag-Drop Flow

```mermaid
sequenceDiagram
    actor Manager
    participant SFS as Syncfusion Schedule
    participant SC as SchedulingController
    participant SR as ShiftRepository
    participant DB as Store Database

    Manager->>SFS: Drag shift to new time/employee
    SFS->>SFS: Preview drop location
    SFS->>SFS: Check allowOverlap constraint
    Manager->>SFS: Drop shift
    SFS->>SC: PUT /api/:typeNum/schedule/shifts/:id
    SC->>SR: checkOverlap(employeeId, start, end, excludeShiftId)
    SR->>DB: SELECT where overlap
    DB-->>SR: Result
    alt Overlap detected
        SR-->>SC: Overlap exists
        SC-->>SFS: 409 Conflict
        SFS->>SFS: Revert to original position
        SFS-->>Manager: Show error message
    else No overlap
        SC->>SR: updateShift(shiftId, data)
        SR->>DB: UPDATE scheduleShifts
        DB-->>SR: Success
        SC-->>SFS: { success: true, laborCost }
        SFS->>SFS: Update event position
        SFS-->>Manager: Shift moved, cost updated
    end
```

## Error Handling

### Syncfusion-Level Errors

| Error Type | Handling | User Feedback |
|------------|----------|---------------|
| Overlap attempt | `allowOverlap: false` prevents drop | Syncfusion auto-reverts position |
| Invalid time range | Validation in popup | Form error message |
| Network failure | Catch in actionComplete | Toast notification |

### API-Level Errors

| Error Code | HTTP Status | Cause | Recovery |
|------------|-------------|-------|----------|
| OVERLAP | 409 | Shift overlaps existing | Revert UI, show conflict |
| STALE_WRITE | 409 | Shift updated by another user | Reload schedule and retry |
| EMPLOYEE_NOT_FOUND | 404 | Invalid employeeId | Reload employees |
| SHIFT_NOT_FOUND | 404 | Shift already deleted | Refresh schedule |
| UNAUTHORIZED | 403 | Insufficient permissions | Show access denied |
| VALIDATION_ERROR | 422 | Invalid data | Show field errors |

## Deployment View

### Syncfusion Installation

Syncfusion and JavaScript build tooling are installed and configured by the bundler specification implemented before this spec.

Requirements for this spec:
- Pin an EJ2 Schedule version (avoid unplanned upgrades)
- Bundle EJ2 Schedule JS into the admin scheduling assets
- Include the EJ2 Bootstrap 5 theme CSS (or an equivalent theme matching our admin)

### Bundle Configuration

The scheduling admin pages load a single compiled JS bundle that includes:
- Syncfusion EJ2 Schedule
- `ScheduleCalendar` module
- Any supporting UI modules (Timesheet dashboard, overtime config)

### Template Integration

```twig
{# templates/themes/default/scheduling/calendar.html #}

{% extends "layouts/admin.html" %}

{% block page_css %}
    <link href="/css/vendor/syncfusion/bootstrap5.css" rel="stylesheet">
    <link href="/css/scheduling/schedule-customizations.css" rel="stylesheet">
{% endblock %}

{% block content %}
<div class="container-fluid">
    <div class="card">
        <div class="card-header d-flex justify-content-between align-items-center">
            <h4>Employee Schedule</h4>
            <div class="btn-group">
                <button class="btn btn-outline-primary" id="copyPrevWeek">
                    <i class="fa fa-copy"></i> Copy Previous Week
                </button>
            </div>
        </div>
        <div class="card-body p-0">
            <div id="schedule-container"></div>
        </div>
        <div class="card-footer labor-cost-summary">
            <div>
                <span class="text-muted">Scheduled:</span>
                <span id="totalHours">0</span> hours
            </div>
            <div class="labor-cost-total" id="totalCost">$0.00</div>
        </div>
    </div>
</div>
{% endblock %}

{% block page_js %}
{# The exact script path depends on the bundler spec (Vite/Webpack/etc). #}
<script type="module">
    import ScheduleCalendar from '/js/scheduling/ScheduleCalendar.js';

    const calendar = new ScheduleCalendar('schedule-container', '{{ typeNum }}');
    calendar.initialize();

    document.getElementById('copyPrevWeek').addEventListener('click', () => {
        calendar.copyPreviousWeek();
    });
</script>
{% endblock %}
```

## Quality Requirements

### Performance
- Schedule load time: < 2 seconds for 50 employees, 200 shifts
- Drag-drop response: < 100ms visual feedback
- API response time: < 500ms for CRUD operations
- Labor cost recalculation: < 1 second

### Usability
- All Syncfusion keyboard shortcuts work (arrow keys, Enter, Delete)
- Touch-friendly for tablet use
- Mobile view automatically switches to Day view
- Consistent with Bootstrap 5 admin theme

### Reliability
- All shifts persisted to database (no client-side only state)
- Optimistic UI updates with server validation
- Automatic retry for transient network failures
- Audit trail for all changes

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Create Shift via Syncfusion**
```gherkin
Given: Manager is viewing Timeline Week schedule
When: Manager clicks empty cell and enters shift details
Then: Shift appears on calendar immediately
And: Labor cost updates in footer
And: Shift is persisted to database
```

**Scenario 2: Drag-Drop with Conflict**
```gherkin
Given: Employee has shift Mon 9am-2pm
And: Employee has shift Mon 2pm-6pm
When: Manager drags first shift to 1pm-6pm (overlap)
Then: Syncfusion prevents the drop
And: Shift reverts to original position
And: Error message displays
```

**Scenario 3: Copy Previous Week**
```gherkin
Given: Previous week has 20 shifts for 5 employees
When: Manager clicks Copy Previous Week
Then: Preview modal shows 20 shifts to copy
And: Any conflicts are highlighted
When: Manager confirms copy
Then: 20 new shifts created for current week
And: Calendar refreshes with new shifts
```

### Test Coverage Requirements

- **Syncfusion Integration**: All CRUD operations via Schedule events
- **API Endpoints**: All shift management endpoints
- **Business Logic**: Overtime calculations, conflict detection
- **Edge Cases**: Midnight-spanning shifts, empty weeks, max employees

## Architecture Decisions

- [x] ADR-1 **Syncfusion EJ2 Schedule for Calendar UI**: Use Syncfusion instead of custom calendar
  - Rationale: 80%+ of calendar requirements built-in, rapid development, professional UX
  - Trade-offs: Dependency on third-party component, learning curve
  - User confirmed: Yes (selected in PRD)

- [x] ADR-2 **Timeline Week as Primary View**: Default to TimelineWeek with employees as rows
  - Rationale: Matches WhenIWork UX users expect, clear employee-to-shift mapping
  - Trade-offs: Less familiar than standard week view for some users
  - User confirmed: Yes

- [x] ADR-3 **Community License**: Use Syncfusion Community License
  - Rationale: Free for <$1M revenue / ≤5 developers, covers initial deployment
  - Trade-offs: Must upgrade if business grows beyond limits
  - User confirmed: Yes

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Shift | A scheduled work period for an employee | Core scheduling unit in Syncfusion events |
| Time Punch | Clock in/out or break start/end record | Separate from Syncfusion, custom implementation |
| Timesheet | Weekly summary of hours for payroll | Derived from time punches |
| Position | Job role (Buyer, Shift Lead, Manager) | Used for shift colors in Syncfusion |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| EJ2 Schedule | Syncfusion Essential JS 2 Schedule component | Primary UI component |
| Timeline View | Horizontal calendar with resources as rows | Default view mode |
| Resource | Syncfusion concept for grouping events | Maps to employees |
| allowOverlap | Syncfusion property preventing event conflicts | Set to false |

### Syncfusion API Terms

| Term | Definition | Context |
|------|------------|---------|
| dataSource | Array of event objects | Shifts loaded from API |
| eventSettings | Configuration for event display and fields | Maps shift fields |
| resourceHeaderTemplate | HTML template for resource column | Employee avatar/info |
| actionBegin/actionComplete | Event hooks for CRUD operations | API integration |
