Various CSS work

This commit is contained in:
Janus C. H. Knudsen 2026-01-12 22:10:57 +01:00
parent ef174af0e1
commit 15579acba8
52 changed files with 8001 additions and 944 deletions

View file

@ -4,7 +4,9 @@
"Bash(cd:*)", "Bash(cd:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(dotnet build:*)", "Bash(dotnet build:*)",
"Bash(find:*)" "Bash(find:*)",
"Bash(tree:*)",
"Bash(npm run analyze-css:*)"
] ]
} }
} }

48
.gitignore vendored
View file

@ -362,3 +362,51 @@ MigrationBackup/
FodyWeavers.xsd FodyWeavers.xsd
nul nul
tmpclaude-031f-cwd
tmpclaude-0e66-cwd
tmpclaude-0fed-cwd
tmpclaude-1d0d-cwd
tmpclaude-1ef3-cwd
tmpclaude-2d7f-cwd
tmpclaude-5104-cwd
tmpclaude-536e-cwd
tmpclaude-556b-cwd
tmpclaude-690d-cwd
tmpclaude-7e66-cwd
tmpclaude-89bb-cwd
tmpclaude-d74d-cwd
tmpclaude-d8f2-cwd
tmpclaude-eab3-cwd
tmpclaude-ff51-cwd
PlanTempus.Application/tmpclaude-0b72-cwd
PlanTempus.Application/tmpclaude-0eb8-cwd
PlanTempus.Application/tmpclaude-4109-cwd
PlanTempus.Application/tmpclaude-6c34-cwd
PlanTempus.Application/tmpclaude-7386-cwd
PlanTempus.Application/tmpclaude-cbe1-cwd
PlanTempus.Application/tmpclaude-d41e-cwd
PlanTempus.Application/tmpclaude-ea41-cwd

149
CLAUDE.md
View file

@ -126,6 +126,88 @@ The solution follows a clean architecture pattern with these main projects:
- `global.json` - .NET SDK version configuration (currently .NET 9.0) - `global.json` - .NET SDK version configuration (currently .NET 9.0)
## Implementing New Pages - MANDATORY Checklist
<CRITICAL>
When implementing a new page or feature, you MUST analyze existing patterns BEFORE writing any code. Creating duplicate components or overriding existing styles is a critical failure.
### Step 1: Identify UI Elements in POC/Design
List ALL UI elements the new page needs:
- Stats cards / KPI boxes
- Tables / Lists
- Tabs
- Badges / Status indicators
- Buttons
- Cards
- Forms
### Step 2: Read the Component Catalog
**MANDATORY:** Read `wwwroot/css/COMPONENT-CATALOG.md` before creating ANY new page.
This file contains:
- All reusable components with examples
- Correct usage patterns (e.g., tabs use `.active` class, not data attributes)
- Design tokens reference
- Table/grid patterns with subgrid
**Component Catalog Location:** `PlanTempus.Application/wwwroot/css/COMPONENT-CATALOG.md`
### Step 3: Document Reusable vs New
Before writing code, create a list:
| Element | Existing Component | File | Action |
|---------|-------------------|------|--------|
| Stats cards | `swp-stat-card` | stats.css | REUSE |
| Tabs | `swp-tab-bar` | tabs.css | REUSE |
| Role badge | `swp-status-badge` | cash.css | REUSE (add variant if needed) |
| Status badge | `swp-status-badge` | cash.css | REUSE (add variant if needed) |
### Step 4: NEVER Create Duplicate Components
**WRONG**: Creating `swp-role-badge` when `swp-status-badge` exists
**CORRECT**: Add `.owner`, `.admin` variants to existing `swp-status-badge`
**WRONG**: Creating `swp-employee-status` for a new status type
**CORRECT**: Add `.active`, `.invited` variants to existing `swp-status-badge`
**Rule:** If a component exists, add a variant class - don't create a new element.
### Step 5: Add Header Comment to New CSS
Every feature CSS file MUST have a header listing reused components:
```css
/**
* Feature Styles - Description
*
* Feature-specific styling only.
* Reuses: swp-stat-card (stats.css), swp-tab-bar (tabs.css), etc.
*/
```
### Common Failure Modes
1. ❌ **Creating custom stat cards** instead of using `swp-stat-card`
- `swp-stat-card` uses `font-family: var(--font-mono)` for values
- Custom implementations lose this consistency
2. ❌ **Using wrong tab pattern** (data attributes instead of classes)
- Correct: `<swp-tab class="active">`
- Wrong: `<swp-tab data-active="true">`
3. ❌ **Creating new badge elements** instead of adding variants
- ALL badges use `swp-status-badge` with variant classes
- Add new variant to cash.css, don't create `swp-role-badge`, `swp-employee-status`, etc.
4. ❌ **Different font sizes** in tables
- Use `var(--font-size-base)` consistently
</CRITICAL>
## CSS Guidelines ## CSS Guidelines
### Grid + Subgrid for Table-like Layouts ### Grid + Subgrid for Table-like Layouts
@ -170,6 +252,56 @@ swp-my-table-row {
- `kasse.css` - swp-kasse-table / swp-kasse-table-row - `kasse.css` - swp-kasse-table / swp-kasse-table-row
### Sticky Header + Tab Content Pattern
For sider med tabs, brug de **GENERISKE** komponenter fra `page.css`:
**Struktur (TO NIVEAUER ER KRITISK):**
```html
<swp-sticky-header> <!-- Generisk - fra page.css -->
<swp-header-content> <!-- Generisk - fra page.css -->
<swp-page-header>...</swp-page-header>
<swp-stats-row>...</swp-stats-row> <!-- optional -->
</swp-header-content>
<swp-tab-bar>...</swp-tab-bar> <!-- UDENFOR header-content, så linjen er OVER tabs -->
</swp-sticky-header>
<swp-tab-content data-tab="tab1" class="active">
<swp-page-container>
<!-- Tab 1 indhold -->
</swp-page-container>
</swp-tab-content>
```
**KRITISK:**
- Brug `swp-sticky-header` og `swp-header-content` fra page.css
- ALDRIG opret feature-specifikke varianter (swp-cash-sticky-header, swp-employees-header, etc.)
- Linjen (border-bottom) er på `swp-header-content`, så den er MELLEM stats og tabs
- Tab-bar er UDENFOR header-content, INDEN I sticky-header
**Reference:** Se `page.css` for styling, `Features/CashRegister/Pages/Index.cshtml` for brug
### Undgå Feature-Specifikke Layout-Komponenter
**ALDRIG** opret feature-specifikke varianter af layout-komponenter:
❌ **FORKERT:**
```css
swp-cash-sticky-header { ... }
swp-employees-sticky-header { ... }
swp-products-sticky-header { ... }
```
✅ **KORREKT:**
```css
/* I page.css - én generisk komponent */
swp-sticky-header { ... }
```
**Regel:** Hvis en komponent er ren layout (sticky header, grid, container), skal den være generisk og ligge i `page.css`. Feature-specifikke styles er kun til feature-specifikt indhold (swp-cash-stats, swp-employee-table, etc.)
## NEVER Lie or Fabricate ## NEVER Lie or Fabricate
<CRITICAL> NEVER lie or fabricate. Violating this = immediate critical failure. <CRITICAL> NEVER lie or fabricate. Violating this = immediate critical failure.
@ -242,4 +374,21 @@ swp-my-table-row {
break builds. break builds.
⚠️ DETECTION: Finished editing but haven't run verify-file-quality-checks ⚠️ DETECTION: Finished editing but haven't run verify-file-quality-checks
skill? → STOP. Run it now. Show the output. skill? → STOP. Run it now. Show the output.
10. ❌ BAD THOUGHT: "I'll create a new component, it's faster than searching for
existing ones."
✅ REALITY: Creating duplicate components causes style conflicts, inconsistent
UX, and maintenance burden. The codebase already has reusable patterns.
Duplicating them wastes time on fixes later.
⚠️ DETECTION: About to create a new CSS element or ViewComponent? → STOP.
Search wwwroot/css/ for existing patterns first. Document what exists vs.
what needs to be created. Show your analysis before writing code.
11. ❌ BAD THOUGHT: "This element looks different in the POC, so I need to create
a new version."
✅ REALITY: POC files often use slightly different markup for prototyping.
The production codebase has established patterns. Match the production
patterns, not the POC variations.
⚠️ DETECTION: POC uses different element names or attributes than existing
code? → STOP. Use the production pattern. The POC is just a reference.
</CRITICAL> </CRITICAL>

797
OPTIMIZATION_PLAN.md Normal file
View file

@ -0,0 +1,797 @@
# CSS & HTML Optimization Plan for PlanTempus
**Date:** 2026-01-12
**Status:** Proposal - Awaiting Approval
**Target:** wwwroot CSS and HTML Components
---
## 📊 Executive Summary
Analysis of the PlanTempus wwwroot folder has identified **5 major categories of duplication** across CSS and HTML components. By consolidating these into a unified component system, we can:
- ✅ **Reduce CSS size** by eliminating ~40% duplicate code
- ✅ **Improve maintainability** with single source of truth
- ✅ **Ensure UI consistency** across all pages
- ✅ **Simplify future development** with predictable patterns
---
## 🔍 Analysis Results
### 1. Avatar Components (6+ variations)
**Current Implementations:**
- `swp-avatar` (waitlist.css) - 40px
- `swp-avatar-small` (bookings.css) - 24px
- `swp-user-avatar` (employees.css) - 36px
- `swp-user-avatar` (auth.css) - 44px
- `swp-employee-avatar-large` (employees.css) - 80px
- `swp-profile-avatar` (topbar.css) - 32px
- `swp-profile-avatar-large` (drawers.css) - 64px
**Problem:**
- 7 different sizes: 24px, 32px, 36px, 40px, 44px, 64px, 80px
- Duplicated styling for circular containers
- Inconsistent naming conventions
- Color variants duplicated across multiple files
**Impact:** ~150 lines of duplicated CSS
---
### 2. Status Badge Components (3+ variations)
**Current Implementations:**
- `swp-status-badge` (cash.css) - Full-featured with dot indicator
- `swp-booking-status` (bookings.css) - Similar styling
- Status utility classes in design-tokens.css
**Problem:**
- Two separate implementations of the same pattern
- Inconsistent API (one uses dot, one doesn't)
- Duplicated color-mix calculations
- Status variants defined in multiple places
**Impact:** ~80 lines of duplicated CSS
---
### 3. Icon Box Components (3 variations)
**Current Implementations:**
- `swp-notification-icon` (notifications.css) - 40px circular
- `swp-attention-icon` (attentions.css) - 40px circular
- `swp-waitlist-icon` (waitlist.css) - 40px + badge
**Problem:**
- Nearly identical styling (40px, circular, centered)
- Same hover states and color variants
- Only difference is semantic naming
**Impact:** ~60 lines of duplicated CSS
---
### 4. Stat Display Components (4+ variations)
**Current Implementations:**
- `swp-stat-card` (stats.css) - Card container with value + label
- `swp-quick-stat` (quick-stats.css) - Smaller variant
- `swp-cash-stat` (cash.css) - Similar to stat-card
- `swp-fact-inline` (employees.css) - Inline variant
**Problem:**
- Same pattern: value + label in vertical layout
- Duplicated typography rules (mono font, semibold weight)
- Inconsistent sizing and naming
- Color variants repeated across files
**Impact:** ~100 lines of duplicated CSS
---
### 5. Button Components (2+ variations)
**Current Implementations:**
- `swp-btn` (cash.css) - Full button system with variants
- Inline button styles in waitlist.css
- Icon buttons in employees.css
**Problem:**
- Button variants defined in multiple places
- Duplicated padding, transitions, and states
- Inconsistent hover behaviors
**Impact:** ~50 lines of duplicated CSS
---
## 🎯 Proposed Solution
### Architecture Overview
```mermaid
graph TD
A[Current State: Scattered Components] --> B[Create components.css]
B --> C[Define Generic Components]
C --> D1[swp-avatar System]
C --> D2[swp-badge System]
C --> D3[swp-icon-box System]
C --> D4[swp-stat System]
C --> D5[swp-btn System]
D1 --> E1[Size Modifiers: xs, sm, md, lg, xl]
D1 --> E2[Color Modifiers: teal, blue, purple, etc.]
D2 --> F1[Semantic Variants: success, warning, error]
D2 --> F2[Role Variants: owner, admin, employee]
D4 --> G1[Unified Value + Label Pattern]
D4 --> G2[Size Variants: inline, card, box]
style B fill:#00897b,color:#fff
style C fill:#1976d2,color:#fff
```
### Component Class Diagram
```mermaid
classDiagram
class AvatarSystem {
+swp-avatar (base)
+Size: xs, sm, md, lg, xl, xxl
+Color: teal, blue, purple, amber, green
}
class BadgeSystem {
+swp-badge (base)
+Status: success, warning, error, pending
+Role: owner, admin, leader, employee
+Size: sm, md
}
class IconBoxSystem {
+swp-icon-box (base)
+Size: sm, md, lg
+State: urgent, warning, info, success
}
class StatSystem {
+swp-stat (container)
+swp-stat-value
+swp-stat-label
+Layout: inline, card, box
}
class ButtonSystem {
+swp-btn (base)
+Variant: primary, secondary, ghost
+Size: sm, md, lg
+State: disabled, loading
}
```
---
## 📝 Implementation Details
### 1. Avatar System
**New Implementation:**
```css
/* Base avatar */
swp-avatar {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background: var(--color-teal);
color: white;
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
/* Default size (md) */
width: 32px;
height: 32px;
font-size: var(--font-size-sm);
}
/* Size modifiers */
swp-avatar.xs { width: 20px; height: 20px; font-size: 10px; }
swp-avatar.sm { width: 24px; height: 24px; font-size: 10px; }
swp-avatar.md { width: 32px; height: 32px; font-size: var(--font-size-sm); }
swp-avatar.lg { width: 40px; height: 40px; font-size: var(--font-size-base); }
swp-avatar.xl { width: 64px; height: 64px; font-size: var(--font-size-xl); }
swp-avatar.xxl { width: 80px; height: 80px; font-size: var(--font-size-2xl); }
/* Color modifiers */
swp-avatar.purple { background: var(--color-purple); }
swp-avatar.blue { background: var(--color-blue); }
swp-avatar.amber { background: var(--color-amber); }
swp-avatar.green { background: var(--color-green); }
```
**Migration Map:**
- `swp-avatar-small``<swp-avatar class="sm">`
- `swp-user-avatar``<swp-avatar class="md">`
- `swp-profile-avatar``<swp-avatar class="md">`
- `swp-waitlist-customer swp-avatar``<swp-avatar class="lg">`
- `swp-profile-avatar-large``<swp-avatar class="xl">`
- `swp-employee-avatar-large``<swp-avatar class="xxl">`
**Example Usage:**
```html
<!-- Before -->
<swp-user-avatar class="purple">JK</swp-user-avatar>
<!-- After -->
<swp-avatar class="md purple">JK</swp-avatar>
```
---
### 2. Badge System
**New Implementation:**
```css
swp-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-pill);
}
/* Optional dot indicator */
swp-badge.with-dot::before {
content: '';
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background: currentColor;
}
/* Status variants */
swp-badge.success,
swp-badge.confirmed,
swp-badge.active {
background: color-mix(in srgb, var(--color-green) 15%, transparent);
color: var(--color-green);
}
swp-badge.warning,
swp-badge.pending,
swp-badge.draft {
background: color-mix(in srgb, var(--color-amber) 15%, transparent);
color: var(--color-amber);
}
swp-badge.error,
swp-badge.urgent {
background: color-mix(in srgb, var(--color-red) 15%, transparent);
color: var(--color-red);
}
swp-badge.info,
swp-badge.inprogress {
background: color-mix(in srgb, var(--color-blue) 15%, transparent);
color: var(--color-blue);
}
/* Role variants */
swp-badge.owner {
background: color-mix(in srgb, var(--color-teal) 15%, transparent);
color: var(--color-teal);
}
swp-badge.admin {
background: color-mix(in srgb, var(--color-purple) 15%, transparent);
color: var(--color-purple);
}
swp-badge.leader {
background: color-mix(in srgb, var(--color-blue) 15%, transparent);
color: var(--color-blue);
}
swp-badge.employee {
background: var(--color-background-alt);
color: var(--color-text-secondary);
}
```
**Migration Map:**
- `swp-status-badge``<swp-badge class="with-dot">`
- `swp-booking-status``<swp-badge>`
---
### 3. Icon Box System
**New Implementation:**
```css
swp-icon-box {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-xl);
background: var(--color-background-hover);
color: var(--color-text-secondary);
/* Default size */
width: 40px;
height: 40px;
font-size: var(--font-size-xl);
}
/* Size variants */
swp-icon-box.sm {
width: 32px;
height: 32px;
font-size: var(--font-size-lg);
}
swp-icon-box.lg {
width: 48px;
height: 48px;
font-size: var(--font-size-2xl);
}
/* State modifiers */
swp-icon-box.urgent {
background: color-mix(in srgb, var(--color-red) 15%, transparent);
color: var(--color-red);
}
swp-icon-box.warning {
background: color-mix(in srgb, var(--color-amber) 15%, transparent);
color: var(--color-amber);
}
swp-icon-box.info {
background: color-mix(in srgb, var(--color-blue) 15%, transparent);
color: var(--color-blue);
}
swp-icon-box.success {
background: color-mix(in srgb, var(--color-green) 15%, transparent);
color: var(--color-green);
}
/* Unread state for notifications */
swp-icon-box.unread {
background: color-mix(in srgb, var(--color-teal) 15%, transparent);
color: var(--color-teal);
}
```
**Migration Map:**
- `swp-notification-icon``<swp-icon-box>`
- `swp-attention-icon``<swp-icon-box>`
- `swp-waitlist-icon``<swp-icon-box>` (with separate badge element)
---
### 4. Stat System
**New Implementation:**
```css
swp-stat {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
swp-stat-value {
display: block;
font-family: var(--font-mono);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
line-height: var(--line-height-tight);
font-size: var(--font-size-xl);
}
swp-stat-label {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Layout variants */
swp-stat.inline {
gap: var(--spacing-1);
}
swp-stat.inline swp-stat-value {
font-size: var(--font-size-lg);
}
swp-stat.card {
padding: var(--card-padding);
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
}
swp-stat.card swp-stat-value {
font-size: var(--font-size-3xl);
}
swp-stat.box {
padding: var(--spacing-6) var(--spacing-8);
background: var(--color-background-alt);
border-radius: var(--radius-lg);
}
swp-stat.box swp-stat-value {
font-size: var(--font-size-2xl);
}
/* Color modifiers */
swp-stat.highlight swp-stat-value,
swp-stat.teal swp-stat-value {
color: var(--color-teal);
}
swp-stat.success swp-stat-value,
swp-stat.positive swp-stat-value {
color: var(--color-green);
}
swp-stat.warning swp-stat-value,
swp-stat.amber swp-stat-value {
color: var(--color-amber);
}
swp-stat.danger swp-stat-value,
swp-stat.negative swp-stat-value,
swp-stat.red swp-stat-value {
color: var(--color-red);
}
swp-stat.purple swp-stat-value {
color: var(--color-purple);
}
swp-stat.blue swp-stat-value,
swp-stat.user swp-stat-value {
color: var(--color-blue);
}
```
**Migration Map:**
- `swp-stat-card``<swp-stat class="card">`
- `swp-quick-stat``<swp-stat class="box">`
- `swp-cash-stat``<swp-stat class="box">`
- `swp-fact-inline``<swp-stat class="inline">`
---
### 5. Button System
**New Implementation:**
```css
swp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: var(--spacing-5) var(--spacing-8);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
font-family: var(--font-family);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
border: none;
}
swp-btn i {
font-size: var(--font-size-lg);
}
/* Variants */
swp-btn.primary {
background: var(--color-teal);
color: white;
}
swp-btn.primary:hover {
opacity: 0.9;
}
swp-btn.primary:disabled {
background: var(--color-border);
cursor: not-allowed;
opacity: 0.6;
}
swp-btn.secondary {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
}
swp-btn.secondary:hover {
background: var(--color-background-hover);
}
swp-btn.ghost {
background: transparent;
color: var(--color-text-secondary);
}
swp-btn.ghost:hover {
color: var(--color-text);
background: var(--color-background-alt);
}
/* Size variants */
swp-btn.sm {
padding: var(--spacing-3) var(--spacing-6);
font-size: var(--font-size-sm);
}
swp-btn.lg {
padding: var(--spacing-6) var(--spacing-10);
font-size: var(--font-size-lg);
}
/* Icon-only button */
swp-btn.icon-only {
padding: var(--spacing-4);
width: 36px;
height: 36px;
}
```
---
## 📁 File Structure Changes
### Before:
```
wwwroot/css/
├── design-tokens.css
├── base.css
├── page.css
├── stats.css (contains stat-card)
├── quick-stats.css (contains quick-stat)
├── bookings.css (contains avatar-small, booking-status)
├── notifications.css (contains notification-icon)
├── attentions.css (contains attention-icon)
├── waitlist.css (contains avatar, icon)
├── employees.css (contains user-avatar, employee-avatar-large)
├── topbar.css (contains profile-avatar)
├── drawers.css (contains profile-avatar-large)
├── auth.css (contains user-avatar)
└── cash.css (contains status-badge, cash-stat, btn)
```
### After:
```
wwwroot/css/
├── design-tokens.css (unchanged)
├── base.css (unchanged)
├── components.css ⭐ NEW - Generic reusable components
├── page.css (keep page-level layouts)
├── stats.css ➡️ Simplified (removes duplicates)
├── quick-stats.css ➡️ Can be removed/merged
├── bookings.css ➡️ Simplified (uses swp-avatar, swp-badge)
├── notifications.css ➡️ Simplified (uses swp-icon-box)
├── attentions.css ➡️ Simplified (uses swp-icon-box)
├── waitlist.css ➡️ Simplified (uses swp-avatar, swp-icon-box, swp-btn)
├── employees.css ➡️ Simplified (uses swp-avatar, swp-stat)
├── topbar.css ➡️ Simplified (uses swp-avatar)
├── drawers.css ➡️ Simplified (uses swp-avatar)
├── auth.css ➡️ Simplified (uses swp-avatar)
└── cash.css ➡️ Simplified (uses swp-badge, swp-stat, swp-btn)
```
---
## 🔄 Implementation Strategy
### Phase 1: Create Foundation
1. Create `components.css` file
2. Implement base components (avatar, badge, icon-box, stat, btn)
3. Test components in isolation
### Phase 2: Pilot Migration
1. Start with Avatar system (most instances)
2. Update HTML in one feature (e.g., Dashboard)
3. Verify no visual regressions
4. Document any issues
### Phase 3: Full Migration
1. Update remaining HTML components
2. Remove duplicated CSS from feature files
3. Test all pages for visual consistency
4. Verify responsive behavior
### Phase 4: Cleanup
1. Remove unused CSS rules
2. Consider merging small CSS files
3. Update documentation
4. Create component usage guide
### Implementation Flowchart
```mermaid
graph TD
A[Start] --> B[Create components.css]
B --> C[Implement base components]
C --> D[Test components in isolation]
D --> E[Pilot: Migrate Dashboard avatars]
E --> F{Visual regression?}
F -->|Yes| G[Fix issues]
G --> E
F -->|No| H[Migrate all HTML components]
H --> I[Remove duplicated CSS]
I --> J[Test all pages]
J --> K{Issues found?}
K -->|Yes| L[Fix issues]
L --> J
K -->|No| M[Cleanup unused CSS]
M --> N[Update documentation]
N --> O[Complete]
style B fill:#00897b,color:#fff
style I fill:#e53935,color:#fff
style O fill:#43a047,color:#fff
```
---
## 📊 Expected Benefits
### Quantitative Benefits
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| Total CSS Lines | ~2,500 | ~2,000 | -20% |
| Avatar Definitions | 7 | 1 + modifiers | -85% |
| Badge Definitions | 3 | 1 + modifiers | -67% |
| Icon Box Definitions | 3 | 1 + modifiers | -67% |
| Stat Definitions | 4 | 1 + modifiers | -75% |
### Qualitative Benefits
```mermaid
mindmap
root((CSS Optimization))
Maintainability
Single source of truth
Easier updates
Consistent behavior
Less context switching
Performance
Reduced CSS size
Better caching
Faster load times
Fewer parse operations
Developer Experience
Clear naming conventions
Predictable class API
Less cognitive load
Self-documenting code
Design Consistency
Unified components
Consistent sizing
Cohesive UI
Brand alignment
```
---
## ⚠️ Potential Risks & Mitigation
### Risk 1: Visual Regressions
**Impact:** High
**Likelihood:** Medium
**Mitigation:**
- Pilot migration with one component first
- Visual regression testing on key pages
- Screenshot comparison before/after
- Incremental rollout
### Risk 2: Breaking Changes
**Impact:** Medium
**Likelihood:** Low
**Mitigation:**
- Keep old CSS during migration period
- Use feature flags if needed
- Gradual deprecation of old classes
- Clear migration guide for team
### Risk 3: HTML Update Overhead
**Impact:** Medium
**Likelihood:** High
**Mitigation:**
- Create search/replace patterns
- Update one feature at a time
- Use code review process
- Document common patterns
### Risk 4: Component Naming Conflicts
**Impact:** Low
**Likelihood:** Low
**Mitigation:**
- Choose unique, descriptive names
- Namespace with swp- prefix
- Check for conflicts before migration
- Update naming if conflicts found
---
## 📋 Checklist
### Pre-Implementation
- [ ] Review plan with team
- [ ] Get stakeholder approval
- [ ] Create backup branch
- [ ] Set up visual regression testing
### Phase 1: Foundation
- [ ] Create components.css
- [ ] Implement swp-avatar system
- [ ] Implement swp-badge system
- [ ] Implement swp-icon-box system
- [ ] Implement swp-stat system
- [ ] Implement swp-btn system
- [ ] Test components in isolation
### Phase 2: Pilot
- [ ] Update Dashboard avatar usage
- [ ] Visual regression test
- [ ] Document any issues
- [ ] Team review & feedback
### Phase 3: Full Migration
- [ ] Migrate all avatar instances
- [ ] Migrate all badge instances
- [ ] Migrate all icon-box instances
- [ ] Migrate all stat instances
- [ ] Migrate all button instances
- [ ] Remove old CSS rules
- [ ] Test all pages
### Phase 4: Cleanup
- [ ] Remove unused CSS
- [ ] Consider file consolidation
- [ ] Update component documentation
- [ ] Create usage guide
- [ ] Final testing round
---
## 📚 Next Steps
1. **Review & Approve** - Stakeholders review this plan
2. **Discuss Concerns** - Address any questions or modifications
3. **Create Timeline** - Determine priority and resources
4. **Begin Implementation** - Start with Phase 1
---
## 📞 Questions & Discussion Points
- Is the modifier-based approach (e.g., `class="md purple"`) acceptable?
- Should we prioritize certain components over others?
- Do we need to maintain backward compatibility during migration?
- What's the preferred testing strategy?
- Are there any other duplications not covered here?
---
**Document Status:** Draft
**Last Updated:** 2026-01-12
**Next Review:** After stakeholder feedback

View file

@ -9,6 +9,6 @@
], ],
"settings": { "settings": {
"liveServer.settings.port": 5501, "liveServer.settings.port": 5501,
"liveServer.settings.multiRootWorkspaceName": "Calendar" "liveServer.settings.multiRootWorkspaceName": "PlanTempus"
} }
} }

View file

@ -6,9 +6,9 @@
} }
<!-- Sticky Header (Stats + Tabs) --> <!-- Sticky Header (Stats + Tabs) -->
<swp-cash-sticky-header> <swp-sticky-header>
<!-- Context Stats (changes based on active tab) --> <!-- Context Stats (changes based on active tab) -->
<swp-cash-header> <swp-header-content>
<!-- Stats for Oversigt tab --> <!-- Stats for Oversigt tab -->
<swp-cash-stats data-for-tab="oversigt" class="active"> <swp-cash-stats data-for-tab="oversigt" class="active">
<swp-cash-stat> <swp-cash-stat>
@ -48,7 +48,7 @@
<swp-cash-stat-label localize="cash.stats.openedRegister">Åbnede kassen 29. dec 09:05</swp-cash-stat-label> <swp-cash-stat-label localize="cash.stats.openedRegister">Åbnede kassen 29. dec 09:05</swp-cash-stat-label>
</swp-cash-stat> </swp-cash-stat>
</swp-cash-stats> </swp-cash-stats>
</swp-cash-header> </swp-header-content>
<!-- Tab Bar --> <!-- Tab Bar -->
<swp-tab-bar> <swp-tab-bar>
@ -61,7 +61,7 @@
<span localize="cash.tabs.reconciliation">Kasseafstemning</span> <span localize="cash.tabs.reconciliation">Kasseafstemning</span>
</swp-tab> </swp-tab>
</swp-tab-bar> </swp-tab-bar>
</swp-cash-sticky-header> </swp-sticky-header>
<!-- Tab Content: Oversigt --> <!-- Tab Content: Oversigt -->
<swp-tab-content data-tab="oversigt" class="active"> <swp-tab-content data-tab="oversigt" class="active">

View file

@ -0,0 +1,227 @@
namespace PlanTempus.Application.Features.Employees.Components;
/// <summary>
/// Shared catalog for employee detail data.
/// Used by all EmployeeDetail* ViewComponents.
/// </summary>
public static class EmployeeDetailCatalog
{
private static readonly Dictionary<string, EmployeeDetailRecord> Employees = new()
{
["employee-1"] = new EmployeeDetailRecord
{
Key = "employee-1",
Initials = "MJ",
Name = "Maria Jensen",
Email = "maria@salonbeauty.dk",
Phone = "+45 12 34 56 78",
Role = "owner",
RoleKey = "employees.roles.owner",
Status = "active",
StatusKey = "employees.status.active",
BookingsThisYear = "312",
RevenueThisYear = "245.800 kr",
Rating = "4.9",
EmployedSince = "2018",
Address = "Hovedgaden 12",
PostalCity = "2100 København Ø",
EmploymentDate = "1. januar 2018",
Position = "Ejer",
EmploymentType = "Fuldtid",
HoursPerWeek = "37",
BirthDate = "12. maj 1985",
EmergencyContact = "Peter Jensen (ægtefælle)",
EmergencyPhone = "+45 98 76 54 32",
BankAccount = "1234-5678901234",
TaxCard = "Hovedkort",
HourlyRate = "250 kr",
MonthlyFixedSalary = "45.000 kr",
Tags = new() { new("Master Stylist", "master"), new("Farvecertificeret", "cert") }
},
["employee-2"] = new EmployeeDetailRecord
{
Key = "employee-2",
Initials = "AS",
Name = "Anna Sørensen",
Email = "anna@salonbeauty.dk",
Phone = "+45 23 45 67 89",
Role = "admin",
RoleKey = "employees.roles.admin",
Status = "active",
StatusKey = "employees.status.active",
AvatarColor = "purple",
BookingsThisYear = "248",
RevenueThisYear = "186.450 kr",
Rating = "4.9",
EmployedSince = "2019",
Address = "Vestergade 15, 3. tv",
PostalCity = "8000 Aarhus C",
EmploymentDate = "1. august 2019",
Position = "Master Stylist",
EmploymentType = "Fuldtid",
HoursPerWeek = "37",
BirthDate = "15. marts 1992",
EmergencyContact = "Peter Sørensen (ægtefælle)",
EmergencyPhone = "+45 87 65 43 21",
BankAccount = "2345-6789012345",
TaxCard = "Hovedkort",
HourlyRate = "220 kr",
MonthlyFixedSalary = "38.000 kr",
Tags = new() { new("Master Stylist", "master"), new("Farvecertificeret", "cert"), new("Balayage", "cert") }
},
["employee-3"] = new EmployeeDetailRecord
{
Key = "employee-3",
Initials = "LP",
Name = "Louise Pedersen",
Email = "louise@salonbeauty.dk",
Phone = "+45 34 56 78 90",
Role = "leader",
RoleKey = "employees.roles.leader",
Status = "active",
StatusKey = "employees.status.active",
AvatarColor = "blue",
BookingsThisYear = "198",
RevenueThisYear = "156.200 kr",
Rating = "4.7",
EmployedSince = "2020",
Address = "Nørrebrogade 45",
PostalCity = "2200 København N",
EmploymentDate = "15. marts 2020",
Position = "Senior Stylist",
EmploymentType = "Fuldtid",
HoursPerWeek = "37",
BirthDate = "22. november 1988",
EmergencyContact = "Hans Pedersen (far)",
EmergencyPhone = "+45 76 54 32 10",
BankAccount = "3456-7890123456",
TaxCard = "Hovedkort",
HourlyRate = "200 kr",
MonthlyFixedSalary = "35.000 kr",
Tags = new() { new("Senior Stylist", "senior"), new("Farvecertificeret", "cert") }
},
["employee-4"] = new EmployeeDetailRecord
{
Key = "employee-4",
Initials = "KN",
Name = "Katrine Nielsen",
Email = "katrine@salonbeauty.dk",
Phone = "+45 45 67 89 01",
Role = "employee",
RoleKey = "employees.roles.employee",
Status = "active",
StatusKey = "employees.status.active",
AvatarColor = "amber",
BookingsThisYear = "165",
RevenueThisYear = "124.300 kr",
Rating = "4.8",
EmployedSince = "2021",
Address = "Frederiksberggade 28",
PostalCity = "1459 København K",
EmploymentDate = "1. juni 2021",
Position = "Stylist",
EmploymentType = "Fuldtid",
HoursPerWeek = "32",
BirthDate = "8. august 1995",
EmergencyContact = "Mette Nielsen (mor)",
EmergencyPhone = "+45 65 43 21 09",
BankAccount = "4567-8901234567",
TaxCard = "Hovedkort",
HourlyRate = "180 kr",
MonthlyFixedSalary = "32.000 kr",
Tags = new() { new("Stylist", "default") }
},
["employee-5"] = new EmployeeDetailRecord
{
Key = "employee-5",
Initials = "SH",
Name = "Sofie Hansen",
Email = "sofie@salonbeauty.dk",
Phone = "+45 56 78 90 12",
Role = "employee",
RoleKey = "employees.roles.employee",
Status = "invited",
StatusKey = "employees.status.invited",
AvatarColor = "purple",
BookingsThisYear = "0",
RevenueThisYear = "0 kr",
Rating = "-",
EmployedSince = "2025",
Address = "-",
PostalCity = "-",
EmploymentDate = "1. januar 2025",
Position = "Junior Stylist",
EmploymentType = "Fuldtid",
HoursPerWeek = "37",
BirthDate = "-",
EmergencyContact = "-",
EmergencyPhone = "-",
BankAccount = "-",
TaxCard = "-",
HourlyRate = "150 kr",
MonthlyFixedSalary = "28.000 kr",
Tags = new() { new("Junior Stylist", "junior") }
}
};
public static EmployeeDetailRecord Get(string key)
{
if (!Employees.TryGetValue(key, out var employee))
throw new KeyNotFoundException($"Employee with key '{key}' not found");
return employee;
}
public static IEnumerable<string> AllKeys => Employees.Keys;
}
/// <summary>
/// Complete employee detail record used across all detail ViewComponents.
/// </summary>
public record EmployeeDetailRecord
{
// Identity
public required string Key { get; init; }
public required string Initials { get; init; }
public required string Name { get; init; }
public string? AvatarColor { get; init; }
// Contact
public required string Email { get; init; }
public required string Phone { get; init; }
public required string Address { get; init; }
public required string PostalCity { get; init; }
// Role & Status
public required string Role { get; init; }
public required string RoleKey { get; init; }
public required string Status { get; init; }
public required string StatusKey { get; init; }
// Stats
public required string BookingsThisYear { get; init; }
public required string RevenueThisYear { get; init; }
public required string Rating { get; init; }
public required string EmployedSince { get; init; }
// Employment
public required string EmploymentDate { get; init; }
public required string Position { get; init; }
public required string EmploymentType { get; init; }
public required string HoursPerWeek { get; init; }
// Personal
public required string BirthDate { get; init; }
public required string EmergencyContact { get; init; }
public required string EmergencyPhone { get; init; }
// Salary
public required string BankAccount { get; init; }
public required string TaxCard { get; init; }
public required string HourlyRate { get; init; }
public required string MonthlyFixedSalary { get; init; }
// Tags (certifications, specialties)
public List<EmployeeTag> Tags { get; init; } = new();
}
public record EmployeeTag(string Text, string CssClass);

View file

@ -0,0 +1,151 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailGeneralViewModel
<swp-detail-grid>
<!-- Left column -->
<div>
<!-- Contact Card -->
<swp-card>
<swp-section-label>@Model.LabelContact</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelFullName</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Name</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmail</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Email</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelPhone</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Phone</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelAddress</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Address</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelPostalCity</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.PostalCity</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<!-- Personal Card -->
<swp-card>
<swp-section-label>@Model.LabelPersonal</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelBirthDate</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.BirthDate</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmergencyContact</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.EmergencyContact</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmergencyPhone</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.EmergencyPhone</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
</div>
<!-- Right column -->
<div>
<!-- Employment Card -->
<swp-card>
<swp-section-label>@Model.LabelEmployment</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmploymentDate</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.EmploymentDate</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelPosition</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Position</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmploymentType</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.EmploymentType</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelHoursPerWeek</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.HoursPerWeek</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<!-- Settings Card -->
<swp-card>
<swp-section-label>@Model.LabelSettings</swp-section-label>
<swp-toggle-row>
<div>
<swp-toggle-label>@Model.SettingShowInBooking</swp-toggle-label>
<swp-toggle-description>@Model.SettingShowInBookingDesc</swp-toggle-description>
</div>
<swp-toggle-slider data-value="yes">
<swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
<swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
<swp-toggle-row>
<div>
<swp-toggle-label>@Model.SettingSmsReminders</swp-toggle-label>
<swp-toggle-description>@Model.SettingSmsRemindersDesc</swp-toggle-description>
</div>
<swp-toggle-slider data-value="yes">
<swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
<swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
<swp-toggle-row>
<div>
<swp-toggle-label>@Model.SettingEditCalendar</swp-toggle-label>
<swp-toggle-description>@Model.SettingEditCalendarDesc</swp-toggle-description>
</div>
<swp-toggle-slider data-value="yes">
<swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
<swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
</swp-card>
<!-- Notifications Card -->
<swp-card>
<swp-section-label>@Model.LabelNotifications</swp-section-label>
<swp-notification-intro>@Model.NotificationsIntro</swp-notification-intro>
<swp-checkbox-list>
<swp-checkbox-row class="checked">
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifOnlineBooking</swp-checkbox-text>
</swp-checkbox-row>
<swp-checkbox-row class="checked">
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifManualBooking</swp-checkbox-text>
</swp-checkbox-row>
<swp-checkbox-row>
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifCancellation</swp-checkbox-text>
</swp-checkbox-row>
<swp-checkbox-row>
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifWaitlist</swp-checkbox-text>
</swp-checkbox-row>
<swp-checkbox-row class="checked">
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifDailySummary</swp-checkbox-text>
</swp-checkbox-row>
</swp-checkbox-list>
</swp-card>
</div>
</swp-detail-grid>

View file

@ -0,0 +1,137 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailGeneralViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailGeneralViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailGeneralViewModel
{
// Contact
Name = employee.Name,
Email = employee.Email,
Phone = employee.Phone,
Address = employee.Address,
PostalCity = employee.PostalCity,
// Personal
BirthDate = employee.BirthDate,
EmergencyContact = employee.EmergencyContact,
EmergencyPhone = employee.EmergencyPhone,
// Employment
EmploymentDate = employee.EmploymentDate,
Position = employee.Position,
EmploymentType = employee.EmploymentType,
HoursPerWeek = employee.HoursPerWeek,
// Labels
LabelContact = _localization.Get("employees.detail.contact"),
LabelPersonal = _localization.Get("employees.detail.personal"),
LabelEmployment = _localization.Get("employees.detail.employment"),
LabelFullName = _localization.Get("employees.detail.fullname"),
LabelEmail = _localization.Get("employees.detail.email"),
LabelPhone = _localization.Get("employees.detail.phone"),
LabelAddress = _localization.Get("employees.detail.address"),
LabelPostalCity = _localization.Get("employees.detail.postalcity"),
LabelBirthDate = _localization.Get("employees.detail.birthdate"),
LabelEmergencyContact = _localization.Get("employees.detail.emergencycontact"),
LabelEmergencyPhone = _localization.Get("employees.detail.emergencyphone"),
LabelEmploymentDate = _localization.Get("employees.detail.employmentdate"),
LabelPosition = _localization.Get("employees.detail.position"),
LabelEmploymentType = _localization.Get("employees.detail.employmenttype"),
LabelHoursPerWeek = _localization.Get("employees.detail.hoursperweek"),
// Settings
LabelSettings = _localization.Get("employees.detail.settings.label"),
SettingShowInBooking = _localization.Get("employees.detail.settings.showinbooking.label"),
SettingShowInBookingDesc = _localization.Get("employees.detail.settings.showinbooking.desc"),
SettingSmsReminders = _localization.Get("employees.detail.settings.smsreminders.label"),
SettingSmsRemindersDesc = _localization.Get("employees.detail.settings.smsreminders.desc"),
SettingEditCalendar = _localization.Get("employees.detail.settings.editcalendar.label"),
SettingEditCalendarDesc = _localization.Get("employees.detail.settings.editcalendar.desc"),
ToggleYes = _localization.Get("common.yes"),
ToggleNo = _localization.Get("common.no"),
// Notifications
LabelNotifications = _localization.Get("employees.detail.notifications.label"),
NotificationsIntro = _localization.Get("employees.detail.notifications.intro"),
NotifOnlineBooking = _localization.Get("employees.detail.notifications.onlinebooking"),
NotifManualBooking = _localization.Get("employees.detail.notifications.manualbooking"),
NotifCancellation = _localization.Get("employees.detail.notifications.cancellation"),
NotifWaitlist = _localization.Get("employees.detail.notifications.waitlist"),
NotifDailySummary = _localization.Get("employees.detail.notifications.dailysummary")
};
return View(model);
}
}
public class EmployeeDetailGeneralViewModel
{
// Contact
public required string Name { get; init; }
public required string Email { get; init; }
public required string Phone { get; init; }
public required string Address { get; init; }
public required string PostalCity { get; init; }
// Personal
public required string BirthDate { get; init; }
public required string EmergencyContact { get; init; }
public required string EmergencyPhone { get; init; }
// Employment
public required string EmploymentDate { get; init; }
public required string Position { get; init; }
public required string EmploymentType { get; init; }
public required string HoursPerWeek { get; init; }
// Labels
public required string LabelContact { get; init; }
public required string LabelPersonal { get; init; }
public required string LabelEmployment { get; init; }
public required string LabelFullName { get; init; }
public required string LabelEmail { get; init; }
public required string LabelPhone { get; init; }
public required string LabelAddress { get; init; }
public required string LabelPostalCity { get; init; }
public required string LabelBirthDate { get; init; }
public required string LabelEmergencyContact { get; init; }
public required string LabelEmergencyPhone { get; init; }
public required string LabelEmploymentDate { get; init; }
public required string LabelPosition { get; init; }
public required string LabelEmploymentType { get; init; }
public required string LabelHoursPerWeek { get; init; }
// Settings
public required string LabelSettings { get; init; }
public required string SettingShowInBooking { get; init; }
public required string SettingShowInBookingDesc { get; init; }
public required string SettingSmsReminders { get; init; }
public required string SettingSmsRemindersDesc { get; init; }
public required string SettingEditCalendar { get; init; }
public required string SettingEditCalendarDesc { get; init; }
public required string ToggleYes { get; init; }
public required string ToggleNo { get; init; }
// Notifications
public required string LabelNotifications { get; init; }
public required string NotificationsIntro { get; init; }
public required string NotifOnlineBooking { get; init; }
public required string NotifManualBooking { get; init; }
public required string NotifCancellation { get; init; }
public required string NotifWaitlist { get; init; }
public required string NotifDailySummary { get; init; }
}

View file

@ -0,0 +1,44 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailHRViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelDocuments</swp-section-label>
<swp-document-list>
<swp-document-item>
<i class="ph ph-file-pdf"></i>
<swp-document-name>@Model.LabelContract</swp-document-name>
<swp-document-date>15. aug 2019</swp-document-date>
</swp-document-item>
<swp-document-item>
<i class="ph ph-file-pdf"></i>
<swp-document-name>Lønaftale 2024</swp-document-name>
<swp-document-date>1. jan 2024</swp-document-date>
</swp-document-item>
</swp-document-list>
</swp-card>
<swp-card>
<swp-section-label>@Model.LabelVacation</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>Optjent ferie</swp-edit-label>
<swp-edit-value>25 dage</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>Afholdt ferie</swp-edit-label>
<swp-edit-value>12 dage</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>Resterende</swp-edit-label>
<swp-edit-value>13 dage</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<swp-card>
<swp-section-label>@Model.LabelNotes</swp-section-label>
<swp-notes-area contenteditable="true">
Ingen noter tilføjet endnu...
</swp-notes-area>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailHRViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailHRViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = new EmployeeDetailHRViewModel
{
LabelDocuments = _localization.Get("employees.detail.hr.documents"),
LabelContract = _localization.Get("employees.detail.hr.contract"),
LabelVacation = _localization.Get("employees.detail.hr.vacation"),
LabelSickLeave = _localization.Get("employees.detail.hr.sickleave"),
LabelNotes = _localization.Get("employees.detail.hr.notes")
};
return View(model);
}
}
public class EmployeeDetailHRViewModel
{
public required string LabelDocuments { get; init; }
public required string LabelContract { get; init; }
public required string LabelVacation { get; init; }
public required string LabelSickLeave { get; init; }
public required string LabelNotes { get; init; }
}

View file

@ -0,0 +1,41 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailHeaderViewModel
<swp-employee-detail-header>
<swp-employee-avatar-large class="@Model.AvatarColor">@Model.Initials</swp-employee-avatar-large>
<swp-employee-info>
<swp-employee-name-row>
<swp-employee-name contenteditable="true">@Model.Name</swp-employee-name>
@if (Model.Tags.Any())
{
<swp-tags-row>
@foreach (var tag in Model.Tags)
{
<swp-tag class="@tag.CssClass">@tag.Text</swp-tag>
}
</swp-tags-row>
}
<swp-employee-status data-active="@Model.IsActive.ToString().ToLower()">
<span class="icon">●</span>
<span class="text">@Model.StatusText</span>
</swp-employee-status>
</swp-employee-name-row>
<swp-fact-boxes-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.BookingsThisYear</swp-fact-inline-value>
<swp-fact-inline-label>@Model.LabelBookings</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.RevenueThisYear</swp-fact-inline-value>
<swp-fact-inline-label>@Model.LabelRevenue</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.Rating</swp-fact-inline-value>
<swp-fact-inline-label>@Model.LabelRating</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.EmployedSince</swp-fact-inline-value>
<swp-fact-inline-label>@Model.LabelEmployedSince</swp-fact-inline-label>
</swp-fact-inline>
</swp-fact-boxes-inline>
</swp-employee-info>
</swp-employee-detail-header>

View file

@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailHeaderViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailHeaderViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailHeaderViewModel
{
Initials = employee.Initials,
Name = employee.Name,
AvatarColor = employee.AvatarColor,
Role = employee.Role,
RoleText = _localization.Get(employee.RoleKey),
Status = employee.Status,
StatusText = _localization.Get(employee.StatusKey),
BookingsThisYear = employee.BookingsThisYear,
RevenueThisYear = employee.RevenueThisYear,
Rating = employee.Rating,
EmployedSince = employee.EmployedSince,
LabelBookings = _localization.Get("employees.detail.bookings"),
LabelRevenue = _localization.Get("employees.detail.revenue"),
LabelRating = _localization.Get("employees.detail.rating"),
LabelEmployedSince = _localization.Get("employees.detail.employedsince"),
Tags = employee.Tags.Select(t => new EmployeeTagViewModel { Text = t.Text, CssClass = t.CssClass }).ToList()
};
return View(model);
}
}
public class EmployeeDetailHeaderViewModel
{
public required string Initials { get; init; }
public required string Name { get; init; }
public string? AvatarColor { get; init; }
public required string Role { get; init; }
public required string RoleText { get; init; }
public required string Status { get; init; }
public required string StatusText { get; init; }
public bool IsActive => Status == "active";
public required string BookingsThisYear { get; init; }
public required string RevenueThisYear { get; init; }
public required string Rating { get; init; }
public required string EmployedSince { get; init; }
public required string LabelBookings { get; init; }
public required string LabelRevenue { get; init; }
public required string LabelRating { get; init; }
public required string LabelEmployedSince { get; init; }
public List<EmployeeTagViewModel> Tags { get; init; } = new();
}
public class EmployeeTagViewModel
{
public required string Text { get; init; }
public required string CssClass { get; init; }
}

View file

@ -0,0 +1,37 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailHoursViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelWeeklySchedule</swp-section-label>
<swp-schedule-grid>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelMonday</swp-schedule-day>
<swp-schedule-time>09:00 - 17:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelTuesday</swp-schedule-day>
<swp-schedule-time>09:00 - 17:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelWednesday</swp-schedule-day>
<swp-schedule-time>09:00 - 17:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelThursday</swp-schedule-day>
<swp-schedule-time>09:00 - 19:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelFriday</swp-schedule-day>
<swp-schedule-time>09:00 - 16:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row class="off">
<swp-schedule-day>@Model.LabelSaturday</swp-schedule-day>
<swp-schedule-time>Fri</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row class="off">
<swp-schedule-day>@Model.LabelSunday</swp-schedule-day>
<swp-schedule-time>Fri</swp-schedule-time>
</swp-schedule-row>
</swp-schedule-grid>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailHoursViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailHoursViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = new EmployeeDetailHoursViewModel
{
LabelWeeklySchedule = _localization.Get("employees.detail.hours.weekly"),
LabelMonday = _localization.Get("employees.detail.hours.monday"),
LabelTuesday = _localization.Get("employees.detail.hours.tuesday"),
LabelWednesday = _localization.Get("employees.detail.hours.wednesday"),
LabelThursday = _localization.Get("employees.detail.hours.thursday"),
LabelFriday = _localization.Get("employees.detail.hours.friday"),
LabelSaturday = _localization.Get("employees.detail.hours.saturday"),
LabelSunday = _localization.Get("employees.detail.hours.sunday")
};
return View(model);
}
}
public class EmployeeDetailHoursViewModel
{
public required string LabelWeeklySchedule { get; init; }
public required string LabelMonday { get; init; }
public required string LabelTuesday { get; init; }
public required string LabelWednesday { get; init; }
public required string LabelThursday { get; init; }
public required string LabelFriday { get; init; }
public required string LabelSaturday { get; init; }
public required string LabelSunday { get; init; }
}

View file

@ -0,0 +1,39 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailSalaryViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelPaymentInfo</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelBankAccount</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.BankAccount</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelTaxCard</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.TaxCard</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<swp-card>
<swp-section-label>@Model.LabelSalarySettings</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelHourlyRate</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.HourlyRate</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelMonthlyFixed</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.MonthlyFixedSalary</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelCommission</swp-edit-label>
<swp-edit-value contenteditable="true">10%</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelProductCommission</swp-edit-label>
<swp-edit-value contenteditable="true">5%</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailSalaryViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailSalaryViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailSalaryViewModel
{
BankAccount = employee.BankAccount,
TaxCard = employee.TaxCard,
HourlyRate = employee.HourlyRate,
MonthlyFixedSalary = employee.MonthlyFixedSalary,
LabelPaymentInfo = _localization.Get("employees.detail.salary.paymentinfo"),
LabelBankAccount = _localization.Get("employees.detail.salary.bankaccount"),
LabelTaxCard = _localization.Get("employees.detail.salary.taxcard"),
LabelSalarySettings = _localization.Get("employees.detail.salary.settings"),
LabelHourlyRate = _localization.Get("employees.detail.salary.hourlyrate"),
LabelMonthlyFixed = _localization.Get("employees.detail.salary.monthlyfixed"),
LabelCommission = _localization.Get("employees.detail.salary.commission"),
LabelProductCommission = _localization.Get("employees.detail.salary.productcommission")
};
return View(model);
}
}
public class EmployeeDetailSalaryViewModel
{
public required string BankAccount { get; init; }
public required string TaxCard { get; init; }
public required string HourlyRate { get; init; }
public required string MonthlyFixedSalary { get; init; }
public required string LabelPaymentInfo { get; init; }
public required string LabelBankAccount { get; init; }
public required string LabelTaxCard { get; init; }
public required string LabelSalarySettings { get; init; }
public required string LabelHourlyRate { get; init; }
public required string LabelMonthlyFixed { get; init; }
public required string LabelCommission { get; init; }
public required string LabelProductCommission { get; init; }
}

View file

@ -0,0 +1,34 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailServicesViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelAssignedServices</swp-section-label>
<swp-service-list>
<swp-service-item>
<swp-service-name>Dameklip</swp-service-name>
<swp-service-duration>45 min</swp-service-duration>
<swp-service-price>450 kr</swp-service-price>
</swp-service-item>
<swp-service-item>
<swp-service-name>Herreklip</swp-service-name>
<swp-service-duration>30 min</swp-service-duration>
<swp-service-price>350 kr</swp-service-price>
</swp-service-item>
<swp-service-item>
<swp-service-name>Farvning</swp-service-name>
<swp-service-duration>90 min</swp-service-duration>
<swp-service-price>850 kr</swp-service-price>
</swp-service-item>
<swp-service-item>
<swp-service-name>Balayage</swp-service-name>
<swp-service-duration>120 min</swp-service-duration>
<swp-service-price>1.200 kr</swp-service-price>
</swp-service-item>
<swp-service-item>
<swp-service-name>Highlights</swp-service-name>
<swp-service-duration>90 min</swp-service-duration>
<swp-service-price>950 kr</swp-service-price>
</swp-service-item>
</swp-service-list>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailServicesViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailServicesViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = new EmployeeDetailServicesViewModel
{
LabelAssignedServices = _localization.Get("employees.detail.services.assigned")
};
return View(model);
}
}
public class EmployeeDetailServicesViewModel
{
public required string LabelAssignedServices { get; init; }
}

View file

@ -0,0 +1,25 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailStatsViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelPerformance</swp-section-label>
<swp-stats-row>
<swp-stat-card class="teal">
<swp-stat-value>@Model.BookingsThisYear</swp-stat-value>
<swp-stat-label>@Model.LabelBookingsThisYear</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="purple">
<swp-stat-value>@Model.RevenueThisYear</swp-stat-value>
<swp-stat-label>@Model.LabelRevenueThisYear</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="amber">
<swp-stat-value>@Model.Rating</swp-stat-value>
<swp-stat-label>@Model.LabelAvgRating</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value>87%</swp-stat-value>
<swp-stat-label>@Model.LabelOccupancy</swp-stat-label>
</swp-stat-card>
</swp-stats-row>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailStatsViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailStatsViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailStatsViewModel
{
BookingsThisYear = employee.BookingsThisYear,
RevenueThisYear = employee.RevenueThisYear,
Rating = employee.Rating,
LabelPerformance = _localization.Get("employees.detail.stats.performance"),
LabelBookingsThisYear = _localization.Get("employees.detail.stats.bookingsyear"),
LabelRevenueThisYear = _localization.Get("employees.detail.stats.revenueyear"),
LabelAvgRating = _localization.Get("employees.detail.stats.avgrating"),
LabelOccupancy = _localization.Get("employees.detail.stats.occupancy")
};
return View(model);
}
}
public class EmployeeDetailStatsViewModel
{
public required string BookingsThisYear { get; init; }
public required string RevenueThisYear { get; init; }
public required string Rating { get; init; }
public required string LabelPerformance { get; init; }
public required string LabelBookingsThisYear { get; init; }
public required string LabelRevenueThisYear { get; init; }
public required string LabelAvgRating { get; init; }
public required string LabelOccupancy { get; init; }
}

View file

@ -0,0 +1,74 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailViewViewModel
<swp-employee-detail-view id="employee-detail-view" data-employee="@Model.EmployeeKey">
<!-- Sticky Header (generic from page.css) -->
<swp-sticky-header>
<swp-header-content>
<!-- Page Header with Back Button -->
<swp-page-header>
<swp-page-title>
<swp-back-link data-employee-back>
<i class="ph ph-arrow-left"></i>
@Model.BackText
</swp-back-link>
</swp-page-title>
<swp-page-actions>
<swp-btn class="primary">
<i class="ph ph-floppy-disk"></i>
@Model.SaveButtonText
</swp-btn>
</swp-page-actions>
</swp-page-header>
<!-- Employee Header -->
@await Component.InvokeAsync("EmployeeDetailHeader", Model.EmployeeKey)
</swp-header-content>
<!-- Tabs (outside header-content, inside sticky-header) -->
<swp-tab-bar>
<swp-tab class="active" data-tab="general">@Model.TabGeneral</swp-tab>
<swp-tab data-tab="hours">@Model.TabHours</swp-tab>
<swp-tab data-tab="services">@Model.TabServices</swp-tab>
<swp-tab data-tab="salary">@Model.TabSalary</swp-tab>
<swp-tab data-tab="hr">@Model.TabHR</swp-tab>
<swp-tab data-tab="stats">@Model.TabStats</swp-tab>
</swp-tab-bar>
</swp-sticky-header>
<!-- Tab Contents -->
<swp-tab-content data-tab="general" class="active">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailGeneral", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="hours">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailHours", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="services">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailServices", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="salary">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailSalary", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="hr">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailHR", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="stats">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailStats", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
</swp-employee-detail-view>

View file

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailViewViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailViewViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailViewViewModel
{
EmployeeKey = employee.Key,
BackText = _localization.Get("employees.detail.back"),
SaveButtonText = _localization.Get("employees.detail.save"),
TabGeneral = _localization.Get("employees.detail.tabs.general"),
TabHours = _localization.Get("employees.detail.tabs.hours"),
TabServices = _localization.Get("employees.detail.tabs.services"),
TabSalary = _localization.Get("employees.detail.tabs.salary"),
TabHR = _localization.Get("employees.detail.tabs.hr"),
TabStats = _localization.Get("employees.detail.tabs.stats")
};
return View(model);
}
}
public class EmployeeDetailViewViewModel
{
public required string EmployeeKey { get; init; }
public required string BackText { get; init; }
public required string SaveButtonText { get; init; }
public required string TabGeneral { get; init; }
public required string TabHours { get; init; }
public required string TabServices { get; init; }
public required string TabSalary { get; init; }
public required string TabHR { get; init; }
public required string TabStats { get; init; }
}

View file

@ -0,0 +1,25 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeRowViewModel
<swp-employee-row data-employee-detail="@Model.Key">
<swp-employee-cell>
<swp-user-info>
<swp-user-avatar class="@Model.AvatarColor">@Model.Initials</swp-user-avatar>
<swp-user-details>
<swp-user-name>@Model.Name</swp-user-name>
<swp-user-email>@Model.Email</swp-user-email>
</swp-user-details>
</swp-user-info>
</swp-employee-cell>
<swp-employee-cell>
<swp-status-badge class="@Model.Role">@Model.RoleText</swp-status-badge>
</swp-employee-cell>
<swp-employee-cell>
<swp-status-badge class="@Model.Status">@Model.StatusText</swp-status-badge>
</swp-employee-cell>
<swp-employee-cell>@Model.LastActive</swp-employee-cell>
<swp-employee-cell>
<swp-row-toggle>
<i class="ph ph-caret-right"></i>
</swp-row-toggle>
</swp-employee-cell>
</swp-employee-row>

View file

@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeRowViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeRowViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = EmployeeRowCatalog.Get(key, _localization);
return View(model);
}
}
public class EmployeeRowViewModel
{
public required string Key { get; init; }
public required string Initials { get; init; }
public required string Name { get; init; }
public required string Email { get; init; }
public required string Role { get; init; }
public required string RoleText { get; init; }
public required string Status { get; init; }
public required string StatusText { get; init; }
public required string LastActive { get; init; }
public string? AvatarColor { get; init; }
public bool IsOwner { get; init; }
public bool IsInvited { get; init; }
}
internal class EmployeeRowData
{
public required string Key { get; init; }
public required string Initials { get; init; }
public required string Name { get; init; }
public required string Email { get; init; }
public required string Role { get; init; }
public required string RoleKey { get; init; }
public required string Status { get; init; }
public required string StatusKey { get; init; }
public required string LastActive { get; init; }
public string? AvatarColor { get; init; }
}
public static class EmployeeRowCatalog
{
private static readonly Dictionary<string, EmployeeRowData> Employees = new()
{
["employee-1"] = new EmployeeRowData
{
Key = "employee-1",
Initials = "MJ",
Name = "Maria Jensen",
Email = "maria@salonbeauty.dk",
Role = "owner",
RoleKey = "employees.roles.owner",
Status = "active",
StatusKey = "employees.status.active",
LastActive = "I dag, 14:32"
},
["employee-2"] = new EmployeeRowData
{
Key = "employee-2",
Initials = "AS",
Name = "Anna Sørensen",
Email = "anna@salonbeauty.dk",
Role = "admin",
RoleKey = "employees.roles.admin",
Status = "active",
StatusKey = "employees.status.active",
LastActive = "I dag, 12:15",
AvatarColor = "purple"
},
["employee-3"] = new EmployeeRowData
{
Key = "employee-3",
Initials = "LP",
Name = "Louise Pedersen",
Email = "louise@salonbeauty.dk",
Role = "leader",
RoleKey = "employees.roles.leader",
Status = "active",
StatusKey = "employees.status.active",
LastActive = "I går, 17:45",
AvatarColor = "blue"
},
["employee-4"] = new EmployeeRowData
{
Key = "employee-4",
Initials = "KN",
Name = "Katrine Nielsen",
Email = "katrine@salonbeauty.dk",
Role = "employee",
RoleKey = "employees.roles.employee",
Status = "active",
StatusKey = "employees.status.active",
LastActive = "27. dec, 09:30",
AvatarColor = "amber"
},
["employee-5"] = new EmployeeRowData
{
Key = "employee-5",
Initials = "SH",
Name = "Sofie Hansen",
Email = "sofie@salonbeauty.dk",
Role = "employee",
RoleKey = "employees.roles.employee",
Status = "invited",
StatusKey = "employees.status.invited",
LastActive = "-",
AvatarColor = "purple"
}
};
public static EmployeeRowViewModel Get(string key, ILocalizationService localization)
{
if (!Employees.TryGetValue(key, out var employee))
throw new KeyNotFoundException($"Employee with key '{key}' not found");
return new EmployeeRowViewModel
{
Key = employee.Key,
Initials = employee.Initials,
Name = employee.Name,
Email = employee.Email,
Role = employee.Role,
RoleText = localization.Get(employee.RoleKey),
Status = employee.Status,
StatusText = localization.Get(employee.StatusKey),
LastActive = employee.LastActive,
AvatarColor = employee.AvatarColor,
IsOwner = employee.Role == "owner",
IsInvited = employee.Status == "invited"
};
}
public static IEnumerable<string> AllKeys => Employees.Keys;
}

View file

@ -0,0 +1,6 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeStatCardViewModel
<swp-stat-card data-key="@Model.Key" class="@Model.Variant">
<swp-stat-value>@Model.Value</swp-stat-value>
<swp-stat-label>@Model.Label</swp-stat-label>
</swp-stat-card>

View file

@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeStatCardViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeStatCardViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = EmployeeStatCardCatalog.Get(key, _localization);
return View(model);
}
}
public class EmployeeStatCardViewModel
{
public required string Key { get; init; }
public required string Value { get; init; }
public required string Label { get; init; }
public string? Variant { get; init; }
}
internal class EmployeeStatCardData
{
public required string Key { get; init; }
public required string Value { get; init; }
public required string LabelKey { get; init; }
public string? Variant { get; init; }
}
public static class EmployeeStatCardCatalog
{
private static readonly Dictionary<string, EmployeeStatCardData> Cards = new()
{
["active-employees"] = new EmployeeStatCardData
{
Key = "active-employees",
Value = "4",
LabelKey = "employees.stats.activeEmployees",
Variant = "teal"
},
["pending-invitations"] = new EmployeeStatCardData
{
Key = "pending-invitations",
Value = "1",
LabelKey = "employees.stats.pendingInvitations",
Variant = "amber"
},
["roles-defined"] = new EmployeeStatCardData
{
Key = "roles-defined",
Value = "4",
LabelKey = "employees.stats.rolesDefined",
Variant = "purple"
}
};
public static EmployeeStatCardViewModel Get(string key, ILocalizationService localization)
{
if (!Cards.TryGetValue(key, out var card))
throw new KeyNotFoundException($"EmployeeStatCard with key '{key}' not found");
return new EmployeeStatCardViewModel
{
Key = card.Key,
Value = card.Value,
Label = localization.Get(card.LabelKey),
Variant = card.Variant
};
}
public static IEnumerable<string> AllKeys => Cards.Keys;
}

View file

@ -0,0 +1,34 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeTableViewModel
<swp-users-header>
<swp-users-count>
<strong>@Model.CurrentCount af @Model.MaxCount</strong> @Model.CountLabel
<swp-users-progress>
<swp-users-progress-bar style="width: @Model.ProgressPercent.ToString("F1", System.Globalization.CultureInfo.InvariantCulture)%"></swp-users-progress-bar>
</swp-users-progress>
</swp-users-count>
<swp-btn class="primary">
<i class="ph ph-user-plus"></i>
@Model.InviteButtonText
</swp-btn>
</swp-users-header>
<swp-employee-table-card>
<swp-employee-table>
<swp-employee-table-header>
<swp-employee-row>
<swp-employee-cell>@Model.ColumnUser</swp-employee-cell>
<swp-employee-cell>@Model.ColumnRole</swp-employee-cell>
<swp-employee-cell>@Model.ColumnStatus</swp-employee-cell>
<swp-employee-cell>@Model.ColumnLastActive</swp-employee-cell>
<swp-employee-cell></swp-employee-cell>
</swp-employee-row>
</swp-employee-table-header>
<swp-employee-table-body>
@foreach (var employeeKey in Model.EmployeeKeys)
{
@await Component.InvokeAsync("EmployeeRow", employeeKey)
}
</swp-employee-table-body>
</swp-employee-table>
</swp-employee-table-card>

View file

@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeTableViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeTableViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = EmployeeTableCatalog.Get(key, _localization);
return View(model);
}
}
public class EmployeeTableViewModel
{
public required string Key { get; init; }
public required int CurrentCount { get; init; }
public required int MaxCount { get; init; }
public required string CountLabel { get; init; }
public required string InviteButtonText { get; init; }
public required string ColumnUser { get; init; }
public required string ColumnRole { get; init; }
public required string ColumnStatus { get; init; }
public required string ColumnLastActive { get; init; }
public required IReadOnlyList<string> EmployeeKeys { get; init; }
public double ProgressPercent => MaxCount > 0 ? (double)CurrentCount / MaxCount * 100 : 0;
}
internal class EmployeeTableData
{
public required string Key { get; init; }
public required int CurrentCount { get; init; }
public required int MaxCount { get; init; }
public required IReadOnlyList<string> EmployeeKeys { get; init; }
}
public static class EmployeeTableCatalog
{
private static readonly Dictionary<string, EmployeeTableData> Tables = new()
{
["all-employees"] = new EmployeeTableData
{
Key = "all-employees",
CurrentCount = 5,
MaxCount = 8,
EmployeeKeys = ["employee-1", "employee-2", "employee-3", "employee-4", "employee-5"]
}
};
public static EmployeeTableViewModel Get(string key, ILocalizationService localization)
{
if (!Tables.TryGetValue(key, out var table))
throw new KeyNotFoundException($"EmployeeTable with key '{key}' not found");
return new EmployeeTableViewModel
{
Key = table.Key,
CurrentCount = table.CurrentCount,
MaxCount = table.MaxCount,
CountLabel = localization.Get("employees.users.count"),
InviteButtonText = localization.Get("employees.users.inviteUser"),
ColumnUser = localization.Get("employees.users.columns.user"),
ColumnRole = localization.Get("employees.users.columns.role"),
ColumnStatus = localization.Get("employees.users.columns.status"),
ColumnLastActive = localization.Get("employees.users.columns.lastActive"),
EmployeeKeys = table.EmployeeKeys
};
}
}

View file

@ -0,0 +1,41 @@
@model PlanTempus.Application.Features.Employees.Components.PermissionsMatrixViewModel
<swp-permissions-matrix>
<table>
<thead>
<tr>
<th localize="employees.permissions.title">Rettighed</th>
@foreach (var role in Model.Roles)
{
<th>@role.Name</th>
}
</tr>
</thead>
<tbody>
@foreach (var permission in Model.Permissions)
{
<tr>
<td>
<span class="permission-name">
<i class="ph @permission.Icon"></i>
@permission.Name
</span>
</td>
@foreach (var role in Model.Roles)
{
<td>
@if (permission.RoleAccess.TryGetValue(role.Key, out var hasAccess) && hasAccess)
{
<i class="ph ph-check-circle check"></i>
}
else
{
<i class="ph ph-minus no-access"></i>
}
</td>
}
</tr>
}
</tbody>
</table>
</swp-permissions-matrix>

View file

@ -0,0 +1,158 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class PermissionsMatrixViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public PermissionsMatrixViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = PermissionsMatrixCatalog.Get(key, _localization);
return View(model);
}
}
public class PermissionsMatrixViewModel
{
public required string Key { get; init; }
public required IReadOnlyList<RoleHeader> Roles { get; init; }
public required IReadOnlyList<PermissionRow> Permissions { get; init; }
}
public class RoleHeader
{
public required string Key { get; init; }
public required string Name { get; init; }
}
public class PermissionRow
{
public required string Key { get; init; }
public required string Name { get; init; }
public required string Icon { get; init; }
public required IReadOnlyDictionary<string, bool> RoleAccess { get; init; }
}
internal class PermissionsMatrixData
{
public required string Key { get; init; }
public required IReadOnlyList<string> RoleKeys { get; init; }
public required IReadOnlyList<PermissionData> Permissions { get; init; }
}
internal class PermissionData
{
public required string Key { get; init; }
public required string NameKey { get; init; }
public required string Icon { get; init; }
public required IReadOnlyDictionary<string, bool> RoleAccess { get; init; }
}
public static class PermissionsMatrixCatalog
{
private static readonly Dictionary<string, string> RoleNameKeys = new()
{
["owner"] = "employees.roles.owner",
["admin"] = "employees.roles.admin",
["leader"] = "employees.roles.leader",
["employee"] = "employees.roles.employee"
};
private static readonly Dictionary<string, PermissionsMatrixData> Matrices = new()
{
["default"] = new PermissionsMatrixData
{
Key = "default",
RoleKeys = ["owner", "admin", "leader", "employee"],
Permissions =
[
new PermissionData
{
Key = "calendar",
NameKey = "employees.permissions.calendar",
Icon = "ph-calendar",
RoleAccess = new Dictionary<string, bool>
{
["owner"] = true,
["admin"] = true,
["leader"] = true,
["employee"] = true
}
},
new PermissionData
{
Key = "employees",
NameKey = "employees.permissions.employees",
Icon = "ph-users",
RoleAccess = new Dictionary<string, bool>
{
["owner"] = true,
["admin"] = true,
["leader"] = true,
["employee"] = false
}
},
new PermissionData
{
Key = "customers",
NameKey = "employees.permissions.customers",
Icon = "ph-address-book",
RoleAccess = new Dictionary<string, bool>
{
["owner"] = true,
["admin"] = true,
["leader"] = true,
["employee"] = true
}
},
new PermissionData
{
Key = "reports",
NameKey = "employees.permissions.reports",
Icon = "ph-chart-bar",
RoleAccess = new Dictionary<string, bool>
{
["owner"] = true,
["admin"] = true,
["leader"] = false,
["employee"] = false
}
}
]
}
};
public static PermissionsMatrixViewModel Get(string key, ILocalizationService localization)
{
if (!Matrices.TryGetValue(key, out var matrix))
throw new KeyNotFoundException($"PermissionsMatrix with key '{key}' not found");
var roles = matrix.RoleKeys.Select(roleKey => new RoleHeader
{
Key = roleKey,
Name = localization.Get(RoleNameKeys[roleKey])
}).ToList();
var permissions = matrix.Permissions.Select(p => new PermissionRow
{
Key = p.Key,
Name = localization.Get(p.NameKey),
Icon = p.Icon,
RoleAccess = p.RoleAccess
}).ToList();
return new PermissionsMatrixViewModel
{
Key = matrix.Key,
Roles = roles,
Permissions = permissions
};
}
}

View file

@ -0,0 +1,57 @@
@page "/medarbejdere"
@using PlanTempus.Application.Features.Employees.Components
@model PlanTempus.Application.Features.Employees.Pages.IndexModel
@{
ViewData["Title"] = "Medarbejdere";
}
<!-- List View (default) -->
<swp-employees-list-view id="employees-list-view">
<!-- Sticky Header (Header + Tabs) -->
<swp-sticky-header>
<!-- Header with page title and stats (has border-bottom) -->
<swp-header-content>
<swp-page-header>
<swp-page-title>
<h1 localize="employees.title">Medarbejdere</h1>
<p localize="employees.subtitle">Administrer brugere, roller og rettigheder</p>
</swp-page-title>
</swp-page-header>
<swp-stats-row>
@await Component.InvokeAsync("EmployeeStatCard", "active-employees")
@await Component.InvokeAsync("EmployeeStatCard", "pending-invitations")
@await Component.InvokeAsync("EmployeeStatCard", "roles-defined")
</swp-stats-row>
</swp-header-content>
<!-- Tab bar (outside header, so line is above tabs) -->
<swp-tab-bar>
<swp-tab class="active" data-tab="users">
<i class="ph ph-users"></i>
<span localize="employees.tabs.users">Brugere</span>
</swp-tab>
<swp-tab data-tab="roles">
<i class="ph ph-shield-check"></i>
<span localize="employees.tabs.roles">Roller</span>
</swp-tab>
</swp-tab-bar>
</swp-sticky-header>
<!-- Tab: Users -->
<swp-tab-content data-tab="users" class="active">
<swp-page-container>
@await Component.InvokeAsync("EmployeeTable", "all-employees")
</swp-page-container>
</swp-tab-content>
<!-- Tab: Roles -->
<swp-tab-content data-tab="roles">
<swp-page-container>
@await Component.InvokeAsync("PermissionsMatrix", "default")
</swp-page-container>
</swp-tab-content>
</swp-employees-list-view>
<!-- Detail View (hidden by default, shown when row clicked) -->
@await Component.InvokeAsync("EmployeeDetailView", "employee-1")

View file

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace PlanTempus.Application.Features.Employees.Pages;
public class IndexModel : PageModel
{
public void OnGet()
{
}
}

View file

@ -29,7 +29,9 @@
"to": "Til", "to": "Til",
"all": "Alle", "all": "Alle",
"reset": "Nulstil", "reset": "Nulstil",
"status": "Status" "status": "Status",
"yes": "Ja",
"no": "Nej"
}, },
"sidebar": { "sidebar": {
"lockScreen": "Lås skærm", "lockScreen": "Lås skærm",
@ -216,5 +218,144 @@
"pending": "Afventer", "pending": "Afventer",
"overdue": "Forfalden" "overdue": "Forfalden"
} }
},
"employees": {
"title": "Medarbejdere",
"subtitle": "Administrer brugere, roller og rettigheder",
"stats": {
"activeEmployees": "Aktive medarbejdere",
"pendingInvitations": "Afventer invitation",
"rolesDefined": "Roller defineret"
},
"tabs": {
"users": "Brugere",
"roles": "Roller"
},
"users": {
"count": "brugere",
"inviteUser": "Inviter bruger",
"columns": {
"user": "Bruger",
"role": "Rolle",
"status": "Status",
"lastActive": "Sidst aktiv"
}
},
"roles": {
"owner": "Ejer",
"admin": "Admin",
"leader": "Leder",
"employee": "Medarbejder"
},
"status": {
"active": "Aktiv",
"invited": "Invitation sendt"
},
"permissions": {
"title": "Rettighed",
"calendar": "Kalender",
"employees": "Medarbejdere",
"customers": "Kunder",
"reports": "Rapporter & Økonomi"
},
"actions": {
"edit": "Rediger",
"remove": "Fjern bruger",
"resend": "Send invitation igen",
"cancel": "Annuller invitation"
},
"detail": {
"title": "Medarbejderdetaljer",
"back": "Tilbage til medarbejdere",
"save": "Gem ændringer",
"tabs": {
"general": "Generelt",
"hours": "Arbejdstid",
"services": "Services",
"salary": "Løn",
"hr": "HR",
"stats": "Statistik"
},
"contact": "Kontaktoplysninger",
"personal": "Personlige oplysninger",
"employment": "Ansættelse",
"fullname": "Fulde navn",
"email": "E-mail",
"phone": "Telefon",
"address": "Adresse",
"postalcity": "Postnr. & By",
"birthdate": "Fødselsdato",
"emergencycontact": "Nødkontakt",
"emergencyphone": "Nødkontakt tlf.",
"employmentdate": "Ansættelsesdato",
"position": "Stilling",
"employmenttype": "Ansættelsestype",
"hoursperweek": "Timer/uge",
"bookings": "bookinger i år",
"revenue": "omsætning i år",
"rating": "rating",
"employedsince": "ansat siden",
"hours": {
"weekly": "Ugentlig arbejdstid",
"monday": "Mandag",
"tuesday": "Tirsdag",
"wednesday": "Onsdag",
"thursday": "Torsdag",
"friday": "Fredag",
"saturday": "Lørdag",
"sunday": "Søndag"
},
"services": {
"assigned": "Tildelte services"
},
"salary": {
"paymentinfo": "Betalingsoplysninger",
"bankaccount": "Bankkonto",
"taxcard": "Skattekort",
"settings": "Lønindstillinger",
"hourlyrate": "Timesats",
"monthlyfixed": "Fast månedsløn",
"commission": "Provision (services)",
"productcommission": "Provision (produkter)"
},
"hr": {
"documents": "Dokumenter",
"contract": "Ansættelseskontrakt",
"vacation": "Ferie",
"sickleave": "Sygefravær",
"notes": "Noter"
},
"stats": {
"performance": "Performance",
"bookingsyear": "Bookinger i år",
"revenueyear": "Omsætning i år",
"avgrating": "Gns. rating",
"occupancy": "Belægningsgrad"
},
"settings": {
"label": "Indstillinger",
"showinbooking": {
"label": "Vis i online booking",
"desc": "Kunder kan vælge denne medarbejder"
},
"smsreminders": {
"label": "Modtag SMS-påmindelser",
"desc": "Få besked om nye bookinger"
},
"editcalendar": {
"label": "Kan redigere egen kalender",
"desc": "Tillad ændringer i egne bookinger"
}
},
"notifications": {
"label": "Notifikationer",
"intro": "Vælg hvilke email-notifikationer medarbejderen skal modtage.",
"onlinebooking": "Modtag email ved online booking",
"manualbooking": "Modtag email ved manuel booking",
"cancellation": "Modtag email ved aflysning",
"waitlist": "Modtag email ved opskrivning til venteliste",
"dailysummary": "Modtag daglig oversigt over morgendagens bookinger"
}
}
} }
} }

View file

@ -29,7 +29,9 @@
"to": "To", "to": "To",
"all": "All", "all": "All",
"reset": "Reset", "reset": "Reset",
"status": "Status" "status": "Status",
"yes": "Yes",
"no": "No"
}, },
"sidebar": { "sidebar": {
"lockScreen": "Lock screen", "lockScreen": "Lock screen",
@ -216,5 +218,107 @@
"pending": "Pending", "pending": "Pending",
"overdue": "Overdue" "overdue": "Overdue"
} }
},
"employees": {
"title": "Employees",
"subtitle": "Manage users, roles and permissions",
"stats": {
"activeEmployees": "Active employees",
"pendingInvitations": "Pending invitations",
"rolesDefined": "Roles defined"
},
"tabs": {
"users": "Users",
"roles": "Roles"
},
"users": {
"count": "users",
"inviteUser": "Invite user",
"columns": {
"user": "User",
"role": "Role",
"status": "Status",
"lastActive": "Last active"
}
},
"roles": {
"owner": "Owner",
"admin": "Admin",
"leader": "Manager",
"employee": "Employee"
},
"status": {
"active": "Active",
"invited": "Invitation sent"
},
"permissions": {
"title": "Permission",
"calendar": "Calendar",
"employees": "Employees",
"customers": "Customers",
"reports": "Reports & Finance"
},
"actions": {
"edit": "Edit",
"remove": "Remove user",
"resend": "Resend invitation",
"cancel": "Cancel invitation"
},
"detail": {
"title": "Employee details",
"back": "Back to employees",
"save": "Save changes",
"tabs": {
"general": "General",
"hours": "Working hours",
"services": "Services",
"salary": "Salary",
"hr": "HR",
"stats": "Statistics"
},
"contact": "Contact information",
"personal": "Personal information",
"employment": "Employment",
"fullname": "Full name",
"email": "Email",
"phone": "Phone",
"address": "Address",
"postalcity": "Postal code & City",
"birthdate": "Date of birth",
"emergencycontact": "Emergency contact",
"emergencyphone": "Emergency phone",
"employmentdate": "Employment date",
"position": "Position",
"employmenttype": "Employment type",
"hoursperweek": "Hours/week",
"bookings": "bookings this year",
"revenue": "revenue this year",
"rating": "rating",
"employedsince": "employed since",
"settings": {
"label": "Settings",
"showinbooking": {
"label": "Show in online booking",
"desc": "Customers can select this employee"
},
"smsreminders": {
"label": "Receive SMS reminders",
"desc": "Get notified about new bookings"
},
"editcalendar": {
"label": "Can edit own calendar",
"desc": "Allow changes to own bookings"
}
},
"notifications": {
"label": "Notifications",
"intro": "Choose which email notifications the employee should receive.",
"onlinebooking": "Receive email for online booking",
"manualbooking": "Receive email for manual booking",
"cancellation": "Receive email for cancellation",
"waitlist": "Receive email for waitlist signup",
"dailysummary": "Receive daily summary of tomorrow's bookings"
}
}
} }
} }

View file

@ -126,8 +126,8 @@ public class MockMenuService : IMenuService
{ {
Id = "employees", Id = "employees",
Label = "Medarbejdere", Label = "Medarbejdere",
Icon = "ph-user", Icon = "ph-users-three",
Url = "/poc-medarbejdere.html", Url = "/medarbejdere",
MinimumRole = UserRole.Manager, MinimumRole = UserRole.Manager,
SortOrder = 4 SortOrder = 4
} }

View file

@ -24,9 +24,11 @@
<link rel="stylesheet" href="~/css/quick-stats.css"> <link rel="stylesheet" href="~/css/quick-stats.css">
<link rel="stylesheet" href="~/css/waitlist.css"> <link rel="stylesheet" href="~/css/waitlist.css">
<link rel="stylesheet" href="~/css/tabs.css"> <link rel="stylesheet" href="~/css/tabs.css">
<link rel="stylesheet" href="~/css/controls.css">
<link rel="stylesheet" href="~/css/cash.css"> <link rel="stylesheet" href="~/css/cash.css">
<link rel="stylesheet" href="~/css/auth.css"> <link rel="stylesheet" href="~/css/auth.css">
<link rel="stylesheet" href="~/css/account.css"> <link rel="stylesheet" href="~/css/account.css">
<link rel="stylesheet" href="~/css/employees.css">
@await RenderSectionAsync("Styles", required: false) @await RenderSectionAsync("Styles", required: false)
</head> </head>
<body class="has-demo-banner"> <body class="has-demo-banner">

View file

@ -0,0 +1,439 @@
import { PurgeCSS } from 'purgecss';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Create reports directory if it doesn't exist
const reportsDir = './reports';
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir);
}
console.log('🔍 Starting CSS Analysis for PlanTempus...\n');
// 1. Run PurgeCSS to find unused CSS
console.log('📊 Running PurgeCSS analysis...');
async function runPurgeCSS() {
const purgeCSSResults = await new PurgeCSS().purge({
content: [
'./Features/**/*.cshtml',
'./wwwroot/ts/**/*.ts'
],
css: [
'./wwwroot/css/*.css'
],
rejected: true,
rejectedCss: true,
safelist: {
standard: [
/^swp-/, // All custom web components
/^ph-/, // Phosphor icons
'active', // Tab states
'checked', // Checkbox states
'collapsed',
'expanded',
'hidden',
'has-demo-banner',
/^owner$/, // Role badges
/^admin$/,
/^leader$/,
/^employee$/,
/^purple$/, // Avatar colors
/^blue$/,
/^amber$/,
/^teal$/,
/^master$/, // Employee tags
/^senior$/,
/^junior$/,
/^cert$/,
/^draft$/, // Status badges
/^approved$/,
/^invited$/,
/^danger$/, // Button variants
/^primary$/,
/^secondary$/
]
}
});
// Calculate statistics
let totalOriginalSize = 0;
let totalPurgedSize = 0;
let totalRejected = 0;
const rejectedByFile = {};
purgeCSSResults.forEach(result => {
const fileName = path.basename(result.file);
const originalSize = result.css.length + (result.rejected ? result.rejected.join('').length : 0);
const purgedSize = result.css.length;
const rejectedSize = result.rejected ? result.rejected.length : 0;
totalOriginalSize += originalSize;
totalPurgedSize += purgedSize;
totalRejected += rejectedSize;
rejectedByFile[fileName] = {
originalSize,
purgedSize,
rejectedCount: rejectedSize,
rejected: result.rejected || []
};
});
const report = {
summary: {
totalFiles: purgeCSSResults.length,
totalOriginalSize,
totalPurgedSize,
totalRejected,
percentageRemoved: ((totalRejected / (totalOriginalSize || 1)) * 100).toFixed(2) + '%',
potentialSavings: totalOriginalSize - totalPurgedSize
},
fileDetails: rejectedByFile
};
fs.writeFileSync(
path.join(reportsDir, 'purgecss-report.json'),
JSON.stringify(report, null, 2)
);
console.log('✅ PurgeCSS analysis complete');
console.log(` - Total CSS rules analyzed: ${totalOriginalSize}`);
console.log(` - Unused CSS rules found: ${totalRejected}`);
console.log(` - Potential removal: ${report.summary.percentageRemoved}`);
return report;
}
// 2. Analyze CSS with basic stats
console.log('\n📊 Running CSS Stats analysis...');
function runCSSStats() {
const cssDir = './wwwroot/css';
const cssFiles = fs.readdirSync(cssDir)
.filter(file => file.endsWith('.css'))
.map(file => path.join(cssDir, file));
const stats = {};
cssFiles.forEach(file => {
if (fs.existsSync(file)) {
const fileName = path.basename(file);
const content = fs.readFileSync(file, 'utf8');
// Basic statistics
const lines = content.split('\n').length;
const size = Buffer.byteLength(content, 'utf8');
const rules = (content.match(/\{[^}]*\}/g) || []).length;
const selectors = (content.match(/[^{]+(?=\{)/g) || []).length;
const properties = (content.match(/[^:]+:[^;]+;/g) || []).length;
const colors = [...new Set(content.match(/#[0-9a-fA-F]{3,6}|rgba?\([^)]+\)|hsla?\([^)]+\)|var\(--color-[^)]+\)/g) || [])];
const mediaQueries = (content.match(/@media[^{]+/g) || []).length;
const cssVariables = [...new Set(content.match(/var\(--[^)]+\)/g) || [])];
stats[fileName] = {
lines,
size: `${(size / 1024).toFixed(2)} KB`,
sizeBytes: size,
rules,
selectors,
properties,
uniqueColors: colors.length,
colors: colors.slice(0, 10), // First 10 colors
mediaQueries,
cssVariables: cssVariables.length
};
}
});
fs.writeFileSync(
path.join(reportsDir, 'css-stats.json'),
JSON.stringify(stats, null, 2)
);
console.log('✅ CSS Stats analysis complete');
console.log(` - Files analyzed: ${Object.keys(stats).length}`);
return stats;
}
// 3. Generate HTML report
function generateHTMLReport(purgeReport, statsReport) {
const totalSize = Object.values(statsReport).reduce((sum, stat) => sum + stat.sizeBytes, 0);
const totalSizeKB = (totalSize / 1024).toFixed(2);
const totalLines = Object.values(statsReport).reduce((sum, stat) => sum + stat.lines, 0);
const html = `
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS Analysis Report - PlanTempus</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #14b8a6;
margin-bottom: 10px;
font-size: 2.5em;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 1.1em;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.stat-card.warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.stat-card.info {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
}
.stat-card.success {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.stat-value {
font-size: 2.5em;
font-weight: bold;
margin: 10px 0;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
}
section {
margin-bottom: 40px;
}
h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #14b8a6;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background: #f8f9fa;
}
.file-detail {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.rejected-list {
max-height: 200px;
overflow-y: auto;
background: white;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
}
.badge-danger { background: #ffebee; color: #c62828; }
.badge-warning { background: #fff3e0; color: #ef6c00; }
.badge-success { background: #e8f5e9; color: #2e7d32; }
.timestamp {
color: #999;
font-size: 0.9em;
margin-top: 30px;
text-align: center;
}
.color-palette {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 10px;
}
.color-swatch {
width: 30px;
height: 30px;
border-radius: 4px;
border: 1px solid #ddd;
}
details {
margin-top: 10px;
}
summary {
cursor: pointer;
color: #14b8a6;
font-weight: 500;
}
</style>
</head>
<body>
<div class="container">
<h1>📊 CSS Analysis Report</h1>
<p class="subtitle">PlanTempus - Production CSS Analysis</p>
<div class="summary">
<div class="stat-card">
<div class="stat-label">Total CSS Size</div>
<div class="stat-value">${totalSizeKB} KB</div>
</div>
<div class="stat-card info">
<div class="stat-label">CSS Files</div>
<div class="stat-value">${purgeReport.summary.totalFiles}</div>
</div>
<div class="stat-card info">
<div class="stat-label">Total Lines</div>
<div class="stat-value">${totalLines.toLocaleString()}</div>
</div>
<div class="stat-card warning">
<div class="stat-label">Unused CSS Rules</div>
<div class="stat-value">${purgeReport.summary.totalRejected}</div>
</div>
</div>
<section>
<h2>📈 CSS Statistics by File</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Size</th>
<th>Lines</th>
<th>Rules</th>
<th>Selectors</th>
<th>Properties</th>
<th>CSS Vars</th>
</tr>
</thead>
<tbody>
${Object.entries(statsReport)
.sort((a, b) => b[1].sizeBytes - a[1].sizeBytes)
.map(([file, stats]) => `
<tr>
<td><strong>${file}</strong></td>
<td>${stats.size}</td>
<td>${stats.lines}</td>
<td>${stats.rules}</td>
<td>${stats.selectors}</td>
<td>${stats.properties}</td>
<td>${stats.cssVariables}</td>
</tr>
`).join('')}
</tbody>
</table>
</section>
<section>
<h2>🗑 Unused CSS by File</h2>
${Object.entries(purgeReport.fileDetails)
.sort((a, b) => b[1].rejectedCount - a[1].rejectedCount)
.map(([file, details]) => `
<div class="file-detail">
<h3>${file}</h3>
<p>
<span class="badge ${details.rejectedCount > 50 ? 'badge-danger' : details.rejectedCount > 20 ? 'badge-warning' : 'badge-success'}">
${details.rejectedCount} unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: ${details.originalSize} | After purge: ${details.purgedSize}
</span>
</p>
${details.rejectedCount > 0 ? `
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
${details.rejected.slice(0, 50).join('<br>')}
${details.rejected.length > 50 ? `<br><em>... and ${details.rejected.length - 50} more</em>` : ''}
</div>
</details>
` : '<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>'}
</div>
`).join('')}
</section>
<section>
<h2>💡 Recommendations</h2>
<ul style="line-height: 2;">
${purgeReport.summary.totalRejected > 100 ?
'<li>⚠️ <strong>High number of unused CSS rules detected.</strong> Consider removing unused styles to improve performance.</li>' :
'<li>✅ CSS usage is relatively clean.</li>'}
${Object.values(purgeReport.fileDetails).some(d => d.rejectedCount > 50) ?
'<li>⚠️ Some files have significant unused CSS. Review these files for optimization opportunities.</li>' : ''}
<li>📦 Consider consolidating similar styles to reduce duplication.</li>
<li>🎨 Review color usage - ensure all colors use CSS variables from design-tokens.css.</li>
<li>📋 Reference COMPONENT-CATALOG.md when adding new components to avoid duplication.</li>
</ul>
</section>
<p class="timestamp">Report generated: ${new Date().toLocaleString('da-DK')}</p>
</div>
</body>
</html>
`;
fs.writeFileSync(path.join(reportsDir, 'css-analysis-report.html'), html);
console.log('\n✅ HTML report generated: reports/css-analysis-report.html');
}
// Run all analyses
(async () => {
try {
const purgeReport = await runPurgeCSS();
const statsReport = runCSSStats();
generateHTMLReport(purgeReport, statsReport);
console.log('\n🎉 CSS Analysis Complete!');
console.log('📄 Reports generated in ./reports/ directory');
console.log(' - purgecss-report.json (detailed unused CSS data)');
console.log(' - css-stats.json (CSS statistics)');
console.log(' - css-analysis-report.html (visual report)');
console.log('\n💡 Open reports/css-analysis-report.html in your browser to view the full report');
} catch (error) {
console.error('❌ Error during analysis:', error);
process.exit(1);
}
})();

View file

@ -5,7 +5,8 @@
"packages": { "packages": {
"": { "": {
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.2" "esbuild": "^0.27.2",
"purgecss": "^6.0.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@ -450,6 +451,150 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@ -491,6 +636,500 @@
"@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2" "@esbuild/win32-x64": "0.27.2"
} }
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/purgecss": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/purgecss/-/purgecss-6.0.0.tgz",
"integrity": "sha512-s3EBxg5RSWmpqd0KGzNqPiaBbWDz1/As+2MzoYVGMqgDqRTLBhJW6sywfTBek7OwNfoS/6pS0xdtvChNhFj2cw==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^12.0.0",
"glob": "^10.3.10",
"postcss": "^8.4.4",
"postcss-selector-parser": "^6.0.7"
},
"bin": {
"purgecss": "bin/purgecss.js"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
} }
} }
} }

View file

@ -1,5 +1,10 @@
{ {
"type": "module",
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.2" "esbuild": "^0.27.2",
"purgecss": "^6.0.0"
},
"scripts": {
"analyze-css": "node analyze-css.js"
} }
} }

View file

@ -0,0 +1,776 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS Analysis Report - PlanTempus</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #14b8a6;
margin-bottom: 10px;
font-size: 2.5em;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 1.1em;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.stat-card.warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.stat-card.info {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
}
.stat-card.success {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.stat-value {
font-size: 2.5em;
font-weight: bold;
margin: 10px 0;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
}
section {
margin-bottom: 40px;
}
h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #14b8a6;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background: #f8f9fa;
}
.file-detail {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.rejected-list {
max-height: 200px;
overflow-y: auto;
background: white;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
}
.badge-danger { background: #ffebee; color: #c62828; }
.badge-warning { background: #fff3e0; color: #ef6c00; }
.badge-success { background: #e8f5e9; color: #2e7d32; }
.timestamp {
color: #999;
font-size: 0.9em;
margin-top: 30px;
text-align: center;
}
.color-palette {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 10px;
}
.color-swatch {
width: 30px;
height: 30px;
border-radius: 4px;
border: 1px solid #ddd;
}
details {
margin-top: 10px;
}
summary {
cursor: pointer;
color: #14b8a6;
font-weight: 500;
}
</style>
</head>
<body>
<div class="container">
<h1>📊 CSS Analysis Report</h1>
<p class="subtitle">PlanTempus - Production CSS Analysis</p>
<div class="summary">
<div class="stat-card">
<div class="stat-label">Total CSS Size</div>
<div class="stat-value">132.81 KB</div>
</div>
<div class="stat-card info">
<div class="stat-label">CSS Files</div>
<div class="stat-value">21</div>
</div>
<div class="stat-card info">
<div class="stat-label">Total Lines</div>
<div class="stat-value">6.033</div>
</div>
<div class="stat-card warning">
<div class="stat-label">Unused CSS Rules</div>
<div class="stat-value">61</div>
</div>
</div>
<section>
<h2>📈 CSS Statistics by File</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Size</th>
<th>Lines</th>
<th>Rules</th>
<th>Selectors</th>
<th>Properties</th>
<th>CSS Vars</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>auth.css</strong></td>
<td>23.66 KB</td>
<td>1144</td>
<td>169</td>
<td>173</td>
<td>571</td>
<td>46</td>
</tr>
<tr>
<td><strong>cash.css</strong></td>
<td>19.38 KB</td>
<td>898</td>
<td>132</td>
<td>135</td>
<td>415</td>
<td>42</td>
</tr>
<tr>
<td><strong>employees.css</strong></td>
<td>15.55 KB</td>
<td>722</td>
<td>105</td>
<td>108</td>
<td>345</td>
<td>37</td>
</tr>
<tr>
<td><strong>design-tokens.css</strong></td>
<td>9.02 KB</td>
<td>318</td>
<td>35</td>
<td>36</td>
<td>192</td>
<td>25</td>
</tr>
<tr>
<td><strong>account.css</strong></td>
<td>8.83 KB</td>
<td>402</td>
<td>60</td>
<td>63</td>
<td>173</td>
<td>31</td>
</tr>
<tr>
<td><strong>drawers.css</strong></td>
<td>6.36 KB</td>
<td>297</td>
<td>38</td>
<td>38</td>
<td>159</td>
<td>32</td>
</tr>
<tr>
<td><strong>page.css</strong></td>
<td>6.00 KB</td>
<td>276</td>
<td>38</td>
<td>40</td>
<td>117</td>
<td>35</td>
</tr>
<tr>
<td><strong>sidebar.css</strong></td>
<td>5.72 KB</td>
<td>247</td>
<td>30</td>
<td>30</td>
<td>119</td>
<td>24</td>
</tr>
<tr>
<td><strong>waitlist.css</strong></td>
<td>5.55 KB</td>
<td>251</td>
<td>30</td>
<td>30</td>
<td>131</td>
<td>31</td>
</tr>
<tr>
<td><strong>stats.css</strong></td>
<td>5.18 KB</td>
<td>232</td>
<td>30</td>
<td>32</td>
<td>78</td>
<td>27</td>
</tr>
<tr>
<td><strong>bookings.css</strong></td>
<td>4.27 KB</td>
<td>176</td>
<td>28</td>
<td>28</td>
<td>75</td>
<td>27</td>
</tr>
<tr>
<td><strong>topbar.css</strong></td>
<td>3.79 KB</td>
<td>181</td>
<td>19</td>
<td>19</td>
<td>103</td>
<td>20</td>
</tr>
<tr>
<td><strong>controls.css</strong></td>
<td>3.32 KB</td>
<td>149</td>
<td>19</td>
<td>19</td>
<td>79</td>
<td>20</td>
</tr>
<tr>
<td><strong>demo-banner.css</strong></td>
<td>2.94 KB</td>
<td>146</td>
<td>19</td>
<td>21</td>
<td>66</td>
<td>9</td>
</tr>
<tr>
<td><strong>attentions.css</strong></td>
<td>2.92 KB</td>
<td>115</td>
<td>15</td>
<td>15</td>
<td>45</td>
<td>15</td>
</tr>
<tr>
<td><strong>design-system.css</strong></td>
<td>2.30 KB</td>
<td>105</td>
<td>20</td>
<td>20</td>
<td>37</td>
<td>20</td>
</tr>
<tr>
<td><strong>tabs.css</strong></td>
<td>2.13 KB</td>
<td>95</td>
<td>11</td>
<td>11</td>
<td>42</td>
<td>19</td>
</tr>
<tr>
<td><strong>base.css</strong></td>
<td>2.06 KB</td>
<td>119</td>
<td>15</td>
<td>15</td>
<td>47</td>
<td>8</td>
</tr>
<tr>
<td><strong>notifications.css</strong></td>
<td>1.67 KB</td>
<td>70</td>
<td>8</td>
<td>8</td>
<td>27</td>
<td>8</td>
</tr>
<tr>
<td><strong>app-layout.css</strong></td>
<td>1.28 KB</td>
<td>51</td>
<td>5</td>
<td>5</td>
<td>18</td>
<td>6</td>
</tr>
<tr>
<td><strong>quick-stats.css</strong></td>
<td>0.88 KB</td>
<td>39</td>
<td>4</td>
<td>4</td>
<td>15</td>
<td>11</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>🗑️ Unused CSS by File</h2>
<div class="file-detail">
<h3>design-tokens.css</h3>
<p>
<span class="badge badge-warning">
24 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 7917 | After purge: 7639
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
.is-red<br>.is-pink<br>.is-magenta<br>.is-purple<br>.is-violet<br>.is-deep-purple<br>.is-indigo<br>.is-blue<br>.is-light-blue<br>.is-cyan<br>.is-teal<br>.is-green<br>.is-light-green<br>.is-lime<br>.is-yellow<br>.is-amber<br>.is-orange<br>.is-deep-orange<br>.status-confirmed<br>.status-pending<br>.status-inprogress<br>.status-error<br>.status-active<br>.status-inactive
</div>
</details>
</div>
<div class="file-detail">
<h3>design-system.css</h3>
<p>
<span class="badge badge-success">
11 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 2095 | After purge: 2056
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
h2<br> h3<br> h4<br> h5<br> h6<br>h2<br>h3<br>h4<br>h5<br>h6<br>:focus-visible
</div>
</details>
</div>
<div class="file-detail">
<h3>bookings.css</h3>
<p>
<span class="badge badge-success">
6 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 3924 | After purge: 3756
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-booking-item.inprogress<br>swp-booking-indicator.green<br>swp-booking-status.confirmed<br>swp-booking-status.pending<br>swp-booking-status.inprogress<br>
swp-booking-status.in-progress
</div>
</details>
</div>
<div class="file-detail">
<h3>attentions.css</h3>
<p>
<span class="badge badge-success">
5 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 2524 | After purge: 2359
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-attention-item.urgent<br>swp-attention-item.urgent:hover<br>swp-attention-item.info<br>swp-attention-item.urgent swp-attention-icon<br>swp-attention-item.info swp-attention-icon
</div>
</details>
</div>
<div class="file-detail">
<h3>base.css</h3>
<p>
<span class="badge badge-success">
4 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 1952 | After purge: 1930
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
ul<br> ol<br>img<br>:focus-visible
</div>
</details>
</div>
<div class="file-detail">
<h3>auth.css</h3>
<p>
<span class="badge badge-success">
4 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 23887 | After purge: 23816
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-btn.social<br>swp-btn.social:hover<br>swp-btn.social img<br>swp-plan-badge.free
</div>
</details>
</div>
<div class="file-detail">
<h3>stats.css</h3>
<p>
<span class="badge badge-success">
3 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 5298 | After purge: 5229
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-stat-trend.up<br>swp-stat-trend.down<br>
swp-stat-card.red swp-stat-value
</div>
</details>
</div>
<div class="file-detail">
<h3>account.css</h3>
<p>
<span class="badge badge-success">
2 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 8829 | After purge: 8777
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-invoice-status.pending<br>swp-invoice-status.overdue
</div>
</details>
</div>
<div class="file-detail">
<h3>drawers.css</h3>
<p>
<span class="badge badge-success">
1 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 6485 | After purge: 6467
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
[data-drawer="xl"]
</div>
</details>
</div>
<div class="file-detail">
<h3>cash.css</h3>
<p>
<span class="badge badge-success">
1 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 19816 | After purge: 19778
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-cash-stat.user swp-cash-stat-value
</div>
</details>
</div>
<div class="file-detail">
<h3>waitlist.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 5686 | After purge: 5686
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>topbar.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 3885 | After purge: 3885
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>tabs.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 2183 | After purge: 2183
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>sidebar.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 5859 | After purge: 5859
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>quick-stats.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 899 | After purge: 899
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>page.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 6140 | After purge: 6140
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>notifications.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 1712 | After purge: 1712
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>employees.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 15923 | After purge: 15923
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>demo-banner.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 3014 | After purge: 3014
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>controls.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 3397 | After purge: 3397
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>app-layout.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 1306 | After purge: 1306
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
</section>
<section>
<h2>💡 Recommendations</h2>
<ul style="line-height: 2;">
<li>✅ CSS usage is relatively clean.</li>
<li>📦 Consider consolidating similar styles to reduce duplication.</li>
<li>🎨 Review color usage - ensure all colors use CSS variables from design-tokens.css.</li>
<li>📋 Reference COMPONENT-CATALOG.md when adding new components to avoid duplication.</li>
</ul>
</section>
<p class="timestamp">Report generated: 12.1.2026, 21.57.11</p>
</div>
</body>
</html>

View file

@ -0,0 +1,437 @@
{
"account.css": {
"lines": 402,
"size": "8.83 KB",
"sizeBytes": 9037,
"rules": 60,
"selectors": 63,
"properties": 173,
"uniqueColors": 13,
"colors": [
"var(--color-text)",
"var(--color-teal)",
"var(--color-background-alt)",
"var(--color-text-secondary)",
"var(--color-surface)",
"var(--color-purple)",
"var(--color-amber)",
"rgba(0, 0, 0, 0.08)",
"var(--color-border)",
"var(--color-blue)"
],
"mediaQueries": 3,
"cssVariables": 31
},
"app-layout.css": {
"lines": 51,
"size": "1.28 KB",
"sizeBytes": 1306,
"rules": 5,
"selectors": 5,
"properties": 18,
"uniqueColors": 2,
"colors": [
"var(--color-background)",
"rgba(0, 0, 0, 0.5)"
],
"mediaQueries": 0,
"cssVariables": 6
},
"attentions.css": {
"lines": 115,
"size": "2.92 KB",
"sizeBytes": 2992,
"rules": 15,
"selectors": 15,
"properties": 45,
"uniqueColors": 8,
"colors": [
"var(--color-background-alt)",
"var(--color-border)",
"var(--color-background-hover)",
"var(--color-red)",
"var(--color-amber)",
"var(--color-blue)",
"var(--color-text-secondary)",
"var(--color-teal)"
],
"mediaQueries": 0,
"cssVariables": 15
},
"auth.css": {
"lines": 1144,
"size": "23.66 KB",
"sizeBytes": 24232,
"rules": 169,
"selectors": 173,
"properties": 571,
"uniqueColors": 17,
"colors": [
"var(--color-teal)",
"#00695c",
"rgba(255,255,255,0.1)",
"rgba(255,255,255,0.08)",
"var(--color-background)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-surface)",
"var(--color-border)",
"var(--color-purple)"
],
"mediaQueries": 3,
"cssVariables": 46
},
"base.css": {
"lines": 119,
"size": "2.06 KB",
"sizeBytes": 2105,
"rules": 15,
"selectors": 15,
"properties": 47,
"uniqueColors": 4,
"colors": [
"var(--color-text)",
"var(--color-background)",
"var(--color-teal)",
"var(--color-teal-light)"
],
"mediaQueries": 0,
"cssVariables": 8
},
"bookings.css": {
"lines": 176,
"size": "4.27 KB",
"sizeBytes": 4369,
"rules": 28,
"selectors": 28,
"properties": 75,
"uniqueColors": 10,
"colors": [
"var(--color-background-alt)",
"var(--color-background-hover)",
"var(--color-border)",
"var(--color-teal)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-blue)",
"var(--color-purple)",
"var(--color-amber)",
"var(--color-green)"
],
"mediaQueries": 0,
"cssVariables": 27
},
"cash.css": {
"lines": 898,
"size": "19.38 KB",
"sizeBytes": 19850,
"rules": 132,
"selectors": 135,
"properties": 415,
"uniqueColors": 14,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text-secondary)",
"var(--color-teal)",
"var(--color-background-alt)",
"var(--color-text)",
"var(--color-amber)",
"var(--color-red)",
"var(--color-blue)",
"var(--color-background-hover)"
],
"mediaQueries": 3,
"cssVariables": 42
},
"controls.css": {
"lines": 149,
"size": "3.32 KB",
"sizeBytes": 3397,
"rules": 19,
"selectors": 19,
"properties": 79,
"uniqueColors": 8,
"colors": [
"var(--color-border)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-background)",
"var(--color-green)",
"var(--color-red)",
"var(--color-background-alt)",
"var(--color-teal)"
],
"mediaQueries": 0,
"cssVariables": 20
},
"demo-banner.css": {
"lines": 146,
"size": "2.94 KB",
"sizeBytes": 3014,
"rules": 19,
"selectors": 21,
"properties": 66,
"uniqueColors": 7,
"colors": [
"var(--color-teal)",
"#00796b",
"rgba(255, 255, 255, 0.95)",
"#00695c",
"rgba(0, 0, 0, 0.15)",
"rgba(255, 255, 255, 0.15)",
"rgba(255, 255, 255, 0.25)"
],
"mediaQueries": 2,
"cssVariables": 9
},
"design-system.css": {
"lines": 105,
"size": "2.30 KB",
"sizeBytes": 2356,
"rules": 20,
"selectors": 20,
"properties": 37,
"uniqueColors": 7,
"colors": [
"var(--color-text)",
"var(--color-background)",
"var(--color-text-secondary)",
"var(--color-teal)",
"var(--color-primary)",
"var(--color-border)",
"var(--color-text-muted)"
],
"mediaQueries": 0,
"cssVariables": 20
},
"design-tokens.css": {
"lines": 318,
"size": "9.02 KB",
"sizeBytes": 9235,
"rules": 35,
"selectors": 36,
"properties": 192,
"uniqueColors": 54,
"colors": [
"#fff",
"#f5f5f5",
"#f0f0f0",
"#fafafa",
"#e0e0e0",
"#333",
"#666",
"#999",
"#1976d2",
"#00897b"
],
"mediaQueries": 2,
"cssVariables": 25
},
"drawers.css": {
"lines": 297,
"size": "6.36 KB",
"sizeBytes": 6513,
"rules": 38,
"selectors": 38,
"properties": 159,
"uniqueColors": 9,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-background-alt)",
"var(--color-background-hover)",
"var(--color-teal)",
"var(--color-background)",
"var(--color-red)"
],
"mediaQueries": 0,
"cssVariables": 32
},
"employees.css": {
"lines": 722,
"size": "15.55 KB",
"sizeBytes": 15923,
"rules": 105,
"selectors": 108,
"properties": 345,
"uniqueColors": 15,
"colors": [
"var(--color-text-secondary)",
"var(--color-text)",
"var(--color-border)",
"var(--color-teal)",
"var(--color-surface)",
"rgba(0, 0, 0, 0.08)",
"var(--color-background-alt)",
"var(--color-background-hover)",
"var(--color-purple)",
"var(--color-blue)"
],
"mediaQueries": 3,
"cssVariables": 37
},
"notifications.css": {
"lines": 70,
"size": "1.67 KB",
"sizeBytes": 1712,
"rules": 8,
"selectors": 8,
"properties": 27,
"uniqueColors": 4,
"colors": [
"var(--color-background-alt)",
"var(--color-background-hover)",
"var(--color-teal)",
"var(--color-text-secondary)"
],
"mediaQueries": 0,
"cssVariables": 8
},
"page.css": {
"lines": 276,
"size": "6.00 KB",
"sizeBytes": 6141,
"rules": 38,
"selectors": 40,
"properties": 117,
"uniqueColors": 8,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-teal)",
"var(--color-purple)",
"var(--color-background)",
"var(--color-background-hover)"
],
"mediaQueries": 2,
"cssVariables": 35
},
"quick-stats.css": {
"lines": 39,
"size": "0.88 KB",
"sizeBytes": 899,
"rules": 4,
"selectors": 4,
"properties": 15,
"uniqueColors": 3,
"colors": [
"var(--color-background-alt)",
"var(--color-text)",
"var(--color-text-secondary)"
],
"mediaQueries": 0,
"cssVariables": 11
},
"sidebar.css": {
"lines": 247,
"size": "5.72 KB",
"sizeBytes": 5859,
"rules": 30,
"selectors": 30,
"properties": 119,
"uniqueColors": 9,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-teal)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-background-hover)",
"var(--color-teal-light)",
"var(--color-amber)",
"var(--color-red)"
],
"mediaQueries": 0,
"cssVariables": 24
},
"stats.css": {
"lines": 232,
"size": "5.18 KB",
"sizeBytes": 5301,
"rules": 30,
"selectors": 32,
"properties": 78,
"uniqueColors": 14,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-text-muted)",
"var(--color-green)",
"var(--color-red)",
"var(--color-teal)",
"var(--color-amber)",
"var(--color-purple)"
],
"mediaQueries": 2,
"cssVariables": 27
},
"tabs.css": {
"lines": 95,
"size": "2.13 KB",
"sizeBytes": 2183,
"rules": 11,
"selectors": 11,
"properties": 42,
"uniqueColors": 6,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text-secondary)",
"var(--color-text)",
"var(--color-background-alt)",
"var(--color-teal)"
],
"mediaQueries": 0,
"cssVariables": 19
},
"topbar.css": {
"lines": 181,
"size": "3.79 KB",
"sizeBytes": 3885,
"rules": 19,
"selectors": 19,
"properties": 103,
"uniqueColors": 8,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-background)",
"var(--color-teal)",
"var(--color-text-secondary)",
"var(--color-text)",
"var(--color-background-hover)",
"var(--color-red)"
],
"mediaQueries": 0,
"cssVariables": 20
},
"waitlist.css": {
"lines": 251,
"size": "5.55 KB",
"sizeBytes": 5686,
"rules": 30,
"selectors": 30,
"properties": 131,
"uniqueColors": 9,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-teal)",
"var(--color-text-secondary)",
"var(--color-text)",
"var(--color-background-alt)",
"var(--color-background)",
"var(--color-amber)",
"var(--color-background-hover)"
],
"mediaQueries": 0,
"cssVariables": 31
}
}

View file

@ -0,0 +1,209 @@
{
"summary": {
"totalFiles": 21,
"totalOriginalSize": 132731,
"totalPurgedSize": 131811,
"totalRejected": 61,
"percentageRemoved": "0.05%",
"potentialSavings": 920
},
"fileDetails": {
"waitlist.css": {
"originalSize": 5686,
"purgedSize": 5686,
"rejectedCount": 0,
"rejected": []
},
"topbar.css": {
"originalSize": 3885,
"purgedSize": 3885,
"rejectedCount": 0,
"rejected": []
},
"tabs.css": {
"originalSize": 2183,
"purgedSize": 2183,
"rejectedCount": 0,
"rejected": []
},
"stats.css": {
"originalSize": 5298,
"purgedSize": 5229,
"rejectedCount": 3,
"rejected": [
"swp-stat-trend.up",
"swp-stat-trend.down",
"\nswp-stat-card.red swp-stat-value"
]
},
"sidebar.css": {
"originalSize": 5859,
"purgedSize": 5859,
"rejectedCount": 0,
"rejected": []
},
"quick-stats.css": {
"originalSize": 899,
"purgedSize": 899,
"rejectedCount": 0,
"rejected": []
},
"page.css": {
"originalSize": 6140,
"purgedSize": 6140,
"rejectedCount": 0,
"rejected": []
},
"notifications.css": {
"originalSize": 1712,
"purgedSize": 1712,
"rejectedCount": 0,
"rejected": []
},
"employees.css": {
"originalSize": 15923,
"purgedSize": 15923,
"rejectedCount": 0,
"rejected": []
},
"drawers.css": {
"originalSize": 6485,
"purgedSize": 6467,
"rejectedCount": 1,
"rejected": [
"[data-drawer=\"xl\"]"
]
},
"design-tokens.css": {
"originalSize": 7917,
"purgedSize": 7639,
"rejectedCount": 24,
"rejected": [
".is-red",
".is-pink",
".is-magenta",
".is-purple",
".is-violet",
".is-deep-purple",
".is-indigo",
".is-blue",
".is-light-blue",
".is-cyan",
".is-teal",
".is-green",
".is-light-green",
".is-lime",
".is-yellow",
".is-amber",
".is-orange",
".is-deep-orange",
".status-confirmed",
".status-pending",
".status-inprogress",
".status-error",
".status-active",
".status-inactive"
]
},
"design-system.css": {
"originalSize": 2095,
"purgedSize": 2056,
"rejectedCount": 11,
"rejected": [
" h2",
" h3",
" h4",
" h5",
" h6",
"h2",
"h3",
"h4",
"h5",
"h6",
":focus-visible"
]
},
"demo-banner.css": {
"originalSize": 3014,
"purgedSize": 3014,
"rejectedCount": 0,
"rejected": []
},
"controls.css": {
"originalSize": 3397,
"purgedSize": 3397,
"rejectedCount": 0,
"rejected": []
},
"cash.css": {
"originalSize": 19816,
"purgedSize": 19778,
"rejectedCount": 1,
"rejected": [
"swp-cash-stat.user swp-cash-stat-value"
]
},
"bookings.css": {
"originalSize": 3924,
"purgedSize": 3756,
"rejectedCount": 6,
"rejected": [
"swp-booking-item.inprogress",
"swp-booking-indicator.green",
"swp-booking-status.confirmed",
"swp-booking-status.pending",
"swp-booking-status.inprogress",
"\nswp-booking-status.in-progress"
]
},
"base.css": {
"originalSize": 1952,
"purgedSize": 1930,
"rejectedCount": 4,
"rejected": [
"ul",
" ol",
"img",
":focus-visible"
]
},
"auth.css": {
"originalSize": 23887,
"purgedSize": 23816,
"rejectedCount": 4,
"rejected": [
"swp-btn.social",
"swp-btn.social:hover",
"swp-btn.social img",
"swp-plan-badge.free"
]
},
"attentions.css": {
"originalSize": 2524,
"purgedSize": 2359,
"rejectedCount": 5,
"rejected": [
"swp-attention-item.urgent",
"swp-attention-item.urgent:hover",
"swp-attention-item.info",
"swp-attention-item.urgent swp-attention-icon",
"swp-attention-item.info swp-attention-icon"
]
},
"app-layout.css": {
"originalSize": 1306,
"purgedSize": 1306,
"rejectedCount": 0,
"rejected": []
},
"account.css": {
"originalSize": 8829,
"purgedSize": 8777,
"rejectedCount": 2,
"rejected": [
"swp-invoice-status.pending",
"swp-invoice-status.overdue"
]
}
}
}

View file

@ -0,0 +1,305 @@
# SWP Design System - Component Catalog
Reference for alle genbrugelige komponenter. **LAV ALDRIG EN NY KOMPONENT HVIS DEN ALLEREDE EKSISTERER HER.**
---
## Page Structure (page.css)
| Element | Beskrivelse | Eksempel |
|---------|-------------|----------|
| `swp-page-container` | Hovedcontainer for side | `<swp-page-container>...</swp-page-container>` |
| `swp-page-header` | Side header med titel og actions | Flex, space-between |
| `swp-page-title` | Titel-wrapper med h1 og p | h1 + subtitle |
| `swp-page-actions` | Action buttons i header | Flex gap |
| `swp-card` | Standard card wrapper | Border, padding, rounded |
| `swp-card-header` | Card header | Flex, title + action |
| `swp-card-title` | Card titel med ikon | `<swp-card-title><i>...</i> Text</swp-card-title>` |
| `swp-card-content` | Card indhold | Block |
---
## Stats Components (stats.css)
### Containers
| Element | Kolonner | Brug |
|---------|----------|------|
| `swp-stats-bar` | 4 kolonner | Dashboard stats |
| `swp-stats-grid` | 4 kolonner | Grid layout |
| `swp-stats-row` | 3 kolonner | Feature pages (Employees, etc.) |
### Stat Card
```html
<swp-stat-card class="[variant]">
<swp-stat-value>42</swp-stat-value>
<swp-stat-label>Aktive brugere</swp-stat-label>
</swp-stat-card>
```
**Varianter (class):**
- `highlight` / `teal` - Teal farve
- `success` - Grøn
- `warning` / `amber` - Amber/orange
- `danger` / `negative` / `red` - Rød
- `purple` - Lilla
- `highlight filled` - Filled teal baggrund
**VIGTIGT:** `swp-stat-value` bruger `font-family: var(--font-mono)` automatisk!
---
## Tabs (tabs.css)
```html
<swp-tab-bar>
<swp-tab class="active" data-tab="users">
<i class="ph ph-users"></i>
<span>Brugere</span>
</swp-tab>
<swp-tab data-tab="roles">
<i class="ph ph-shield-check"></i>
<span>Roller</span>
</swp-tab>
</swp-tab-bar>
<swp-tab-content data-tab="users" class="active">
<!-- Content -->
</swp-tab-content>
<swp-tab-content data-tab="roles">
<!-- Content -->
</swp-tab-content>
```
**VIGTIGT:**
- Aktiv tab: `class="active"` (IKKE data-active="true")
- Tab content: `class="active"` for at vise
---
## Buttons (cash.css)
```html
<swp-btn class="primary">
<i class="ph ph-plus"></i>
Tilføj
</swp-btn>
```
**Varianter:**
- `primary` - Teal baggrund, hvid tekst
- `secondary` - Hvid baggrund, border
- `ghost` - Transparent
---
## Badges (cash.css)
**ALLE badges bruger `swp-status-badge`** - kun farve og indhold ændres.
```html
<swp-status-badge class="[variant]">Tekst</swp-status-badge>
```
**Varianter:**
| Class | Farve | Brug |
|-------|-------|------|
| `approved` | Grøn | Godkendt status |
| `active` | Grøn | Aktiv status |
| `draft` | Amber | Kladde status |
| `invited` | Amber | Invitation sendt |
| `owner` | Teal | Ejer rolle |
| `admin` | Purple | Admin rolle |
| `leader` | Blue | Leder rolle |
| `employee` | Grå | Medarbejder rolle |
Automatisk dot via `::before` pseudo-element.
---
## Tables - Grid + Subgrid Pattern
### Struktur (ALTID følg dette mønster)
```html
<swp-[feature]-table>
<swp-[feature]-table-header>
<swp-[feature]-cell>Kolonne 1</swp-[feature]-cell>
<swp-[feature]-cell>Kolonne 2</swp-[feature]-cell>
</swp-[feature]-table-header>
<swp-[feature]-table-body>
<swp-[feature]-row>
<swp-[feature]-cell>Data 1</swp-[feature]-cell>
<swp-[feature]-cell>Data 2</swp-[feature]-cell>
</swp-[feature]-row>
</swp-[feature]-table-body>
</swp-[feature]-table>
```
### CSS Pattern
```css
swp-[feature]-table {
display: grid;
grid-template-columns: /* definer kolonner her */;
}
swp-[feature]-table-header,
swp-[feature]-table-body {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
swp-[feature]-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
```
### Eksisterende tabeller
| Feature | Container | Row | CSS fil |
|---------|-----------|-----|---------|
| Cash | `swp-cash-table` | `swp-cash-table-row` | cash.css |
| Employees | `swp-employee-table` | `swp-employee-row` | employees.css |
| Bookings | `swp-booking-list` | `swp-booking-item` | bookings.css |
| Notifications | `swp-notification-list` | `swp-notification-item` | notifications.css |
| Attentions | `swp-attention-list` | `swp-attention-item` | attentions.css |
---
## Table Cells - Standard Styling
```css
/* Header cells */
swp-[feature]-table-header swp-[feature]-cell {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
}
/* Body cells */
swp-[feature]-cell {
padding: var(--spacing-5);
font-size: var(--font-size-base); /* ALTID base, ikke sm */
color: var(--color-text);
}
```
---
## Icon Buttons (employees.css)
```html
<swp-table-actions>
<swp-icon-btn title="Rediger">
<i class="ph ph-pencil"></i>
</swp-icon-btn>
<swp-icon-btn class="danger" title="Slet">
<i class="ph ph-trash"></i>
</swp-icon-btn>
</swp-table-actions>
```
---
## User Info Pattern (employees.css)
```html
<swp-user-info>
<swp-user-avatar class="[color]">MJ</swp-user-avatar>
<swp-user-details>
<swp-user-name>Maria Jensen</swp-user-name>
<swp-user-email>maria@example.com</swp-user-email>
</swp-user-details>
</swp-user-info>
```
**Avatar farver:** (ingen class = teal), `purple`, `blue`, `amber`
---
## Design Tokens (design-tokens.css)
### Farver
| Token | Brug |
|-------|------|
| `--color-teal` | Primary brand, success |
| `--color-green` | Success, positive |
| `--color-amber` | Warning, pending |
| `--color-red` | Error, danger |
| `--color-purple` | Special, AI |
| `--color-blue` | Info |
### Spacing
```css
--spacing-1: 2px;
--spacing-2: 4px;
--spacing-3: 6px;
--spacing-4: 8px;
--spacing-5: 10px;
--spacing-6: 12px;
--spacing-7: 14px;
--spacing-8: 16px;
--spacing-10: 20px;
--spacing-12: 24px;
```
### Font Sizes
```css
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-md: 13px;
--font-size-base: 14px; /* Standard body text */
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-size-2xl: 20px;
--font-size-3xl: 22px;
```
### Font Families
```css
--font-family: 'Poppins', sans-serif;
--font-mono: 'JetBrains Mono', monospace; /* Til tal/værdier */
```
---
## Checklist for Ny Side
1. [ ] Læs denne fil
2. [ ] List UI elementer der skal bruges
3. [ ] Match hver element med eksisterende komponent
4. [ ] Dokumenter kun NYE elementer der skal oprettes
5. [ ] Opret feature CSS med header der angiver genbrugte komponenter
6. [ ] Brug `var(--font-size-base)` for body text
7. [ ] Brug `var(--font-mono)` kun for tal/værdier
---
## Fil Reference
| Fil | Indhold |
|-----|---------|
| `design-tokens.css` | Farver, spacing, fonts, shadows |
| `design-system.css` | Base resets, typography |
| `page.css` | Page structure, cards |
| `stats.css` | Stat cards, stat rows |
| `tabs.css` | Tab bar, tab content |
| `cash.css` | Buttons, status badges, tables |
| `employees.css` | User info, role badges, employee table |
| `bookings.css` | Booking list items |
| `notifications.css` | Notification items |
| `attentions.css` | Attention items |

View file

@ -2,36 +2,9 @@
* Cash Register - Page Styling * Cash Register - Page Styling
* *
* Filter bar, stats, table, forms, and difference box * Filter bar, stats, table, forms, and difference box
* Reuses: swp-sticky-header, swp-header-content (page.css)
*/ */
/* ===========================================
STICKY HEADER CONTAINER
=========================================== */
swp-cash-sticky-header {
display: block;
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: var(--color-surface);
overflow: visible;
}
/* Override tab-bar sticky when inside sticky header */
swp-cash-sticky-header swp-tab-bar {
position: static;
top: auto;
}
/* ===========================================
KASSE HEADER (Stats above tabs)
=========================================== */
swp-cash-header {
display: block;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-10) var(--spacing-12);
}
/* =========================================== /* ===========================================
FILTER BAR FILTER BAR
=========================================== */ =========================================== */
@ -375,16 +348,40 @@ swp-status-badge::before {
background: currentColor; background: currentColor;
} }
swp-status-badge.approved { /* Status variants */
swp-status-badge.approved,
swp-status-badge.active {
background: color-mix(in srgb, var(--color-green) 15%, transparent); background: color-mix(in srgb, var(--color-green) 15%, transparent);
color: var(--color-green); color: var(--color-green);
} }
swp-status-badge.draft { swp-status-badge.draft,
swp-status-badge.invited {
background: color-mix(in srgb, var(--color-amber) 15%, transparent); background: color-mix(in srgb, var(--color-amber) 15%, transparent);
color: #b45309; color: #b45309;
} }
/* Role variants */
swp-status-badge.owner {
background: color-mix(in srgb, var(--color-teal) 15%, transparent);
color: var(--color-teal);
}
swp-status-badge.admin {
background: color-mix(in srgb, var(--color-purple) 15%, transparent);
color: var(--color-purple);
}
swp-status-badge.leader {
background: color-mix(in srgb, var(--color-blue) 15%, transparent);
color: var(--color-blue);
}
swp-status-badge.employee {
background: var(--color-background-alt);
color: var(--color-text-secondary);
}
/* =========================================== /* ===========================================
TWO-COLUMN GRID (Detail View) TWO-COLUMN GRID (Detail View)
=========================================== */ =========================================== */

View file

@ -0,0 +1,148 @@
/**
* Form Controls - Toggles, Checkboxes, Inputs
*
* Reusable form control components used across the application.
*/
/* ===========================================
TOGGLE SLIDER (Yes/No switch)
=========================================== */
swp-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-3) 0;
border-bottom: 1px solid var(--color-border);
}
swp-toggle-row:last-child {
border-bottom: none;
}
swp-toggle-label {
font-size: var(--font-size-base);
color: var(--color-text);
}
swp-toggle-description {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-top: var(--spacing-1);
}
swp-toggle-slider {
display: inline-flex;
width: fit-content;
background: var(--color-background);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
overflow: hidden;
position: relative;
}
swp-toggle-slider::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: calc(50% - 4px);
height: calc(100% - 4px);
background: color-mix(in srgb, var(--color-green) 18%, white);
border-radius: var(--radius-sm);
transition: transform 200ms ease, background 200ms ease;
}
swp-toggle-slider[data-value="no"]::before {
transform: translateX(100%);
background: color-mix(in srgb, var(--color-red) 18%, white);
}
swp-toggle-option {
position: relative;
z-index: 1;
padding: var(--spacing-2) var(--spacing-5);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast);
user-select: none;
}
swp-toggle-slider[data-value="yes"] swp-toggle-option:first-child {
color: var(--color-green);
font-weight: var(--font-weight-semibold);
}
swp-toggle-slider[data-value="no"] swp-toggle-option:last-child {
color: var(--color-red);
font-weight: var(--font-weight-semibold);
}
/* ===========================================
CHECKBOX LIST
=========================================== */
swp-checkbox-list {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
swp-checkbox-row {
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
}
swp-checkbox-row:hover {
background: var(--color-background-alt);
}
swp-checkbox-box {
width: 18px;
height: 18px;
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
transition: all var(--transition-fast);
}
swp-checkbox-row.checked swp-checkbox-box {
background: var(--color-teal);
border-color: var(--color-teal);
}
swp-checkbox-box svg {
width: 12px;
height: 12px;
fill: white;
opacity: 0;
transition: opacity var(--transition-fast);
}
swp-checkbox-row.checked swp-checkbox-box svg {
opacity: 1;
}
swp-checkbox-text {
font-size: var(--font-size-base);
color: var(--color-text);
line-height: 1.4;
}
/* Intro text for checkbox lists (e.g. notifications) */
swp-notification-intro {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-5);
}

View file

@ -0,0 +1,721 @@
/**
* Employees Styles - User & Role Management
*
* Employees-specific styling only.
* Reuses: swp-stat-card (stats.css), swp-stats-row (stats.css), swp-tab-bar (tabs.css),
* swp-btn (cash.css), swp-status-badge (cash.css), swp-row-toggle (cash.css),
* swp-sticky-header, swp-header-content (page.css),
* swp-toggle-slider, swp-checkbox-list (controls.css)
*
* Creates: swp-employee-table, swp-employee-row, swp-user-info,
* swp-employee-avatar-large, swp-employee-detail-header,
* swp-fact-inline, swp-edit-row, swp-detail-grid
*/
/* ===========================================
USERS HEADER
=========================================== */
swp-users-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--section-gap);
}
swp-users-count {
display: flex;
align-items: center;
gap: var(--spacing-4);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-users-count strong {
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
swp-users-progress {
width: 120px;
height: 6px;
background: var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
swp-users-progress-bar {
display: block;
height: 100%;
background: var(--color-teal);
border-radius: var(--radius-sm);
transition: width var(--transition-normal);
}
/* ===========================================
EMPLOYEE TABLE (Grid + Subgrid)
=========================================== */
swp-employee-table-card {
display: block;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
swp-employee-table {
display: grid;
grid-template-columns: minmax(220px, 1fr) 120px 140px 120px 40px;
}
swp-employee-table-header {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
background: var(--color-background-alt);
padding: 0 var(--spacing-10);
}
swp-employee-table-body {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
swp-employee-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
padding: 0 var(--spacing-10);
border-bottom: 1px solid var(--color-border);
transition: background var(--transition-fast);
}
swp-employee-table-body swp-employee-row {
cursor: pointer;
}
swp-employee-table-body swp-employee-row:hover {
background: var(--color-background-hover);
}
swp-employee-row:last-child {
border-bottom: none;
}
swp-employee-cell {
padding: var(--spacing-5) 0;
font-size: var(--font-size-base);
color: var(--color-text);
}
/* Chevron cell (last column) */
swp-employee-cell:last-child {
display: flex;
align-items: center;
justify-content: center;
}
/* Header cells */
swp-employee-table-header swp-employee-cell {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
padding-top: var(--spacing-5);
padding-bottom: var(--spacing-5);
}
/* ===========================================
USER INFO (Avatar + Details)
=========================================== */
swp-user-info {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
swp-user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-teal);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
}
swp-user-avatar.purple {
background: var(--color-purple);
}
swp-user-avatar.blue {
background: var(--color-blue);
}
swp-user-avatar.amber {
background: var(--color-amber);
}
swp-user-details {
min-width: 0;
}
swp-user-name {
display: block;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
swp-user-email {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ===========================================
TABLE ACTIONS
=========================================== */
swp-table-actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
justify-content: flex-end;
}
swp-icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: transparent;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
swp-icon-btn:hover {
background: var(--color-background-alt);
color: var(--color-text);
}
swp-icon-btn.danger:hover {
background: color-mix(in srgb, var(--color-red) 10%, transparent);
color: var(--color-red);
}
swp-icon-btn i {
font-size: 18px;
}
/* ===========================================
PERMISSIONS MATRIX
=========================================== */
swp-permissions-matrix {
display: block;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
swp-permissions-matrix table {
width: 100%;
border-collapse: collapse;
}
swp-permissions-matrix th,
swp-permissions-matrix td {
padding: var(--spacing-5) var(--spacing-6);
text-align: center;
border-bottom: 1px solid var(--color-border);
font-size: var(--font-size-base);
}
swp-permissions-matrix th:first-child,
swp-permissions-matrix td:first-child {
text-align: left;
font-weight: var(--font-weight-medium);
}
swp-permissions-matrix thead th {
background: var(--color-background-alt);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
}
swp-permissions-matrix tbody tr:last-child td {
border-bottom: none;
}
swp-permissions-matrix .permission-name {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-base);
color: var(--color-text);
}
swp-permissions-matrix .permission-name i {
font-size: 18px;
color: var(--color-text-secondary);
}
swp-permissions-matrix .check {
color: var(--color-teal);
font-size: 20px;
}
swp-permissions-matrix .no-access {
color: var(--color-border);
font-size: 16px;
}
/* ===========================================
EMPLOYEE DETAIL VIEW (replaces page content)
=========================================== */
/* Large avatar for detail view */
swp-employee-avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--color-teal);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
}
swp-employee-avatar-large.purple { background: var(--color-purple); }
swp-employee-avatar-large.blue { background: var(--color-blue); }
swp-employee-avatar-large.amber { background: var(--color-amber); }
/* Detail header content (inside swp-header-content) */
swp-employee-detail-header {
display: flex;
gap: var(--spacing-12);
}
swp-employee-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
/* Name row with name + tags + status */
swp-employee-name-row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
swp-employee-name {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
/* Tags row */
swp-tags-row {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
swp-tag {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1) var(--spacing-3);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.3px;
border-radius: var(--radius-sm);
background: var(--color-background);
color: var(--color-text-secondary);
}
swp-tag.master {
background: color-mix(in srgb, var(--color-purple) 15%, white);
color: var(--color-purple);
}
swp-tag.senior {
background: color-mix(in srgb, var(--color-blue) 15%, white);
color: var(--color-blue);
}
swp-tag.junior {
background: color-mix(in srgb, var(--color-amber) 15%, white);
color: #b45309;
}
swp-tag.cert {
background: color-mix(in srgb, var(--color-teal) 15%, white);
color: var(--color-teal);
}
/* Employee status indicator */
swp-employee-status {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
margin-left: auto;
}
swp-employee-status[data-active="true"] {
background: color-mix(in srgb, var(--color-green) 15%, white);
color: var(--color-green);
border: 1px solid color-mix(in srgb, var(--color-green) 30%, white);
}
swp-employee-status[data-active="false"] {
background: color-mix(in srgb, var(--color-red) 12%, white);
color: var(--color-red);
border: 1px solid color-mix(in srgb, var(--color-red) 30%, white);
}
swp-employee-status .icon {
font-size: var(--font-size-base);
}
/* Inline fact boxes (horizontal baseline alignment) */
swp-fact-boxes-inline {
display: flex;
gap: var(--spacing-12);
margin-top: var(--spacing-1);
flex-wrap: wrap;
}
swp-fact-inline {
display: flex;
align-items: baseline;
gap: var(--spacing-2);
}
swp-fact-inline-value {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
font-family: var(--font-mono);
color: var(--color-text);
}
swp-fact-inline-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Edit rows for contenteditable (Grid + Subgrid) */
swp-edit-section {
display: grid;
grid-template-columns: 140px 1fr;
gap: var(--spacing-4);
}
swp-edit-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
swp-edit-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-edit-value {
font-size: var(--font-size-base);
color: var(--color-text);
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-sm);
background: var(--color-background-alt);
border: 1px solid transparent;
transition: all var(--transition-fast);
cursor: text;
}
swp-edit-value:hover {
background: var(--color-background);
}
swp-edit-value:focus {
outline: none;
background: var(--color-surface);
border-color: var(--color-teal);
}
/* Section label in cards */
swp-section-label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
margin-bottom: var(--spacing-6);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
}
/* ===========================================
VIEW CONTAINERS (List/Detail swap)
=========================================== */
swp-employees-list-view {
display: block;
}
swp-employee-detail-view {
display: none;
min-height: calc(100vh - 60px);
}
/* Back link */
swp-back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: color var(--transition-fast);
}
swp-back-link:hover {
color: var(--color-teal);
}
swp-back-link i {
font-size: 16px;
}
/* Detail grid for cards (2-column layout) */
swp-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8);
}
swp-detail-grid > div {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
swp-detail-grid swp-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--card-padding);
}
@media (max-width: 900px) {
swp-detail-grid {
grid-template-columns: 1fr;
}
}
/* ===========================================
SCHEDULE GRID (Hours tab)
=========================================== */
swp-schedule-grid {
display: flex;
flex-direction: column;
}
swp-schedule-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
}
swp-schedule-row:last-child {
border-bottom: none;
}
swp-schedule-row.off {
color: var(--color-text-secondary);
}
swp-schedule-day {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
swp-schedule-time {
font-size: var(--font-size-base);
font-family: var(--font-mono);
color: var(--color-teal);
}
swp-schedule-row.off swp-schedule-time {
color: var(--color-text-secondary);
}
/* ===========================================
SERVICE LIST (Services tab)
=========================================== */
swp-service-list {
display: flex;
flex-direction: column;
}
swp-service-item {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--spacing-6);
align-items: center;
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
}
swp-service-item:last-child {
border-bottom: none;
}
swp-service-name {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
swp-service-duration {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-service-price {
font-size: var(--font-size-base);
font-family: var(--font-mono);
color: var(--color-teal);
font-weight: var(--font-weight-medium);
}
/* ===========================================
DOCUMENT LIST (HR tab)
=========================================== */
swp-document-list {
display: flex;
flex-direction: column;
}
swp-document-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background var(--transition-fast);
}
swp-document-item:last-child {
border-bottom: none;
}
swp-document-item:hover {
background: var(--color-background-alt);
margin: 0 calc(-1 * var(--spacing-3));
padding-left: var(--spacing-3);
padding-right: var(--spacing-3);
border-radius: var(--radius-sm);
}
swp-document-item i {
font-size: 24px;
color: var(--color-red);
}
swp-document-name {
flex: 1;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
swp-document-date {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Notes area */
swp-notes-area {
display: block;
min-height: 100px;
padding: var(--spacing-4);
background: var(--color-background-alt);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
line-height: 1.6;
}
swp-notes-area:focus {
outline: none;
color: var(--color-text);
}
/* ===========================================
RESPONSIVE
=========================================== */
@media (max-width: 1024px) {
swp-employee-table {
grid-template-columns: minmax(180px, 1fr) 100px 120px 100px 40px;
}
}
@media (max-width: 768px) {
swp-employee-table {
grid-template-columns: minmax(160px, 1fr) 90px 110px 90px 40px;
}
swp-employee-cell {
padding: var(--spacing-3) 0;
}
swp-employee-table-header,
swp-employee-row {
padding: 0 var(--spacing-4);
}
swp-users-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
swp-users-header swp-btn {
width: 100%;
justify-content: center;
}
}

View file

@ -14,6 +14,42 @@ swp-page-container {
padding: var(--page-padding); padding: var(--page-padding);
} }
/* ===========================================
STICKY HEADER (Generic - use for all tabbed pages)
=========================================== */
swp-sticky-header {
display: block;
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: var(--color-surface);
overflow: visible;
/* INGEN padding eller border - det er på swp-header-content */
}
/* Override tab-bar sticky when inside sticky header */
swp-sticky-header swp-tab-bar {
position: static;
top: auto;
}
/* Header content wrapper - HAR padding + border */
swp-header-content {
display: block;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-10) var(--spacing-12);
}
swp-header-content swp-page-header {
padding: 0;
margin-bottom: 0;
}
swp-header-content swp-stats-row {
margin-bottom: 0;
}
/* =========================================== /* ===========================================
PAGE HEADER PAGE HEADER
=========================================== */ =========================================== */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,7 @@ import { ThemeController } from './modules/theme';
import { SearchController } from './modules/search'; import { SearchController } from './modules/search';
import { LockScreenController } from './modules/lockscreen'; import { LockScreenController } from './modules/lockscreen';
import { CashController } from './modules/cash'; import { CashController } from './modules/cash';
import { EmployeesController } from './modules/employees';
/** /**
* Main application class * Main application class
@ -21,6 +22,7 @@ export class App {
readonly search: SearchController; readonly search: SearchController;
readonly lockScreen: LockScreenController; readonly lockScreen: LockScreenController;
readonly cash: CashController; readonly cash: CashController;
readonly employees: EmployeesController;
constructor() { constructor() {
// Initialize controllers // Initialize controllers
@ -30,6 +32,7 @@ export class App {
this.search = new SearchController(); this.search = new SearchController();
this.lockScreen = new LockScreenController(this.drawers); this.lockScreen = new LockScreenController(this.drawers);
this.cash = new CashController(); this.cash = new CashController();
this.employees = new EmployeesController();
} }
} }

View file

@ -0,0 +1,191 @@
/**
* Employees Controller
*
* Handles content swap between list view and detail view,
* plus tab switching within each view.
* Uses History API for browser back/forward navigation.
*/
export class EmployeesController {
private listView: HTMLElement | null = null;
private detailView: HTMLElement | null = null;
constructor() {
this.listView = document.getElementById('employees-list-view');
this.detailView = document.getElementById('employee-detail-view');
// Only initialize if we're on the employees page
if (!this.listView) return;
this.setupListTabs();
this.setupDetailTabs();
this.setupChevronNavigation();
this.setupBackNavigation();
this.setupHistoryNavigation();
this.restoreStateFromUrl();
}
/**
* Setup popstate listener for browser back/forward
*/
private setupHistoryNavigation(): void {
window.addEventListener('popstate', (e: PopStateEvent) => {
if (e.state?.employeeKey) {
this.showDetailViewInternal(e.state.employeeKey);
} else {
this.showListViewInternal();
}
});
}
/**
* Restore view state from URL on page load
*/
private restoreStateFromUrl(): void {
const hash = window.location.hash;
if (hash.startsWith('#employee-')) {
const employeeKey = hash.substring(1); // Remove #
this.showDetailViewInternal(employeeKey);
}
}
/**
* Setup tab switching for the list view
*/
private setupListTabs(): void {
if (!this.listView) return;
const tabs = this.listView.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.dataset.tab;
if (targetTab) {
this.switchTab(this.listView!, targetTab);
}
});
});
}
/**
* Setup tab switching for the detail view
*/
private setupDetailTabs(): void {
if (!this.detailView) return;
const tabs = this.detailView.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.dataset.tab;
if (targetTab) {
this.switchTab(this.detailView!, targetTab);
}
});
});
}
/**
* Switch to a specific tab within a container
*/
private switchTab(container: HTMLElement, targetTab: string): void {
const tabs = container.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
const contents = container.querySelectorAll<HTMLElement>('swp-tab-content[data-tab]');
tabs.forEach(t => {
t.classList.toggle('active', t.dataset.tab === targetTab);
});
contents.forEach(content => {
content.classList.toggle('active', content.dataset.tab === targetTab);
});
}
/**
* Setup row click to show detail view
* Ignores clicks on action buttons
*/
private setupChevronNavigation(): void {
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
// Ignore clicks on action buttons
if (target.closest('swp-icon-btn') || target.closest('swp-table-actions')) {
return;
}
const row = target.closest<HTMLElement>('swp-employee-row[data-employee-detail]');
if (row) {
const employeeKey = row.dataset.employeeDetail;
if (employeeKey) {
this.showDetailView(employeeKey);
}
}
});
}
/**
* Setup back button to return to list view
*/
private setupBackNavigation(): void {
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
const backLink = target.closest<HTMLElement>('[data-employee-back]');
if (backLink) {
this.showListView();
}
});
}
/**
* Show the detail view and hide list view (with history push)
*/
private showDetailView(employeeKey: string): void {
// Push state to history
history.pushState(
{ employeeKey },
'',
`#${employeeKey}`
);
this.showDetailViewInternal(employeeKey);
}
/**
* Show detail view without modifying history (for popstate)
*/
private showDetailViewInternal(employeeKey: string): void {
if (this.listView && this.detailView) {
this.listView.style.display = 'none';
this.detailView.style.display = 'block';
this.detailView.dataset.employee = employeeKey;
// Reset to first tab
this.switchTab(this.detailView, 'general');
}
}
/**
* Show the list view and hide detail view (with history push)
*/
private showListView(): void {
// Push state to history (clear hash)
history.pushState(
{},
'',
window.location.pathname
);
this.showListViewInternal();
}
/**
* Show list view without modifying history (for popstate)
*/
private showListViewInternal(): void {
if (this.listView && this.detailView) {
this.detailView.style.display = 'none';
this.listView.style.display = 'block';
}
}
}