public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 11:30:40 -05:00
commit 3b7f30d14d
469 changed files with 89039 additions and 0 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
node_modules
.git
.gitignore
.houdini
.svelte-kit
build
dist
*.log
*.md
.env*
!.env.example
.vscode
.idea

23
.env.example Normal file
View File

@ -0,0 +1,23 @@
# GraphQL API
PUBLIC_GRAPHQL_URL=http://localhost:8000/graphql/
# Ory Kratos (Authentication)
PUBLIC_KRATOS_URL=http://localhost:4433
# Calendar Service
PUBLIC_CALENDAR_API_URL=http://localhost:8001
PUBLIC_CALENDAR_API_KEY=your-calendar-api-key
# Email Service
PUBLIC_EMAIL_API_URL=http://localhost:8002
PUBLIC_EMAIL_API_KEY=your-email-api-key
# Wave API (Invoice Integration)
WAVE_ACCESS_TOKEN=your-wave-access-token
WAVE_BUSINESS_ID=your-wave-business-id
# Houdini Schema Introspection (development only)
USER_ID=your-dev-user-id
USER_PROFILE_TYPE=TeamProfileType
OATHKEEPER_SECRET=your-oathkeeper-secret
DJANGO_PROFILE_ID=your-dev-profile-id

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.houdini
/context/
.idea

16
.graphqlrc.yaml Normal file
View File

@ -0,0 +1,16 @@
projects:
default:
schema:
- ./schema.graphql
- ./.houdini/graphql/schema.graphql
documents:
- '**/*.gql'
- '**/*.graphql'
- '**/*.svelte'
- ./.houdini/graphql/documents.gql
exclude:
- '**/node_modules/**'
- 'src/lib/graphql/wave/**'
wave:
schema: ./src/lib/graphql/wave/schema.graphql
documents: src/lib/graphql/wave/**/*.gql

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

14
.prettierignore Normal file
View File

@ -0,0 +1,14 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Generated files
.houdini/
src/lib/graphql/wave/generated.ts
src/lib/graphql/wave/schema.graphql
# Miscellaneous
/static/

16
.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/routes/layout.css"
}

177
CLAUDE.md Normal file
View File

@ -0,0 +1,177 @@
# Claude Code Guidelines
## Project Overview
This is a SvelteKit 2.x application using Svelte 5 with Tailwind CSS v4.
## Code Style Rules
### SVG Attributes
When using SVG elements, use `style` attribute instead of `fill` attribute to avoid IDE obsolete attribute warnings:
```svelte
<!-- Correct -->
<svg style="fill: currentColor" ...>
<svg style="fill: none" ...>
<!-- Incorrect (triggers obsolete attribute warning) -->
<svg fill="currentColor" ...>
<svg fill="none" ...>
```
### Svelte 5 Runes
- Use `$state()` for reactive state
- Use `$derived()` for computed values
- Use `$props()` for component props
- Use `$effect()` for side effects
### Page Store
Use `$app/state` instead of `$app/stores` for the page object:
```svelte
<!-- Correct -->
import {page} from '$app/state'; const path = page.url.pathname;
<!-- Incorrect -->
import {page} from '$app/stores'; const path = $page.url.pathname;
```
### Theme Classes
Use the custom theme utility classes defined in `layout.css`:
- `text-theme`, `text-theme-secondary`, `text-theme-muted`
- `bg-theme`, `bg-theme-card`
- `border-theme`
- `shadow-theme`, `shadow-theme-lg`
### Hover/Active States
Use consistent hover/active patterns:
```
hover:bg-black/5 dark:hover:bg-white/10
active:bg-black/10 dark:active:bg-white/15
```
### Mobile Grid Layouts
For card grids that should display single-column on mobile and multi-column on larger screens, always:
1. Explicitly set `grid-cols-1` (don't rely on CSS grid's implicit single column)
2. Add `min-w-0` on grid items with flex content to allow proper shrinking
```svelte
<!-- Correct -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="card-example flex min-w-0 items-center justify-between">
<div class="min-w-0 flex-1">...</div>
</div>
</div>
<!-- Incorrect (may overflow on mobile) -->
<div class="grid gap-4 sm:grid-cols-2">
<div class="card-example flex items-center justify-between">...</div>
</div>
```
### Button Element Content
Button elements can only contain phrasing content (inline elements). Use `<span>` instead of `<div>`, `<p>`, or `<h1>`-`<h6>` inside buttons:
```svelte
<!-- Correct -->
<button>
<span class="flex items-center gap-2">
<span>Click me</span>
</span>
</button>
<!-- Incorrect (triggers HTML validation warning) -->
<button>
<div class="flex items-center gap-2">
<p>Click me</p>
</div>
</button>
```
For multi-line button content, use `<span class="block">` to create line breaks:
```svelte
<button>
<span class="flex items-center gap-2">Title</span>
<span class="text-muted block text-sm">Subtitle on new line</span>
</button>
```
### SvelteSet Reactivity
`SvelteSet` from `svelte/reactivity` is already reactive and does NOT need to be wrapped in `$state()`:
```svelte
<!-- Correct -->
import { SvelteSet } from 'svelte/reactivity';
let expandedItems = new SvelteSet<string>();
<!-- Incorrect (unnecessary wrapper) -->
let expandedItems = $state(new SvelteSet<string>());
```
### Each Block Keys
Always provide a key for `{#each}` blocks to avoid ESLint warnings:
```svelte
<!-- Correct -->
{#each items as item (item.id)}
{#each days as day (day)}
<!-- Incorrect -->
{#each items as item}
```
### Date Formatting
Use the date utilities from `$lib/utils/date` which use `date-fns` to properly handle ISO date strings without timezone offset issues:
```svelte
import {formatDate} from '$lib/utils/date';
<!-- Returns empty string for null/undefined, so add fallback -->
{formatDate(startDate) || '—'}
```
### File Organization
- Place `.svelte.ts` files (stores, shared state) in `src/lib/stores/`, not in component folders
- GraphQL mutations go in `src/lib/graphql/mutations/{entity}/`
- GraphQL queries go in `src/lib/graphql/queries/{entity}/`
### Backend Integration Notes
- The backend uses a unique constraint allowing only ONE active scope per address
- The `isConditional` field on tasks is legacy and should always be set to `false`
- GraphQL inputs use camelCase (e.g., `isActive`, `accountId`) which maps to snake_case in Django
### Task Frequency Values
The backend `TaskFrequencyChoices` enum uses **lowercase** values. Handle frequency differently depending on the operation:
**Individual mutations** (create/update task or task template) - use lowercase:
```typescript
frequency: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'triannual' | 'annual' | 'as_needed';
```
**JSON import** (`createScopeTemplateFromJson`) - uppercase is acceptable:
```json
{ "frequency": "DAILY" }
```
The backend's `build_scope_template` service normalizes uppercase to lowercase automatically.
Valid frequency values: `daily`, `weekly`, `monthly`, `quarterly`, `triannual`, `annual`, `as_needed`

64
Dockerfile Normal file
View File

@ -0,0 +1,64 @@
# Use the official Node.js runtime as base image
FROM node:22-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Make PUBLIC_ vars available at build time
ARG PUBLIC_CALENDAR_API_URL
ARG PUBLIC_CALENDAR_API_KEY
ARG PUBLIC_EMAIL_API_URL
ARG PUBLIC_EMAIL_API_KEY
ARG PUBLIC_WAVE_BUSINESS_ID
ARG WAVE_ACCESS_TOKEN
ENV PUBLIC_CALENDAR_API_URL=$PUBLIC_CALENDAR_API_URL
ENV PUBLIC_CALENDAR_API_KEY=$PUBLIC_CALENDAR_API_KEY
ENV PUBLIC_EMAIL_API_URL=$PUBLIC_EMAIL_API_URL
ENV PUBLIC_EMAIL_API_KEY=$PUBLIC_EMAIL_API_KEY
ENV PUBLIC_WAVE_BUSINESS_ID=$PUBLIC_WAVE_BUSINESS_ID
ENV WAVE_ACCESS_TOKEN=$WAVE_ACCESS_TOKEN
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:22-alpine AS runner
WORKDIR /app
# Ensure production mode in runtime
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
# Copy package files
COPY package*.json ./
# Install only production dependencies (modern flag)
RUN npm ci --omit=dev && npm cache clean --force
# Copy built application from builder stage
COPY --from=builder /app/build build/
# Create a non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S svelte -u 1001
# Change ownership of the app directory
RUN chown -R svelte:nodejs /app
USER svelte
# Expose the port your app runs on
EXPOSE 3000
# Start the application
CMD ["node", "build"]

230
README.md Normal file
View File

@ -0,0 +1,230 @@
# Nexus 5 Frontend 3 - Full Portal
Full-featured web portal for the Nexus 5 platform, combining public marketing pages, customer portal, team interface, and admin dashboard in a single unified application.
## Overview
This is the third and most complete iteration of the Nexus 5 frontend, designed as a comprehensive portal that serves all user types: public visitors, customers, team members, and administrators. It includes public-facing marketing pages alongside authenticated portals for different user roles.
## Tech Stack
- **SvelteKit 5** - Latest SvelteKit with Svelte 5 runes
- **Houdini** - Type-safe GraphQL client
- **Tailwind CSS v4** - Next-generation Tailwind with CSS variables
- **TypeScript** - Strict mode enabled
- **Node adapter** - Production deployment
- **Wave Integration** - Invoice management via Wave API
## Evolution
| Feature | Frontend 1 | Frontend 2 | Frontend 3 |
|---------|------------|------------|------------|
| **Purpose** | Admin only | Team only | All users |
| **Public Pages** | No | No | Yes |
| **Customer Portal** | No | No | Yes |
| **Team Portal** | No | Yes | Yes |
| **Admin Portal** | Yes | No | Yes |
| **Theming** | Basic dark mode | Theme toggle | Full CSS variables |
| **AI Chat** | No | No | Yes |
| **Invoice Integration** | No | No | Wave API |
## Features
### Public Pages
- **Home** - Landing page with service overview
- **Services** - Service offerings and descriptions
- **Pricing** - Pricing information
- **About** - Company information
- **Contact** - Contact form with email integration
- **Privacy/Terms** - Legal pages
### Customer Portal
- **Dashboard** - Overview of accounts and upcoming work
- **Accounts** - View service accounts and locations
- **Schedule** - Upcoming services and projects
- **History** - Completed work history
- **Invoices** - View and pay invoices
- **Profile** - Customer profile management
### Team Portal
- **Dashboard** - Today's assignments
- **Services** - Assigned services with session management
- **Projects** - Assigned projects
- **Reports** - Submit and view reports
- **Profile** - Team member profile
### Admin Portal
- **Dashboard** - Business overview
- **Customers** - Customer management
- **Accounts** - Account management
- **Services** - Service scheduling and assignment
- **Projects** - Project management
- **Scopes** - Scope template management
- **Reports** - Report management
- **Invoices** - Invoice creation and Wave integration
- **Calendar** - Event management
- **Profiles** - User profile management
- **Notifications** - Notification rule configuration
- **Event Log** - System event audit log
### Cross-cutting Features
- **AI Chat** - Claude-powered assistant for all users
- **Messages** - Inter-user messaging
- **Notifications** - Real-time notifications
- **Theme Toggle** - Light/dark mode support
## Getting Started
### Prerequisites
- Node.js 18+
- Access to Nexus 5 GraphQL API
- (Optional) Wave API credentials for invoicing
### Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Type checking
npm run check
```
### Docker Deployment
```bash
# Build and run
docker-compose up --build
# Production deployment
docker-compose -f docker-compose.prod.yml up -d
```
### Environment Variables
See `.env.example` for required configuration:
- `PUBLIC_GRAPHQL_URL` - Nexus 5 API endpoint
- `PUBLIC_KRATOS_URL` - Ory Kratos public URL
- `WAVE_ACCESS_TOKEN` - Wave API token (for invoicing)
- `WAVE_BUSINESS_ID` - Wave business ID
## Project Structure
```
src/
├── lib/
│ ├── components/
│ │ ├── admin/ # Admin-specific components
│ │ ├── chat/ # AI chat interface
│ │ ├── customer/ # Customer portal components
│ │ ├── entity/ # Shared entity components
│ │ ├── icons/ # SVG icon components
│ │ ├── layout/ # Layout components
│ │ ├── nav/ # Navigation (TopNav, AdminNav, etc.)
│ │ ├── session/ # Work session components
│ │ ├── shared/ # Shared UI components
│ │ └── team/ # Team portal components
│ ├── data/ # Static data (event types, etc.)
│ ├── graphql/
│ │ ├── mutations/ # GraphQL mutations
│ │ ├── queries/ # GraphQL queries
│ │ └── wave/ # Wave API integration
│ ├── server/ # Server-side utilities
│ ├── services/ # External service integrations
│ ├── stores/ # Svelte stores
│ └── utils/ # Utility functions
└── routes/
├── admin/ # Admin portal routes
├── customer/ # Customer portal routes
├── team/ # Team portal routes
├── messages/ # Messaging
├── notifications/ # Notifications
├── about/ # Public pages
├── contact/
├── pricing/
├── privacy/
├── services/
├── terms/
└── login/logout/ # Auth flows
```
## Key Patterns
### CSS Variables for Theming
Uses CSS custom properties for comprehensive theming:
```css
:root {
--color-primary-500: #3b82f6;
--bg-primary: 255 255 255;
--text-primary: 15 23 42;
}
.dark {
--bg-primary: 15 23 42;
--text-primary: 248 250 252;
}
```
### Role-Based Navigation
Navigation adapts based on user role:
```svelte
{#if isAdmin}
<AdminNav />
{:else if isTeamMember}
<TeamNav />
{:else if isCustomer}
<CustomerNav />
{:else}
<PublicNav />
{/if}
```
### Server-Side Data Loading
Uses SvelteKit's load functions for server-side data fetching:
```typescript
// +page.server.ts
export const load = async ({ locals }) => {
const data = await fetchData(locals.user);
return { data };
};
```
### Wave API Integration
Separate GraphQL client for Wave invoice management:
```typescript
import { waveClient } from '$lib/graphql/wave';
const invoices = await waveClient.query({
query: ListInvoices,
variables: { businessId }
});
```
## Related Repositories
- **nexus-5** - Django GraphQL API backend
- **nexus-5-auth** - Ory Kratos/Oathkeeper authentication
- **nexus-5-frontend-1** - Admin-only dashboard
- **nexus-5-frontend-2** - Team-only mobile app
- **nexus-5-emailer** - Email microservice
- **nexus-5-scheduler** - Calendar integration microservice
## License
MIT License - See LICENSE file for details.

28
codegen.wave.ts Normal file
View File

@ -0,0 +1,28 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: [
{
'https://gql.waveapps.com/graphql/public': {
headers: {
Authorization: 'Bearer ' + process.env.WAVE_ACCESS_TOKEN
}
}
}
],
documents: 'src/lib/graphql/wave/**/*.gql',
generates: {
'src/lib/graphql/wave/schema.graphql': {
plugins: ['schema-ast']
},
'src/lib/graphql/wave/generated.ts': {
plugins: ['typescript', 'typescript-operations', 'typescript-urql'],
config: {
withHooks: false,
withComponent: false
}
}
}
};
export default config;

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
PUBLIC_CALENDAR_API_URL: ${PUBLIC_CALENDAR_API_URL}
PUBLIC_CALENDAR_API_KEY: ${PUBLIC_CALENDAR_API_KEY}
PUBLIC_EMAIL_API_URL: ${PUBLIC_EMAIL_API_URL}
PUBLIC_EMAIL_API_KEY: ${PUBLIC_EMAIL_API_KEY}
PUBLIC_WAVE_BUSINESS_ID: ${PUBLIC_WAVE_BUSINESS_ID}
WAVE_ACCESS_TOKEN: ${WAVE_ACCESS_TOKEN}
image: nexus-5-frontend-3:latest
container_name: nexus-5-frontend-3
ports:
- '7000:3000'
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=3000
- HOST=0.0.0.0
restart: unless-stopped

46
eslint.config.js Normal file
View File

@ -0,0 +1,46 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
{
ignores: ['src/lib/graphql/wave/generated.ts']
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
// Disable the navigation resolve rule - we use standard href links
'svelte/no-navigation-without-resolve': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

27
houdini.config.js Normal file
View File

@ -0,0 +1,27 @@
/// <references types="houdini-svelte">
/** @type {import('houdini').ConfigFile} */
const config = {
watchSchema: {
url: 'http://10.10.10.51:5500/graphql/',
headers: {
'X-USER-ID': (env) => env.USER_ID,
'X-USER-PROFILE-TYPE': (env) => env.USER_PROFILE_TYPE,
'X-OATHKEEPER-SECRET': (env) => env.OATHKEEPER_SECRET,
'X-DJANGO-PROFILE-ID': (env) => env.DJANGO_PROFILE_ID
}
},
schemaPath: './schema.graphql',
runtimeDir: '.houdini',
defaultCachePolicy: 'NetworkOnly',
// Exclude Wave GraphQL files - they use urql with a separate schema
exclude: ['src/lib/graphql/wave/**'],
plugins: {
'houdini-svelte': {
client: './src/lib/graphql/client.ts',
forceRunesMode: true
}
}
};
export default config;

11225
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "nexus-5-frontend-3",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"generate:wave": "graphql-codegen --config codegen.wave.ts"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@graphql-codegen/cli": "^6.1.0",
"@graphql-codegen/schema-ast": "^5.0.0",
"@graphql-codegen/typescript": "^5.0.6",
"@graphql-codegen/typescript-operations": "^5.0.6",
"@graphql-codegen/typescript-urql": "^4.0.1",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24",
"date-fns": "^4.1.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0",
"houdini": "^2.0.0-next.11",
"houdini-svelte": "^3.0.0-next.13",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.2.2",
"vite-plugin-devtools-json": "^1.0.0",
"vite-plugin-mkcert": "^1.17.9"
},
"dependencies": {
"@urql/svelte": "^5.0.0",
"graphql": "^16.12.0",
"heic2any": "^0.0.4",
"urql": "^5.0.1"
}
}

4411
schema.graphql Normal file

File diff suppressed because it is too large Load Diff

9
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare global {
namespace App {
interface Locals {
cookie: string | null;
}
}
}
export {};

26
src/app.html Normal file
View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nexus</title>
<script>
(function () {
const stored = localStorage.getItem('theme-preference');
const theme =
stored === 'light' || stored === 'dark'
? stored
: stored === 'system' || !stored
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: 'light';
document.documentElement.classList.add(theme);
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

8
src/hooks.server.ts Normal file
View File

@ -0,0 +1,8 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.cookies.get('ory_kratos_session');
event.locals.cookie = cookie ? `ory_kratos_session=${cookie}` : null;
return resolve(event);
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/lib/assets/floors.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
src/lib/assets/hero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
src/lib/assets/hh-walls.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
src/lib/assets/kitchens.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -0,0 +1,72 @@
<script lang="ts">
const currentYear = new Date().getFullYear();
const footerLinks = {
services: [
{ label: 'Our Services', href: '/services' },
{ label: 'Our Standard', href: '/standard' },
{ label: 'Pricing', href: '/pricing' }
],
company: [
{ label: 'About', href: '/about' },
{ label: 'Contact', href: '/contact' },
{ label: 'Terms of Use', href: '/terms' },
{ label: 'Privacy Policy', href: '/privacy' }
]
};
</script>
<footer class="border-t border-theme bg-theme-card">
<div class="mx-auto w-full max-w-6xl px-4 py-12 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 gap-8 sm:grid-cols-3">
<!-- Brand -->
<div>
<a href="/" class="text-xl font-bold text-theme">Nexus Cleaning</a>
<p class="mt-2 text-sm text-theme-muted">
Dependable commercial cleaning services for businesses that demand excellence.
</p>
</div>
<!-- Services -->
<div>
<h3 class="text-sm font-semibold tracking-wider text-theme uppercase">Services</h3>
<ul class="mt-4 space-y-2">
{#each footerLinks.services as link (link.href)}
<li>
<a
href={link.href}
class="text-sm text-theme-secondary transition-colors hover:text-theme"
>
{link.label}
</a>
</li>
{/each}
</ul>
</div>
<!-- Company -->
<div>
<h3 class="text-sm font-semibold tracking-wider text-theme uppercase">Company</h3>
<ul class="mt-4 space-y-2">
{#each footerLinks.company as link (link.href)}
<li>
<a
href={link.href}
class="text-sm text-theme-secondary transition-colors hover:text-theme"
>
{link.label}
</a>
</li>
{/each}
</ul>
</div>
</div>
<!-- Bottom bar -->
<div class="mt-12 border-t border-theme pt-8 text-center">
<p class="text-sm text-theme-muted">
&copy; {currentYear} Corellon Digital. All rights reserved.
</p>
</div>
</div>
</footer>

View File

@ -0,0 +1,19 @@
<script lang="ts">
interface Props {
href?: string;
label?: string;
class?: string;
}
let { href = '/', label = 'Home', class: className = '' }: Props = $props();
</script>
<a
{href}
class="inline-flex items-center gap-1 text-sm text-theme-muted transition-colors hover:text-theme {className}"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{label}
</a>

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<button
type="button"
onclick={() => theme.toggle()}
class="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-theme
bg-theme-card text-theme-secondary transition-colors duration-200
hover:bg-theme-secondary hover:text-theme {className}"
aria-label={theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{#if theme.isDark}
<!-- Sun icon for dark mode (click to go light) -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style="fill: none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-5 w-5"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
{:else}
<!-- Moon icon for light mode (click to go dark) -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style="fill: none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-5 w-5"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
{/if}
</button>

View File

@ -0,0 +1,228 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import { goto, beforeNavigate } from '$app/navigation';
interface Props {
title: string;
subtitle?: string;
subtitleSnippet?: Snippet<[{ toggleMenu: () => void }]>;
showBackButton?: boolean;
backHref?: string;
invalidateOnBack?: boolean;
}
let {
title,
subtitle,
subtitleSnippet,
showBackButton = true,
backHref = '/admin',
invalidateOnBack = false
}: Props = $props();
let menuOpen = $state(false);
let isNavigatingBack = $state(false);
// Track when we're navigating to the back URL
beforeNavigate(({ to }) => {
if (to?.url.pathname === backHref) {
isNavigatingBack = true;
}
});
function handleBack(event: MouseEvent) {
if (invalidateOnBack) {
event.preventDefault();
goto(backHref, { invalidateAll: true });
}
}
function toggleMenu() {
menuOpen = !menuOpen;
}
const navItems = [
{
label: 'Customers',
href: '/admin/customers',
color: 'text-accent3-600 dark:text-accent3-400'
},
{ label: 'Accounts', href: '/admin/accounts', color: 'text-primary-600 dark:text-primary-400' },
{
label: 'Services',
href: '/admin/services',
color: 'text-secondary-600 dark:text-secondary-400'
},
{ label: 'Projects', href: '/admin/projects', color: 'text-accent-600 dark:text-accent-400' },
{ label: 'Scopes', href: '/admin/scopes', color: 'text-accent3-600 dark:text-accent3-400' },
{ label: 'Reports', href: '/admin/reports', color: 'text-accent2-600 dark:text-accent2-400' },
{ label: 'Invoices', href: '/admin/invoices', color: 'text-accent6-600 dark:text-accent6-400' },
{ label: 'Calendar', href: '/admin/calendar', color: 'text-accent7-600 dark:text-accent7-400' },
{ label: 'Profiles', href: '/admin/profiles', color: 'text-accent4-600 dark:text-accent4-400' }
];
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.nav-menu-container') && !target.closest('.menu-trigger')) {
menuOpen = false;
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
menuOpen = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="mb-6">
{#if showBackButton}
<!-- 3-column grid: back button | content | spacer for centering -->
<div class="grid grid-cols-[auto_1fr_auto] items-start gap-4">
<a
href={backHref}
onclick={handleBack}
class="rounded-lg p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Go back"
>
{#if isNavigatingBack}
<svg
class="h-5 w-5 animate-spin text-primary-600 dark:text-primary-400"
style="fill: none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{/if}
</a>
<div>
<div class="flex items-center gap-2">
<h1 class="page-title text-primary-600 dark:text-primary-400">{title}</h1>
<div class="nav-menu-container relative">
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Navigation menu"
aria-expanded={menuOpen}
>
<svg
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{#if menuOpen}
<div
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
>
{#each navItems as item (item.href)}
<a
href={item.href}
class="block interactive px-4 py-2 text-sm {item.color} {page.url.pathname ===
item.href
? 'font-semibold'
: ''}"
onclick={() => (menuOpen = false)}
>
{item.label}
</a>
{/each}
</div>
{/if}
</div>
</div>
{#if subtitleSnippet}
<p class="page-subtitle">
{@render subtitleSnippet({ toggleMenu })}
</p>
{:else if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
</div>
<!-- Spacer to balance the back button and center the content -->
<div class="w-7"></div>
</div>
{:else}
<!-- No back button - simple layout -->
<div class="flex items-center gap-2">
<h1 class="page-title text-primary-600 dark:text-primary-400">{title}</h1>
<div class="nav-menu-container relative">
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Navigation menu"
aria-expanded={menuOpen}
>
<svg
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{#if menuOpen}
<div
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
>
{#each navItems as item (item.href)}
<a
href={item.href}
class="block interactive px-4 py-2 text-sm {item.color} {page.url.pathname ===
item.href
? 'font-semibold'
: ''}"
onclick={() => (menuOpen = false)}
>
{item.label}
</a>
{/each}
</div>
{/if}
</div>
</div>
{#if subtitleSnippet}
<p class="page-subtitle">
{@render subtitleSnippet({ toggleMenu })}
</p>
{:else if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
{/if}
</div>

View File

@ -0,0 +1,282 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { navigating } from '$app/state';
import ContentContainer from '$lib/components/layout/ContentContainer.svelte';
import MonthSelector from '$lib/components/team/MonthSelector.svelte';
import StatusTabs from '$lib/components/team/StatusTabs.svelte';
interface Props {
title: string;
subtitle: string;
items: T[];
currentStatus: string;
currentMonth: string;
counts?: {
scheduled: number;
inProgress: number;
completed: number;
};
item: Snippet<[T]>;
emptyIcon?: Snippet;
emptyTitle?: string;
emptyText?: string;
colorScheme?:
| 'primary'
| 'secondary'
| 'accent'
| 'accent2'
| 'accent3'
| 'accent6'
| 'accent7';
// Admin-specific props
searchQuery?: string;
onSearchChange?: (query: string) => void;
searchPlaceholder?: string;
createButtonText?: string;
onCreateClick?: () => void;
emptyAction?: Snippet;
headerActions?: Snippet;
backHref?: string;
}
let {
title,
subtitle,
items,
currentStatus,
currentMonth,
counts,
item,
emptyIcon,
emptyTitle = 'No items found',
emptyText = 'There are no items matching your current filters.',
colorScheme = 'primary',
searchQuery = '',
onSearchChange,
searchPlaceholder = 'Search...',
createButtonText,
onCreateClick,
emptyAction,
headerActions,
backHref = '/admin'
}: Props = $props();
let hasItems = $derived(items.length > 0);
let isLoading = $derived(navigating.to !== null);
const colorClasses: Record<string, string> = {
primary: 'text-primary-600 dark:text-primary-400',
secondary: 'text-secondary-600 dark:text-secondary-400',
accent: 'text-accent-600 dark:text-accent-400',
accent2: 'text-accent2-600 dark:text-accent2-400',
accent3: 'text-accent3-600 dark:text-accent3-400',
accent6: 'text-accent6-600 dark:text-accent6-400',
accent7: 'text-accent7-600 dark:text-accent7-400'
};
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
onSearchChange?.(target.value);
}
</script>
<svelte:head>
<title>{title} - Admin - Nexus</title>
</svelte:head>
<div class="py-8">
<ContentContainer>
<!-- Header -->
<div class="mb-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex items-start gap-4">
<a
href={backHref}
class="rounded-lg p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Go back"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</a>
<div>
<h1 class="text-2xl font-bold md:text-3xl {colorClasses[colorScheme]}">{title}</h1>
<p class="mt-2 text-theme-secondary">{subtitle}</p>
</div>
</div>
<div class="hidden items-center gap-2 sm:flex">
{#if headerActions}
{@render headerActions()}
{/if}
{#if createButtonText && onCreateClick}
<button
type="button"
onclick={onCreateClick}
class="inline-flex shrink-0 items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 whitespace-nowrap text-white transition-colors hover:bg-primary-600"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
<span>{createButtonText}</span>
</button>
{/if}
</div>
</div>
</div>
<!-- Filters Row -->
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<MonthSelector value={currentMonth} />
<div class="sm:hidden">
<StatusTabs current={currentStatus} {counts} compact />
</div>
</div>
<!-- Mobile Header Actions -->
{#if headerActions}
<div class="mb-6 flex flex-wrap items-center gap-2 sm:hidden">
{@render headerActions()}
</div>
{/if}
<!-- Desktop Tabs -->
<div class="mb-6 hidden sm:block">
<StatusTabs current={currentStatus} {counts} />
</div>
<!-- Search -->
{#if onSearchChange}
<div class="mb-6">
<div class="relative">
<svg
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
value={searchQuery}
oninput={handleSearchInput}
placeholder={searchPlaceholder}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme-card py-2 pr-4 pl-10 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
{/if}
<!-- Content -->
<div role="tabpanel" id="{currentStatus}-panel" class="relative">
{#if isLoading}
<!-- Loading Overlay -->
<div
class="bg-theme/50 absolute inset-0 z-10 flex min-h-[200px] items-start justify-center pt-12 backdrop-blur-sm"
>
<div class="flex flex-col items-center gap-3">
<svg
class="h-8 w-8 animate-spin text-theme-secondary"
style="fill: none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="text-sm text-theme-secondary">Loading...</span>
</div>
</div>
{/if}
{#if hasItems}
<div class="space-y-3">
{#each items as itemData, index (index)}
{@render item(itemData)}
{/each}
</div>
{:else if !isLoading}
<!-- Empty State (only show when not loading) -->
<div class="empty-state">
{#if emptyIcon}
{@render emptyIcon()}
{:else}
<svg
class="empty-state-icon"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
{/if}
<h2 class="empty-state-title">{emptyTitle}</h2>
<p class="empty-state-text">{emptyText}</p>
{#if emptyAction}
{@render emptyAction()}
{:else if createButtonText && onCreateClick && !searchQuery}
<button
type="button"
onclick={onCreateClick}
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-white transition-colors hover:bg-primary-600"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
<span>{createButtonText}</span>
</button>
{/if}
</div>
{/if}
</div>
</ContentContainer>
</div>
<!-- Mobile FAB -->
{#if createButtonText && onCreateClick}
<button
type="button"
onclick={onCreateClick}
class="fixed right-4 bottom-20 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-primary-500 text-white shadow-lg transition-colors hover:bg-primary-600 sm:hidden"
aria-label={createButtonText}
>
<svg class="h-6 w-6" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
{/if}

View File

@ -0,0 +1,294 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import { goto, beforeNavigate } from '$app/navigation';
import { IconEdit } from '$lib/components/icons';
interface Props {
title: string;
subtitle?: string;
subtitleSnippet?: Snippet<[{ toggleMenu: () => void }]>;
colorScheme?:
| 'primary'
| 'secondary'
| 'accent'
| 'accent2'
| 'accent3'
| 'accent4'
| 'accent5'
| 'accent6'
| 'accent7';
showBackButton?: boolean;
backHref?: string;
showNavMenu?: boolean;
invalidateOnBack?: boolean;
onEdit?: () => void;
}
let {
title,
subtitle,
subtitleSnippet,
colorScheme = 'primary',
showBackButton = true,
backHref = '/admin',
showNavMenu = false,
invalidateOnBack = false,
onEdit
}: Props = $props();
function handleBack(event: MouseEvent) {
if (invalidateOnBack) {
event.preventDefault();
goto(backHref, { invalidateAll: true });
}
}
let menuOpen = $state(false);
let isNavigatingBack = $state(false);
// Track when we're navigating to the back URL
beforeNavigate(({ to }) => {
if (to?.url.pathname === backHref) {
isNavigatingBack = true;
}
});
function toggleMenu() {
menuOpen = !menuOpen;
}
const colorClasses: Record<string, string> = {
primary: 'text-primary-600 dark:text-primary-400',
secondary: 'text-secondary-600 dark:text-secondary-400',
accent: 'text-accent-600 dark:text-accent-400',
accent2: 'text-accent2-600 dark:text-accent2-400',
accent3: 'text-accent3-600 dark:text-accent3-400',
accent4: 'text-accent4-600 dark:text-accent4-400',
accent5: 'text-accent5-600 dark:text-accent5-400',
accent6: 'text-accent6-600 dark:text-accent6-400',
accent7: 'text-accent7-600 dark:text-accent7-400'
};
const navItems = [
{
label: 'Customers',
href: '/admin/customers',
color: 'text-accent3-600 dark:text-accent3-400'
},
{
label: 'Accounts',
href: '/admin/accounts',
color: 'text-primary-600 dark:text-primary-400'
},
{
label: 'Services',
href: '/admin/services',
color: 'text-secondary-600 dark:text-secondary-400'
},
{
label: 'Projects',
href: '/admin/projects',
color: 'text-accent-600 dark:text-accent-400'
},
{
label: 'Reports',
href: '/admin/reports',
color: 'text-accent2-600 dark:text-accent2-400'
},
{
label: 'Invoices',
href: '/admin/invoices',
color: 'text-accent6-600 dark:text-accent6-400'
},
{
label: 'Calendar',
href: '/admin/calendar',
color: 'text-accent7-600 dark:text-accent7-400'
},
{
label: 'Profiles',
href: '/admin/profiles',
color: 'text-accent4-600 dark:text-accent4-400'
}
];
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.nav-menu-container') && !target.closest('.menu-trigger')) {
menuOpen = false;
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
menuOpen = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="mb-6">
{#if showBackButton}
<!-- 3-column grid: back button | content | spacer for centering -->
<div class="grid grid-cols-[auto_1fr_auto] items-start gap-4">
<a
href={backHref}
onclick={handleBack}
class="rounded-lg p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Go back"
>
{#if isNavigatingBack}
<svg
class="h-5 w-5 animate-spin {colorClasses[colorScheme]}"
style="fill: none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{/if}
</a>
<div>
<div class="flex items-center gap-2">
<h1 class="page-title {colorClasses[colorScheme]}">{title}</h1>
{#if showNavMenu}
<div class="nav-menu-container relative">
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Navigation menu"
aria-expanded={menuOpen}
>
<svg
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{#if menuOpen}
<div
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
>
{#each navItems as item (item.href)}
<a
href={item.href}
class="block interactive px-4 py-2 text-sm {item.color} {page.url.pathname ===
item.href
? 'font-semibold'
: ''}"
onclick={() => (menuOpen = false)}
>
{item.label}
</a>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if subtitleSnippet}
<p class="page-subtitle">
{@render subtitleSnippet({ toggleMenu })}
</p>
{:else if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
</div>
<!-- Edit button or spacer to balance the back button -->
{#if onEdit}
<button
type="button"
onclick={onEdit}
class="rounded-lg p-2 text-theme-secondary transition-colors hover:bg-black/5 active:bg-black/10 dark:hover:bg-white/10 dark:active:bg-white/15"
aria-label="Edit"
>
<IconEdit class="h-5 w-5" />
</button>
{:else}
<div class="w-7"></div>
{/if}
</div>
{:else}
<!-- No back button - simple layout -->
<div class="flex items-center gap-2">
<h1 class="page-title {colorClasses[colorScheme]}">{title}</h1>
{#if showNavMenu}
<div class="nav-menu-container relative">
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Navigation menu"
aria-expanded={menuOpen}
>
<svg
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{#if menuOpen}
<div
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
>
{#each navItems as item (item.href)}
<a
href={item.href}
class="block interactive px-4 py-2 text-sm {item.color} {page.url.pathname ===
item.href
? 'font-semibold'
: ''}"
onclick={() => (menuOpen = false)}
>
{item.label}
</a>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if subtitleSnippet}
<p class="page-subtitle">
{@render subtitleSnippet({ toggleMenu })}
</p>
{:else if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
{/if}
</div>

View File

@ -0,0 +1,142 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
interface Props {
open: boolean;
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
loading?: boolean;
onconfirm: () => Promise<void> | void;
oncancel: () => void;
}
let {
open = $bindable(false),
title = 'Confirm Delete',
message = 'Are you sure you want to delete this item? This action cannot be undone.',
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
loading = false,
onconfirm,
oncancel
}: Props = $props();
let error = $state('');
let isSubmitting = $state(false);
async function handleConfirm() {
if (!onconfirm) return;
isSubmitting = true;
error = '';
try {
await onconfirm();
open = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to perform action';
} finally {
isSubmitting = false;
}
}
function handleCancel() {
if (isSubmitting) return;
error = '';
open = false;
oncancel?.();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open && !isSubmitting) {
handleCancel();
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && !isSubmitting) {
handleCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
transition:fade={{ duration: 150 }}
onclick={handleBackdropClick}
role="presentation"
>
<!-- Modal -->
<div
class="w-full max-w-sm rounded-xl border border-theme bg-theme p-6 shadow-theme-lg"
transition:scale={{ duration: 150, start: 0.95 }}
role="alertdialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
{#if error}
<div class="mb-4 rounded-lg border border-danger bg-danger p-3 text-sm text-danger">
{error}
</div>
{/if}
<div class="text-center">
<!-- Warning Icon -->
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-danger">
<svg
class="h-6 w-6 text-danger"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h3 id="modal-title" class="mb-2 text-lg font-semibold text-theme">
{title}
</h3>
<p id="modal-description" class="mb-6 text-sm text-theme-secondary">
{message}
</p>
<div class="flex gap-3">
<button
type="button"
onclick={handleCancel}
disabled={isSubmitting || loading}
class="flex-1 rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
{cancelLabel}
</button>
<button
type="button"
onclick={handleConfirm}
disabled={isSubmitting || loading}
class="btn-danger flex-1 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting || loading}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>Deleting...</span>
</span>
{:else}
{confirmLabel}
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,41 @@
<script lang="ts">
import { IconEdit, IconTrash } from '$lib/components/icons';
interface Props {
editLabel?: string;
deleteLabel?: string;
size?: 'sm' | 'md';
onEdit: () => void;
onDelete: () => void;
}
let {
editLabel = 'Edit',
deleteLabel = 'Delete',
size = 'md',
onEdit,
onDelete
}: Props = $props();
const buttonClass = size === 'sm' ? 'rounded p-1' : 'rounded p-1.5';
const iconClass = size === 'sm' ? 'h-3 w-3' : '';
</script>
<div class="flex items-center gap-1">
<button
type="button"
onclick={onEdit}
class="{buttonClass} text-theme-secondary hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label={editLabel}
>
<IconEdit class={iconClass} />
</button>
<button
type="button"
onclick={onDelete}
class="{buttonClass} text-theme-secondary hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label={deleteLabel}
>
<IconTrash class={iconClass} />
</button>
</div>

View File

@ -0,0 +1,30 @@
<script lang="ts">
import { IconPlus, IconEdit } from '$lib/components/icons';
interface Props {
title: string;
buttonText?: string;
buttonIcon?: 'plus' | 'edit' | 'none';
onButtonClick?: () => void;
}
let { title, buttonText, buttonIcon = 'plus', onButtonClick }: Props = $props();
</script>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-theme">{title}</h2>
{#if onButtonClick && buttonText}
<button
type="button"
onclick={onButtonClick}
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-500/10"
>
{#if buttonIcon === 'plus'}
<IconPlus />
{:else if buttonIcon === 'edit'}
<IconEdit />
{/if}
<span>{buttonText}</span>
</button>
{/if}
</div>

View File

@ -0,0 +1,218 @@
<script lang="ts">
import { CreateAccountStore, UpdateAccountStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { GetAccount$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import { toGlobalId } from '$lib/utils/relay';
type Account = NonNullable<GetAccount$result['account']>;
interface Props {
account?: Account;
customers: { id: string; name: string }[];
onSuccess?: () => void;
onCancel?: () => void;
}
let { account, customers, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!account);
let submitting = $state(false);
let error = $state('');
// Convert customerId from UUID to GlobalID to match dropdown option values
function getCustomerGlobalId(customerId: string | null | undefined): string {
if (!customerId) return '';
return toGlobalId('CustomerType', customerId);
}
let name = $state(account?.name ?? '');
let customerId = $state(getCustomerGlobalId(account?.customerId));
let startDate = $state(account?.startDate ?? '');
let endDate = $state(account?.endDate ?? '');
let status = $state(account?.status ?? 'ACTIVE');
const createStore = new CreateAccountStore();
const updateStore = new UpdateAccountStore();
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!name || !customerId || !startDate) {
error = 'Please provide name, customer, and start date';
return;
}
if (isEdit && account) {
const res = await updateStore.mutate({
input: {
id: account.id,
name,
customerId,
startDate,
endDate: endDate || null,
status
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: {
name,
customerId,
startDate,
endDate: endDate || null,
status
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save account';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Account Name <span class="text-red-500">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="Enter account name"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="customerId" class="mb-1.5 block text-sm font-medium text-theme">
Customer <span class="text-red-500">*</span>
</label>
<div class="relative w-full">
<select
id="customerId"
bind:value={customerId}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select a customer</option>
{#each customers as customer (customer.id)}
<option value={customer.id}>{customer.name}</option>
{/each}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
Start Date <span class="text-red-500">*</span>
</label>
<input
id="startDate"
type="date"
bind:value={startDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
<input
id="endDate"
type="date"
bind:value={endDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
{#if isEdit}
<div>
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
<select
id="status"
bind:value={status}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
{/if}
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Account'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,362 @@
<script lang="ts">
import ItemActions from '$lib/components/admin/ItemActions.svelte';
import LaborSection from './LaborSection.svelte';
import ScopeSection from './ScopeSection.svelte';
import { IconChevronRight, IconEdit, IconTrash } from '$lib/components/icons';
import { formatDate } from '$lib/utils/date';
import { SvelteDate, type SvelteSet } from 'svelte/reactivity';
import type { GetAccount$result, GetScopeTemplates$result } from '$houdini';
import type { ScopeType, AreaType, TaskType } from '$lib/stores/scopeEditor.svelte';
// Extract types from Houdini generated result
type AddressType = NonNullable<GetAccount$result['account']>['addresses'][number];
type LaborType = AddressType['labors'][number];
type ScheduleType = AddressType['schedules'][number];
type ScopeTemplateType = GetScopeTemplates$result['scopeTemplates'][number];
interface Props {
address: AddressType;
isExpanded: boolean;
showLaborHistory: boolean;
showScheduleHistory: boolean;
expandedScopes: SvelteSet<string>;
expandedAreas: SvelteSet<string>;
scopeTemplates: ScopeTemplateType[];
scopeError?: string;
onToggleExpanded: () => void;
onEdit: () => void;
onDelete: () => void;
onAddSchedule: () => void;
onEditSchedule: (schedule: ScheduleType) => void;
onDeleteSchedule: (scheduleId: string) => void;
onToggleScheduleHistory: () => void;
onAddLabor: () => void;
onEditLabor: (labor: LaborType) => void;
onDeleteLabor: (laborId: string) => void;
onToggleLaborHistory: () => void;
onToggleScope: (scopeId: string) => void;
onToggleArea: (areaId: string) => void;
onAddScope: () => void;
onAddScopeFromTemplate: (templateId: string) => void;
onEditScope: (scope: ScopeType) => void;
onDeleteScope: (scopeId: string) => void;
onAddArea: (scopeId: string, nextOrder: number) => void;
onEditArea: (scopeId: string, area: AreaType) => void;
onDeleteArea: (areaId: string) => void;
onAddTask: (areaId: string, nextOrder: number) => void;
onEditTask: (areaId: string, task: TaskType) => void;
onDeleteTask: (taskId: string) => void;
onGenerateServices?: (addressId: string, scheduleId: string) => void;
onClearScopeError?: () => void;
}
let {
address,
isExpanded,
showLaborHistory,
showScheduleHistory,
expandedScopes,
expandedAreas,
scopeTemplates,
scopeError = '',
onToggleExpanded,
onEdit,
onDelete,
onAddSchedule,
onEditSchedule,
onDeleteSchedule,
onToggleScheduleHistory,
onAddLabor,
onEditLabor,
onDeleteLabor,
onToggleLaborHistory,
onToggleScope,
onToggleArea,
onAddScope,
onAddScopeFromTemplate,
onEditScope,
onDeleteScope,
onAddArea,
onEditArea,
onDeleteArea,
onAddTask,
onEditTask,
onDeleteTask,
onGenerateServices,
onClearScopeError
}: Props = $props();
// Separate current and past schedules
let currentSchedules = $derived(
address.schedules?.filter((s) => {
if (!s.endDate) return true;
const endDate = new Date(s.endDate);
const today = new SvelteDate();
today.setHours(0, 0, 0, 0);
return endDate >= today;
}) ?? []
);
let pastSchedules = $derived(
address.schedules?.filter((s) => {
if (!s.endDate) return false;
const endDate = new Date(s.endDate);
const today = new SvelteDate();
today.setHours(0, 0, 0, 0);
return endDate < today;
}) ?? []
);
function getScheduleDays(schedule: ScheduleType): string[] {
const days: string[] = [];
if (schedule.mondayService) days.push('Mon');
if (schedule.tuesdayService) days.push('Tue');
if (schedule.wednesdayService) days.push('Wed');
if (schedule.thursdayService) days.push('Thu');
if (schedule.fridayService) days.push('Fri');
if (schedule.saturdayService) days.push('Sat');
if (schedule.sundayService) days.push('Sun');
return days;
}
</script>
<div
class="rounded-lg border border-theme bg-theme {address.isPrimary
? 'border-l-2 border-l-primary-500'
: ''}"
>
<!-- Address Header -->
<div class="flex items-start justify-between gap-1 p-3">
<button type="button" onclick={onToggleExpanded} class="min-w-0 flex-1 text-left">
<span class="flex items-center gap-2">
<IconChevronRight
class="h-4 w-4 flex-shrink-0 text-theme-muted transition-transform {isExpanded
? 'rotate-90'
: ''}"
/>
<span class="font-medium text-theme"
>{address.name ||
(address.isPrimary ? 'Primary Service Address' : 'Service Address')}</span
>
{#if !address.isActive}
<span class="badge-neutral">Inactive</span>
{/if}
</span>
<span class="mt-0.5 block pl-6 text-sm text-theme-secondary">
{address.streetAddress}, {address.city}, {address.state}
{address.zipCode}
</span>
</button>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={onEdit}
class="rounded p-1.5 text-gray-500 hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-300"
aria-label="Edit address"
>
<IconEdit />
</button>
<button
type="button"
onclick={onDelete}
class="rounded p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-500 dark:text-gray-400 dark:hover:bg-red-900/20"
aria-label="Delete address"
>
<IconTrash />
</button>
</div>
</div>
<!-- Address Expanded Content -->
{#if isExpanded}
<div class="border-t border-theme px-3 pb-3">
<!-- Notes -->
{#if address.notes}
<p class="mt-3 text-sm text-theme-secondary italic">{address.notes}</p>
{/if}
<!-- Schedules for this address -->
<div class="mt-3 border-t border-theme pt-3">
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-medium tracking-wide text-theme-muted uppercase">Schedules</p>
<button
type="button"
onclick={onAddSchedule}
class="text-xs font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
+ Add Schedule
</button>
</div>
{#if currentSchedules.length > 0 || pastSchedules.length > 0}
<div class="space-y-2">
<!-- Current Schedules -->
{#each currentSchedules as schedule (schedule.id)}
{@const scheduleDays = getScheduleDays(schedule)}
{@const isCurrentSchedule = !schedule.endDate}
<div class="rounded border border-theme bg-theme-card p-2 text-sm">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-theme"
>{schedule.name || 'Unnamed Schedule'}</span
>
<span class="text-xs text-theme-muted">
{#if isCurrentSchedule}
As of {formatDate(schedule.startDate) || '—'}
{:else}
{formatDate(schedule.startDate) || '—'} - {formatDate(schedule.endDate)}
{/if}
</span>
{#if isCurrentSchedule && onGenerateServices}
<button
type="button"
onclick={() => onGenerateServices(address.id, schedule.id)}
class="rounded bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-700 transition-colors hover:bg-secondary-200 dark:bg-secondary-900/40 dark:text-secondary-400 dark:hover:bg-secondary-900/60"
>
Generate
</button>
{/if}
</div>
<div class="mt-2 flex flex-wrap gap-1">
{#if schedule.weekendService}
<span
class="rounded bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-400"
>
Weekend
</span>
{:else if scheduleDays.length > 0}
{#each scheduleDays as day (day)}
<span
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
>
{day}
</span>
{/each}
{:else}
<span class="text-xs text-theme-muted">No days selected</span>
{/if}
</div>
{#if schedule.scheduleException}
<p class="mt-1 text-xs text-theme-muted italic">
{schedule.scheduleException}
</p>
{/if}
</div>
<div class="shrink-0">
<ItemActions
size="sm"
editLabel="Edit schedule"
deleteLabel="Delete schedule"
onEdit={() => onEditSchedule(schedule)}
onDelete={() => onDeleteSchedule(schedule.id)}
/>
</div>
</div>
</div>
{/each}
<!-- Past Schedules (collapsed by default) -->
{#if pastSchedules.length > 0}
<div class="mt-2">
<button
type="button"
onclick={onToggleScheduleHistory}
class="text-xs font-medium text-theme-muted hover:text-theme-secondary"
>
{showScheduleHistory ? 'Hide' : 'Show'} past schedules ({pastSchedules.length})
</button>
{#if showScheduleHistory}
<div class="mt-2 space-y-2">
{#each pastSchedules as schedule (schedule.id)}
{@const scheduleDays = getScheduleDays(schedule)}
<div class="rounded border border-theme bg-theme-card p-2 text-sm opacity-60">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-theme"
>{schedule.name || 'Unnamed Schedule'}</span
>
<span class="text-xs text-theme-muted">
{formatDate(schedule.startDate) || '—'} - {formatDate(
schedule.endDate
)}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-1">
{#if schedule.weekendService}
<span
class="rounded bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-400"
>
Weekend
</span>
{:else if scheduleDays.length > 0}
{#each scheduleDays as day (day)}
<span
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
>
{day}
</span>
{/each}
{:else}
<span class="text-xs text-theme-muted">No days selected</span>
{/if}
</div>
</div>
<div class="shrink-0">
<ItemActions
size="sm"
editLabel="Edit schedule"
deleteLabel="Delete schedule"
onEdit={() => onEditSchedule(schedule)}
onDelete={() => onDeleteSchedule(schedule.id)}
/>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{:else}
<p class="text-sm text-theme-muted">No schedules configured</p>
{/if}
</div>
<!-- Labor for this address -->
<LaborSection
labors={address.labors}
showHistory={showLaborHistory}
onAdd={onAddLabor}
onEdit={onEditLabor}
onDelete={onDeleteLabor}
onToggleHistory={onToggleLaborHistory}
/>
<!-- Scopes for this address -->
<ScopeSection
scopes={address.scopes ?? []}
{scopeTemplates}
{expandedScopes}
{expandedAreas}
{scopeError}
{onToggleScope}
{onToggleArea}
{onAddScope}
{onAddScopeFromTemplate}
{onEditScope}
{onDeleteScope}
{onAddArea}
{onEditArea}
{onDeleteArea}
{onAddTask}
{onEditTask}
{onDeleteTask}
onClearError={onClearScopeError}
/>
</div>
{/if}
</div>

View File

@ -0,0 +1,128 @@
<script lang="ts">
import { createArea, updateArea } from '$lib/utils/scopes';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { GetAccount$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Area = NonNullable<
GetAccount$result['account']
>['addresses'][number]['scopes'][number]['areas'][number];
interface Props {
scopeId: string;
area?: Area;
nextOrder?: number;
onSuccess?: () => void;
onCancel?: () => void;
}
let { scopeId, area, nextOrder = 0, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!area);
let submitting = $state(false);
let error = $state('');
let name = $state(area?.name ?? '');
let order = $state(area?.order ?? nextOrder);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!name.trim()) {
error = 'Please provide an area name';
return;
}
if (isEdit && area) {
await updateArea({
id: area.id,
name: name.trim(),
order
});
} else {
await createArea({
scopeId,
name: name.trim(),
order
});
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save area';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Area Name <span class="text-red-500">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="e.g., Kitchen, Lobby, Restrooms"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="order" class="mb-1.5 block text-sm font-medium text-theme"> Display Order </label>
<input
id="order"
type="number"
min="0"
bind:value={order}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="mt-1 text-xs text-theme-muted">Lower numbers appear first</p>
</div>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Add Area'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,73 @@
<script lang="ts">
import { IconEdit, IconTrash } from '$lib/components/icons';
interface Contact {
fullName: string;
email: string | null;
phone: string | null;
isPrimary: boolean;
isActive: boolean;
}
interface Props {
contact: Contact;
onEdit: () => void;
onDelete: () => void;
}
let { contact, onEdit, onDelete }: Props = $props();
</script>
<div class="rounded-lg border border-theme bg-theme p-3">
{#if contact.isPrimary || !contact.isActive}
<div class="mb-1.5 flex flex-wrap gap-1.5 sm:hidden">
{#if contact.isPrimary}
<span class="badge-primary">Primary</span>
{/if}
{#if !contact.isActive}
<span class="badge-neutral">Inactive</span>
{/if}
</div>
{/if}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-theme">{contact.fullName}</span>
{#if contact.isPrimary}
<span class="hidden badge-primary sm:inline-flex">Primary</span>
{/if}
{#if !contact.isActive}
<span class="hidden badge-neutral sm:inline-flex">Inactive</span>
{/if}
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={onEdit}
class="rounded p-1.5 text-gray-500 hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-300"
aria-label="Edit contact"
>
<IconEdit />
</button>
<button
type="button"
onclick={onDelete}
class="rounded p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-500 dark:text-gray-400 dark:hover:bg-red-900/20"
aria-label="Delete contact"
>
<IconTrash />
</button>
</div>
</div>
{#if contact.email || contact.phone}
<div class="mt-2 flex flex-col gap-1 sm:flex-row sm:flex-wrap sm:gap-x-4">
{#if contact.email}
<p class="text-sm text-theme-secondary">{contact.email}</p>
{/if}
{#if contact.phone}
<p class="text-sm text-theme-muted">{contact.phone}</p>
{/if}
</div>
{/if}
</div>

View File

@ -0,0 +1,171 @@
<script lang="ts">
import { CreateLaborStore, UpdateLaborStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { GetAccount$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Labor = NonNullable<GetAccount$result['account']>['addresses'][number]['labors'][number];
interface Props {
accountAddressId: string;
labor?: Labor;
onSuccess?: () => void;
onCancel?: () => void;
}
let { accountAddressId, labor, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!labor);
let submitting = $state(false);
let error = $state('');
let amount = $state(labor?.amount?.toString() ?? '');
let startDate = $state(labor?.startDate ?? '');
let endDate = $state(labor?.endDate ?? '');
const createStore = new CreateLaborStore();
const updateStore = new UpdateLaborStore();
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount < 0) {
error = 'Please provide a valid amount';
return;
}
if (!startDate) {
error = 'Please provide a start date';
return;
}
if (isEdit && labor) {
const res = await updateStore.mutate({
input: {
id: labor.id,
amount: parsedAmount,
startDate,
endDate: endDate || null
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: {
accountAddressId,
amount: parsedAmount,
startDate,
endDate: endDate || null
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save labor';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="amount" class="mb-1.5 block text-sm font-medium text-theme">
Labor Rate (per visit) <span class="text-red-500">*</span>
</label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
<input
id="amount"
type="number"
step="0.01"
min="0"
bind:value={amount}
disabled={submitting}
placeholder="0.00"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
Start Date <span class="text-red-500">*</span>
</label>
<input
id="startDate"
type="date"
bind:value={startDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
<input
id="endDate"
type="date"
bind:value={endDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<p class="text-xs text-theme-muted">
Leave end date empty to mark this as the current labor rate.
</p>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Add Labor'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,128 @@
<script lang="ts">
import ItemActions from '$lib/components/admin/ItemActions.svelte';
import { IconEdit, IconTrash } from '$lib/components/icons';
import { formatDate } from '$lib/utils/date';
interface Labor {
id: string;
amount: number | null;
startDate: string | null;
endDate: string | null;
}
interface Props {
labors: Labor[];
showHistory: boolean;
onAdd: () => void;
onEdit: (labor: Labor) => void;
onDelete: (laborId: string) => void;
onToggleHistory: () => void;
}
let { labors, showHistory, onAdd, onEdit, onDelete, onToggleHistory }: Props = $props();
let currentLabor = $derived(labors.find((l) => !l.endDate));
let historicalLabors = $derived(labors.filter((l) => l.endDate));
function formatCurrency(amount: number | null | undefined): string {
if (amount === null || amount === undefined) return '$0.00';
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
</script>
<div class="mt-3 border-t border-theme pt-3">
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-medium tracking-wide text-theme-muted uppercase">Labor</p>
<button
type="button"
onclick={onAdd}
class="text-xs font-medium text-primary-500 hover:text-primary-600"
>
+ Add
</button>
</div>
{#if currentLabor}
<div
class="rounded border-2 border-blue-200 bg-blue-50 p-2 text-sm dark:border-blue-800 dark:bg-blue-900/20"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-bold text-blue-700 dark:text-blue-400">
{formatCurrency(currentLabor.amount)}
</span>
<span
class="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-400"
>
Current
</span>
</div>
<div class="flex items-center gap-1">
<button
type="button"
onclick={() => onEdit(currentLabor)}
class="rounded p-1 text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/40"
aria-label="Edit labor"
>
<IconEdit class="h-3.5 w-3.5" />
</button>
<button
type="button"
onclick={() => onDelete(currentLabor.id)}
class="rounded p-1 text-blue-600 hover:bg-red-50 hover:text-red-500 dark:text-blue-400 dark:hover:bg-red-900/20"
aria-label="Delete labor"
>
<IconTrash class="h-3.5 w-3.5" />
</button>
</div>
</div>
<p class="mt-1 text-xs text-blue-600 dark:text-blue-500">
As of {formatDate(currentLabor.startDate) || '—'}
</p>
</div>
{:else}
<p class="text-xs text-theme-muted">No current labor rate set.</p>
{/if}
{#if historicalLabors.length > 0}
<button
type="button"
onclick={onToggleHistory}
class="mt-2 flex w-full items-center justify-between text-xs text-theme-secondary hover:text-theme"
>
<span>History ({historicalLabors.length})</span>
<svg
class="h-3 w-3 transition-transform {showHistory ? 'rotate-180' : ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showHistory}
<div class="mt-2 space-y-1">
{#each historicalLabors as labor (labor.id)}
<div
class="flex items-center justify-between rounded border border-theme bg-theme-card p-2 text-xs"
>
<div>
<span class="font-medium text-theme">{formatCurrency(labor.amount)}</span>
<span class="text-theme-muted">
({formatDate(labor.startDate) || '—'} - {formatDate(labor.endDate) || 'Present'})
</span>
</div>
<ItemActions
size="sm"
editLabel="Edit labor"
deleteLabel="Delete labor"
onEdit={() => onEdit(labor)}
onDelete={() => onDelete(labor.id)}
/>
</div>
{/each}
</div>
{/if}
{/if}
</div>

View File

@ -0,0 +1,216 @@
<script lang="ts">
import { CreateRevenueStore, UpdateRevenueStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { GetAccount$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import { fromGlobalId } from '$lib/utils/relay';
type Revenue = NonNullable<GetAccount$result['account']>['revenues'][number];
interface WaveProduct {
id: string;
name: string;
description: string | null;
unitPrice: number | string;
isArchived: boolean;
}
interface Props {
accountId: string;
revenue?: Revenue;
waveProducts?: WaveProduct[];
onSuccess?: () => void;
onCancel?: () => void;
}
let { accountId, revenue, waveProducts = [], onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!revenue);
let submitting = $state(false);
let error = $state('');
let amount = $state(revenue?.amount?.toString() ?? '');
let startDate = $state(revenue?.startDate ?? '');
let endDate = $state(revenue?.endDate ?? '');
let waveServiceId = $state(revenue?.waveServiceId ?? '');
// Find currently linked product for display
let linkedProduct = $derived(
waveServiceId ? waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) : null
);
const createStore = new CreateRevenueStore();
const updateStore = new UpdateRevenueStore();
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount < 0) {
error = 'Please provide a valid amount';
return;
}
if (!startDate) {
error = 'Please provide a start date';
return;
}
if (isEdit && revenue) {
const res = await updateStore.mutate({
input: {
id: revenue.id,
amount: parsedAmount,
startDate,
endDate: endDate || null,
waveServiceId: waveServiceId || ''
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: {
accountId,
amount: parsedAmount,
startDate,
endDate: endDate || null,
waveServiceId: waveServiceId || ''
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save revenue';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="amount" class="mb-1.5 block text-sm font-medium text-theme">
Monthly Revenue <span class="text-red-500">*</span>
</label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
<input
id="amount"
type="number"
step="0.01"
min="0"
bind:value={amount}
disabled={submitting}
placeholder="0.00"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
Start Date <span class="text-red-500">*</span>
</label>
<input
id="startDate"
type="date"
bind:value={startDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
<input
id="endDate"
type="date"
bind:value={endDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<p class="text-xs text-theme-muted">
Leave end date empty to mark this as the current revenue rate.
</p>
{#if waveProducts.length > 0}
<div class="border-t border-theme pt-4">
<label for="waveProduct" class="mb-1.5 block text-sm font-medium text-theme">
Wave Product
</label>
<select
id="waveProduct"
bind:value={waveServiceId}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">No linked product</option>
{#each waveProducts as product (product.id)}
{@const decodedId = fromGlobalId(product.id)}
<option value={decodedId}>
{product.name} (${Number(product.unitPrice).toFixed(2)})
</option>
{/each}
</select>
{#if linkedProduct}
<p class="mt-1 text-xs text-theme-muted">
Currently linked to: {linkedProduct.name}
</p>
{/if}
</div>
{/if}
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Add Revenue'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,170 @@
<script lang="ts">
import SectionHeader from '$lib/components/admin/SectionHeader.svelte';
import ItemActions from '$lib/components/admin/ItemActions.svelte';
import { IconEdit, IconTrash } from '$lib/components/icons';
import { formatDate } from '$lib/utils/date';
import { fromGlobalId } from '$lib/utils/relay';
interface Revenue {
id: string;
amount: number | null;
startDate: string | null;
endDate: string | null;
waveServiceId: string | null;
}
interface WaveProduct {
id: string;
name: string;
description: string | null;
unitPrice: number;
isArchived: boolean;
}
interface Props {
revenues: Revenue[];
waveProducts?: WaveProduct[];
onAdd: () => void;
onEdit: (revenue: Revenue) => void;
onDelete: (revenueId: string) => void;
}
let { revenues, waveProducts = [], onAdd, onEdit, onDelete }: Props = $props();
let currentRevenue = $derived(revenues.find((r) => !r.endDate));
let historicalRevenues = $derived(revenues.filter((r) => r.endDate));
let showHistoricalRevenues = $state(false);
function formatCurrency(amount: number | null | undefined): string {
if (amount === null || amount === undefined) return '$0.00';
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
function getLinkedProduct(waveServiceId: string | null | undefined): WaveProduct | null {
if (!waveServiceId) return null;
return waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) ?? null;
}
</script>
<div class="card-padded">
<SectionHeader title="Revenue" buttonText="Add" onButtonClick={onAdd} />
{#if currentRevenue}
{@const linkedProduct = getLinkedProduct(currentRevenue.waveServiceId)}
<!-- Current revenue shown prominently -->
<div
class="mb-4 rounded-lg border-2 border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20"
>
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-2xl font-bold text-green-700 dark:text-green-400">
{formatCurrency(currentRevenue.amount)}
</span>
<span class="badge-success">Current</span>
{#if currentRevenue.waveServiceId}
<span class="badge-primary">Linked</span>
{/if}
</div>
<p class="mt-1 text-sm text-green-600 dark:text-green-500">
As of {formatDate(currentRevenue.startDate) || '—'}
</p>
{#if linkedProduct}
<p class="mt-1 text-sm text-green-600 dark:text-green-500">
Wave: {linkedProduct.name}
</p>
<p class="mt-1 font-mono text-xs text-green-500 dark:text-green-600">
{currentRevenue.waveServiceId}
</p>
{:else if currentRevenue.waveServiceId}
<p class="mt-1 font-mono text-xs text-green-500 dark:text-green-600">
{currentRevenue.waveServiceId}
</p>
{/if}
</div>
<div class="flex items-center gap-1">
<button
type="button"
onclick={() => onEdit(currentRevenue)}
class="rounded p-1.5 text-green-600 hover:bg-green-100 dark:text-green-400 dark:hover:bg-green-900/40"
aria-label="Edit revenue"
>
<IconEdit />
</button>
<button
type="button"
onclick={() => onDelete(currentRevenue.id)}
class="rounded p-1.5 text-green-600 hover:bg-red-50 hover:text-red-500 dark:text-green-400 dark:hover:bg-red-900/20"
aria-label="Delete revenue"
>
<IconTrash />
</button>
</div>
</div>
</div>
{:else}
<p class="mb-4 text-center text-theme-muted">No current revenue rate set.</p>
{/if}
<!-- Historical revenues (collapsed by default) -->
{#if historicalRevenues.length > 0}
<div class="border-t border-theme pt-4">
<button
type="button"
onclick={() => (showHistoricalRevenues = !showHistoricalRevenues)}
class="flex w-full items-center justify-between text-sm font-medium text-theme-secondary hover:text-theme"
>
<span>Historical Revenue ({historicalRevenues.length})</span>
<svg
class="h-4 w-4 transition-transform {showHistoricalRevenues ? 'rotate-180' : ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if showHistoricalRevenues}
<div class="mt-3 space-y-2">
{#each historicalRevenues as revenue (revenue.id)}
{@const linkedProduct = getLinkedProduct(revenue.waveServiceId)}
<div
class="flex items-center justify-between rounded-lg border border-theme bg-theme p-3"
>
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-theme">{formatCurrency(revenue.amount)}</span>
{#if revenue.waveServiceId}
<span class="badge-primary text-xs">Linked</span>
{/if}
</div>
<p class="text-xs text-theme-muted">
{formatDate(revenue.startDate) || '—'} - {formatDate(revenue.endDate) ||
'Present'}
</p>
{#if linkedProduct}
<p class="text-xs text-theme-muted">Wave: {linkedProduct.name}</p>
<p class="font-mono text-xs text-theme-muted">{revenue.waveServiceId}</p>
{:else if revenue.waveServiceId}
<p class="font-mono text-xs text-theme-muted">{revenue.waveServiceId}</p>
{/if}
</div>
<ItemActions
editLabel="Edit revenue"
deleteLabel="Delete revenue"
onEdit={() => onEdit(revenue)}
onDelete={() => onDelete(revenue.id)}
/>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View File

@ -0,0 +1,347 @@
<script lang="ts">
import { CreateScheduleStore, UpdateScheduleStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { GetAccount$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Schedule = NonNullable<
GetAccount$result['account']
>['addresses'][number]['schedules'][number];
interface Props {
accountAddressId: string;
schedule?: Schedule;
onSuccess?: () => void;
onCancel?: () => void;
}
let { accountAddressId, schedule, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!schedule);
let submitting = $state(false);
let error = $state('');
// Form fields
let name = $state(schedule?.name ?? '');
let startDate = $state(schedule?.startDate ?? '');
let endDate = $state(schedule?.endDate ?? '');
let mondayService = $state(schedule?.mondayService ?? false);
let tuesdayService = $state(schedule?.tuesdayService ?? false);
let wednesdayService = $state(schedule?.wednesdayService ?? false);
let thursdayService = $state(schedule?.thursdayService ?? false);
let fridayService = $state(schedule?.fridayService ?? false);
let saturdayService = $state(schedule?.saturdayService ?? false);
let sundayService = $state(schedule?.sundayService ?? false);
let weekendService = $state(schedule?.weekendService ?? false);
let scheduleException = $state(schedule?.scheduleException ?? '');
const createStore = new CreateScheduleStore();
const updateStore = new UpdateScheduleStore();
// When weekend service is toggled on, clear Fri/Sat/Sun
function handleWeekendServiceChange(checked: boolean) {
weekendService = checked;
if (checked) {
fridayService = false;
saturdayService = false;
sundayService = false;
}
}
// When any of Fri/Sat/Sun is toggled on, clear weekend service
function handleWeekendDayChange(day: 'friday' | 'saturday' | 'sunday', checked: boolean) {
if (day === 'friday') fridayService = checked;
if (day === 'saturday') saturdayService = checked;
if (day === 'sunday') sundayService = checked;
if (checked) {
weekendService = false;
}
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!startDate) {
error = 'Please provide a start date';
return;
}
const hasAnyService =
mondayService ||
tuesdayService ||
wednesdayService ||
thursdayService ||
fridayService ||
saturdayService ||
sundayService ||
weekendService;
if (!hasAnyService) {
error = 'Please select at least one service day or weekend service';
return;
}
if (isEdit && schedule) {
const res = await updateStore.mutate({
input: {
id: schedule.id,
accountAddressId,
name: name || null,
startDate,
endDate: endDate || null,
mondayService,
tuesdayService,
wednesdayService,
thursdayService,
fridayService,
saturdayService,
sundayService,
weekendService,
scheduleException: scheduleException || null
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: {
accountAddressId,
name: name || null,
startDate,
endDate: endDate || null,
mondayService,
tuesdayService,
wednesdayService,
thursdayService,
fridayService,
saturdayService,
sundayService,
weekendService,
scheduleException: scheduleException || null
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save schedule';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
const dayCheckboxClass =
'h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50';
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<!-- Name -->
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme"> Schedule Name </label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="e.g., Summer Schedule"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
Start Date <span class="text-red-500">*</span>
</label>
<input
id="startDate"
type="date"
bind:value={startDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
<input
id="endDate"
type="date"
bind:value={endDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<p class="text-xs text-theme-muted">
Leave end date empty to mark this as the current active schedule.
</p>
<!-- Service Days -->
<div>
<p class="mb-2 text-sm font-medium text-theme">
Service Days <span class="text-red-500">*</span>
</p>
<!-- Weekday checkboxes -->
<div class="grid grid-cols-4 gap-3">
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={mondayService}
disabled={submitting}
class={dayCheckboxClass}
/>
<span class="text-sm text-theme">Mon</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={tuesdayService}
disabled={submitting}
class={dayCheckboxClass}
/>
<span class="text-sm text-theme">Tue</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={wednesdayService}
disabled={submitting}
class={dayCheckboxClass}
/>
<span class="text-sm text-theme">Wed</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={thursdayService}
disabled={submitting}
class={dayCheckboxClass}
/>
<span class="text-sm text-theme">Thu</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={fridayService}
onchange={(e) => handleWeekendDayChange('friday', e.currentTarget.checked)}
disabled={submitting || weekendService}
class={dayCheckboxClass}
/>
<span class="text-sm text-theme {weekendService ? 'opacity-50' : ''}">Fri</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={saturdayService}
onchange={(e) => handleWeekendDayChange('saturday', e.currentTarget.checked)}
disabled={submitting || weekendService}
class={dayCheckboxClass}
/>
<span class="text-sm text-theme {weekendService ? 'opacity-50' : ''}">Sat</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={sundayService}
onchange={(e) => handleWeekendDayChange('sunday', e.currentTarget.checked)}
disabled={submitting || weekendService}
class={dayCheckboxClass}
/>
<span class="text-sm text-theme {weekendService ? 'opacity-50' : ''}">Sun</span>
</label>
</div>
<!-- Weekend Service toggle -->
<div class="mt-3 border-t border-theme pt-3">
<label class="flex items-start gap-2">
<input
type="checkbox"
checked={weekendService}
onchange={(e) => handleWeekendServiceChange(e.currentTarget.checked)}
disabled={submitting}
class="mt-0.5 {dayCheckboxClass}"
/>
<span class="flex flex-col">
<span class="text-sm font-medium text-theme">Weekend Service</span>
<span class="text-xs text-theme-muted">
Single service visit on Friday that can be performed anytime Friday-Sunday
</span>
</span>
</label>
</div>
</div>
<!-- Schedule Exception -->
<div>
<label for="scheduleException" class="mb-1.5 block text-sm font-medium text-theme">
Schedule Notes / Exceptions
</label>
<textarea
id="scheduleException"
bind:value={scheduleException}
disabled={submitting}
rows={3}
placeholder="Any special requirements or exceptions..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Add Schedule'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,164 @@
<script lang="ts">
import { createScope, updateScope } from '$lib/utils/scopes';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { GetAccount$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Scope = NonNullable<GetAccount$result['account']>['addresses'][number]['scopes'][number];
interface Props {
accountId: string;
accountAddressId: string;
scope?: Scope;
existingScopes?: Scope[];
onSuccess?: () => void;
onCancel?: () => void;
}
let {
accountId,
accountAddressId,
scope,
existingScopes = [],
onSuccess,
onCancel
}: Props = $props();
let isEdit = $derived(!!scope);
let submitting = $state(false);
let error = $state('');
let name = $state(scope?.name ?? '');
let description = $state(scope?.description ?? '');
// Check if there's already an active scope (other than the one being edited)
let existingActiveScope = $derived(existingScopes.find((s) => s.isActive && s.id !== scope?.id));
let hasExistingActiveScope = $derived(!!existingActiveScope);
// Default to active only if there's no existing active scope
let isActive = $state(scope?.isActive ?? !existingScopes.some((s) => s.isActive));
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!name.trim()) {
error = 'Please provide a scope name';
return;
}
if (isEdit && scope) {
await updateScope({
id: scope.id,
name: name.trim(),
description: description.trim(),
isActive
});
} else {
await createScope({
accountId,
accountAddressId,
name: name.trim(),
description: description.trim(),
isActive
});
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save scope';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Scope Name <span class="text-red-500">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="e.g., Standard Cleaning"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
Description
</label>
<textarea
id="description"
bind:value={description}
disabled={submitting}
rows="3"
placeholder="Optional description of this scope"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<div>
<div class="flex items-center gap-2">
<input
id="isActive"
type="checkbox"
bind:checked={isActive}
disabled={submitting || (hasExistingActiveScope && !scope?.isActive)}
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<label for="isActive" class="text-sm text-theme">Active scope</label>
</div>
{#if hasExistingActiveScope && !scope?.isActive}
<p class="mt-1.5 text-xs text-warning">
"{existingActiveScope?.name}" is currently the active scope. Deactivate it first to make
this scope active.
</p>
{/if}
</div>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Scope'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,700 @@
<script lang="ts">
import type { ScopeType, AreaType, TaskType } from '$lib/stores/scopeEditor.svelte';
import { getFrequencyShortLabel } from '$lib/stores/scopeEditor.svelte';
import type { GetScopeTemplates$result } from '$houdini';
type ScopeTemplateType = GetScopeTemplates$result['scopeTemplates'][number];
interface Props {
scopes: ScopeType[];
scopeTemplates?: ScopeTemplateType[];
expandedScopes: Set<string>;
expandedAreas: Set<string>;
scopeError?: string;
onToggleScope: (scopeId: string) => void;
onToggleArea: (areaId: string) => void;
onAddScope: () => void;
onAddScopeFromTemplate?: (templateId: string) => void;
onEditScope: (scope: ScopeType) => void;
onDeleteScope: (scopeId: string) => void;
onAddArea: (scopeId: string, nextOrder: number) => void;
onEditArea: (scopeId: string, area: AreaType) => void;
onDeleteArea: (areaId: string) => void;
onAddTask: (areaId: string, nextOrder: number) => void;
onEditTask: (areaId: string, task: TaskType) => void;
onDeleteTask: (taskId: string) => void;
onClearError?: () => void;
}
let {
scopes,
scopeTemplates = [],
expandedScopes,
expandedAreas,
scopeError = '',
onToggleScope,
onToggleArea,
onAddScope,
onAddScopeFromTemplate,
onEditScope,
onDeleteScope,
onAddArea,
onEditArea,
onDeleteArea,
onAddTask,
onEditTask,
onDeleteTask,
onClearError
}: Props = $props();
let showTemplateDropdown = $state(false);
// Separate active and inactive scopes
let activeScopes = $derived(scopes.filter((s) => s.isActive));
let inactiveScopes = $derived(scopes.filter((s) => !s.isActive));
// Toggle for showing scope history
let showScopeHistory = $state(false);
function getTotalTasks(scope: ScopeType): number {
return scope.areas?.reduce((acc, area) => acc + (area.tasks?.length ?? 0), 0) ?? 0;
}
function sortedAreas(areas: AreaType[]): AreaType[] {
return [...areas].sort((a, b) => a.order - b.order);
}
function sortedTasks(tasks: TaskType[]): TaskType[] {
return [...tasks].sort((a, b) => a.order - b.order);
}
</script>
<div class="mt-3 border-t border-theme pt-3">
<!-- Scope Error Alert -->
{#if scopeError}
<div class="mb-2 flex items-start gap-2 rounded-lg border border-amber-400 bg-amber-50 p-2 text-xs text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-300">
<svg class="mt-0.5 h-4 w-4 shrink-0" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="flex-1">{scopeError}</span>
{#if onClearError}
<button
type="button"
onclick={onClearError}
class="shrink-0 font-medium underline hover:no-underline"
>
Dismiss
</button>
{/if}
</div>
{/if}
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-medium tracking-wide text-theme-muted uppercase">Scopes</p>
<div class="relative">
{#if scopeTemplates.length > 0 && onAddScopeFromTemplate}
<button
type="button"
onclick={() => (showTemplateDropdown = !showTemplateDropdown)}
class="flex items-center gap-1 text-xs font-medium text-secondary-500 hover:text-secondary-600"
>
+ Add Scope
<svg class="h-3 w-3" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if showTemplateDropdown}
<!-- Backdrop to close dropdown -->
<button
type="button"
class="fixed inset-0 z-10"
onclick={() => (showTemplateDropdown = false)}
aria-label="Close dropdown"
></button>
<!-- Dropdown menu -->
<div
class="absolute right-0 z-20 mt-1 w-48 rounded-lg border border-theme bg-theme-card py-1 shadow-theme-lg"
>
<button
type="button"
onclick={() => {
showTemplateDropdown = false;
onAddScope();
}}
class="block w-full interactive px-3 py-1.5 text-left text-xs text-theme"
>
Blank Scope
</button>
<div class="my-1 border-t border-theme"></div>
<p class="px-3 py-1 text-[10px] font-medium text-theme-muted uppercase">
From Template
</p>
{#each scopeTemplates as template (template.id)}
<button
type="button"
onclick={() => {
showTemplateDropdown = false;
onAddScopeFromTemplate(template.id);
}}
class="block w-full interactive px-3 py-1.5 text-left text-xs text-theme"
>
{template.name}
</button>
{/each}
</div>
{/if}
{:else}
<button
type="button"
onclick={onAddScope}
class="text-xs font-medium text-secondary-500 hover:text-secondary-600"
>
+ Add Scope
</button>
{/if}
</div>
</div>
{#if activeScopes.length > 0 || inactiveScopes.length > 0}
<div class="space-y-2">
<!-- Active Scopes (always visible) -->
{#each activeScopes as scope (scope.id)}
{@const isExpanded = expandedScopes.has(scope.id)}
{@const totalAreas = scope.areas?.length ?? 0}
{@const totalTasks = getTotalTasks(scope)}
<div
class="rounded-lg border border-theme bg-theme-card {scope.isActive
? 'border-l-2 border-l-secondary-500'
: ''}"
>
<!-- Scope Header -->
<div class="flex items-start justify-between gap-1 p-2">
<button
type="button"
onclick={() => onToggleScope(scope.id)}
class="min-w-0 flex-1 text-left"
>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4 flex-shrink-0 text-theme-muted transition-transform {isExpanded
? 'rotate-90'
: ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span class="font-medium text-theme">{scope.name}</span>
{#if scope.isActive}
<span
class="rounded bg-secondary-100 px-1.5 py-0.5 text-xs font-medium text-secondary-700 dark:bg-secondary-900/40 dark:text-secondary-400"
>
Active
</span>
{/if}
</span>
<span class="mt-0.5 block pl-6 text-xs text-theme-muted">
{totalAreas}
{totalAreas === 1 ? 'area' : 'areas'}, {totalTasks}
{totalTasks === 1 ? 'task' : 'tasks'}
</span>
</button>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={() => onEditScope(scope)}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Edit scope"
>
<svg
class="h-3.5 w-3.5"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={() => onDeleteScope(scope.id)}
class="rounded p-1 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label="Delete scope"
>
<svg
class="h-3.5 w-3.5"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<!-- Expanded Content -->
{#if isExpanded}
<div class="border-t border-theme px-2 pb-2">
{#if scope.description}
<p class="mt-2 text-xs text-theme-muted italic">{scope.description}</p>
{/if}
<!-- Areas -->
<div class="mt-2">
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-medium text-theme-secondary">Areas</span>
<button
type="button"
onclick={() => onAddArea(scope.id, totalAreas)}
class="text-xs text-secondary-500 hover:text-secondary-600"
>
+ Add
</button>
</div>
{#if scope.areas && scope.areas.length > 0}
<div class="space-y-1">
{#each sortedAreas(scope.areas) as area (area.id)}
{@const taskCount = area.tasks?.length ?? 0}
{@const isAreaExpanded = expandedAreas.has(area.id)}
<div
class="rounded border border-l-2 border-theme border-l-secondary-400 bg-theme"
>
<!-- Area Header -->
<div class="flex items-start justify-between gap-1 p-2">
<button
type="button"
onclick={() => onToggleArea(area.id)}
class="min-w-0 flex-1 text-left"
>
<span class="flex items-center gap-2">
<svg
class="h-3 w-3 flex-shrink-0 text-theme-muted transition-transform {isAreaExpanded
? 'rotate-90'
: ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span class="text-sm font-medium text-theme">{area.name}</span>
</span>
<span class="mt-0.5 block pl-5 text-xs text-theme-muted">
{taskCount}
{taskCount === 1 ? 'task' : 'tasks'}
</span>
</button>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={() => onAddTask(area.id, taskCount)}
class="rounded p-1 text-secondary-500 hover:bg-secondary-500/10"
aria-label="Add task"
title="Add task"
>
<svg
class="h-3 w-3"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<button
type="button"
onclick={() => onEditArea(scope.id, area)}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Edit area"
>
<svg
class="h-3 w-3"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={() => onDeleteArea(area.id)}
class="rounded p-1 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label="Delete area"
>
<svg
class="h-3 w-3"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<!-- Tasks (expanded content) -->
{#if isAreaExpanded}
<div class="border-t border-theme px-2 pb-2">
{#if area.tasks && area.tasks.length > 0}
<div class="mt-2 space-y-2">
{#each sortedTasks(area.tasks) as task (task.id)}
<div class="rounded bg-gray-50 p-2 text-xs dark:bg-gray-800/50">
<!-- Task actions -->
<div class="mb-2 flex items-center justify-end gap-0.5">
<button
type="button"
onclick={() => onEditTask(area.id, task)}
class="rounded p-0.5 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Edit task"
>
<svg
class="h-3 w-3"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={() => onDeleteTask(task.id)}
class="rounded p-0.5 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label="Delete task"
>
<svg
class="h-3 w-3"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<!-- Task fields stacked vertically -->
<div class="space-y-2">
<div>
<span
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
>
Scope Description
<button
type="button"
class="group relative"
aria-label="What is scope description?"
>
<svg
class="h-3 w-3 text-theme-muted hover:text-theme"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-[10px] text-white normal-case group-hover:block dark:bg-gray-700"
>
The customer-facing description of the task
</span>
</button>
</span>
<p class="text-theme">{task.description}</p>
</div>
<div>
<span
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
>
Checklist Description
<button
type="button"
class="group relative"
aria-label="What is checklist description?"
>
<svg
class="h-3 w-3 text-theme-muted hover:text-theme"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-[10px] text-white normal-case group-hover:block dark:bg-gray-700"
>
What team members see during a session
</span>
</button>
</span>
<p class="text-theme">{task.checklistDescription || '—'}</p>
</div>
<div>
<span
class="text-[10px] font-medium text-theme-muted uppercase"
>Frequency</span
>
<p class="text-theme">
{getFrequencyShortLabel(task.frequency)}
</p>
</div>
</div>
</div>
{/each}
</div>
{:else}
<p class="mt-2 text-center text-xs text-theme-muted">No tasks yet</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-center text-xs text-theme-muted">No areas yet</p>
{/if}
</div>
</div>
{/if}
</div>
{/each}
<!-- Inactive Scopes (collapsed by default) -->
{#if inactiveScopes.length > 0}
<div class="mt-2">
<button
type="button"
onclick={() => (showScopeHistory = !showScopeHistory)}
class="text-xs font-medium text-theme-muted hover:text-theme-secondary"
>
{showScopeHistory ? 'Hide' : 'Show'} scope history ({inactiveScopes.length})
</button>
{#if showScopeHistory}
<div class="mt-2 space-y-2">
{#each inactiveScopes as scope (scope.id)}
{@const isExpanded = expandedScopes.has(scope.id)}
{@const totalAreas = scope.areas?.length ?? 0}
{@const totalTasks = getTotalTasks(scope)}
<div class="rounded-lg border border-theme bg-theme-card opacity-60">
<!-- Scope Header -->
<div class="flex items-start justify-between gap-1 p-2">
<button
type="button"
onclick={() => onToggleScope(scope.id)}
class="min-w-0 flex-1 text-left"
>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4 flex-shrink-0 text-theme-muted transition-transform {isExpanded
? 'rotate-90'
: ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span class="font-medium text-theme">{scope.name}</span>
<span class="badge-neutral">Inactive</span>
</span>
<span class="mt-0.5 block pl-6 text-xs text-theme-muted">
{totalAreas}
{totalAreas === 1 ? 'area' : 'areas'}, {totalTasks}
{totalTasks === 1 ? 'task' : 'tasks'}
</span>
</button>
</div>
<!-- Expanded Content (read-only for inactive scopes) -->
{#if isExpanded}
<div class="border-t border-theme px-2 pb-2">
{#if scope.description}
<p class="mt-2 text-xs text-theme-muted italic">{scope.description}</p>
{/if}
<!-- Areas (read-only) -->
<div class="mt-2">
<span class="mb-1 text-xs font-medium text-theme-secondary">Areas</span>
{#if scope.areas && scope.areas.length > 0}
<div class="space-y-1">
{#each sortedAreas(scope.areas) as area (area.id)}
{@const taskCount = area.tasks?.length ?? 0}
{@const isAreaExpanded = expandedAreas.has(area.id)}
<div
class="rounded border border-l-2 border-theme border-l-secondary-400 bg-theme"
>
<!-- Area Header -->
<div class="flex items-start justify-between gap-1 p-2">
<button
type="button"
onclick={() => onToggleArea(area.id)}
class="min-w-0 flex-1 text-left"
>
<span class="flex items-center gap-2">
<svg
class="h-3 w-3 flex-shrink-0 text-theme-muted transition-transform {isAreaExpanded
? 'rotate-90'
: ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span class="text-sm font-medium text-theme">{area.name}</span
>
</span>
<span class="mt-0.5 block pl-5 text-xs text-theme-muted">
{taskCount}
{taskCount === 1 ? 'task' : 'tasks'}
</span>
</button>
</div>
<!-- Tasks (expanded content, read-only) -->
{#if isAreaExpanded}
<div class="border-t border-theme px-2 pb-2">
{#if area.tasks && area.tasks.length > 0}
<div class="mt-2 space-y-2">
{#each sortedTasks(area.tasks) as task (task.id)}
<div
class="rounded bg-gray-50 p-2 text-xs dark:bg-gray-800/50"
>
<!-- Task fields stacked vertically (no actions) -->
<div class="space-y-2">
<div>
<span
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
>
Scope Description
</span>
<p class="text-theme">{task.description}</p>
</div>
<div>
<span
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
>
Checklist Description
</span>
<p class="text-theme">
{task.checklistDescription || '—'}
</p>
</div>
<div>
<span
class="text-[10px] font-medium text-theme-muted uppercase"
>Frequency</span
>
<p class="text-theme">
{getFrequencyShortLabel(task.frequency)}
</p>
</div>
</div>
</div>
{/each}
</div>
{:else}
<p class="mt-2 text-center text-xs text-theme-muted">
No tasks
</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-center text-xs text-theme-muted">No areas</p>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{:else}
<p class="text-xs text-theme-muted">No scopes defined for this address.</p>
{/if}
</div>

View File

@ -0,0 +1,205 @@
<script lang="ts">
import { createTask, updateTask } from '$lib/utils/scopes';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { GetAccount$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Task = NonNullable<
GetAccount$result['account']
>['addresses'][number]['scopes'][number]['areas'][number]['tasks'][number];
const FREQUENCY_OPTIONS = [
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'triannual', label: 'Triannual (3x/year)' },
{ value: 'annual', label: 'Annual' },
{ value: 'as_needed', label: 'As Needed' }
];
const VALID_FREQUENCIES = new Set(FREQUENCY_OPTIONS.map((o) => o.value));
// Sanitize frequency to ensure it's a valid enum value (lowercase)
function sanitizeFrequency(freq: string | null | undefined): string {
if (!freq) return 'weekly';
const lower = freq.toLowerCase();
if (VALID_FREQUENCIES.has(lower)) {
return lower;
}
return 'weekly'; // Default to weekly for invalid/legacy values
}
interface Props {
areaId: string;
task?: Task;
nextOrder?: number;
onSuccess?: () => void;
onCancel?: () => void;
}
let { areaId, task, nextOrder = 0, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!task);
let submitting = $state(false);
let error = $state('');
let description = $state(task?.description ?? '');
let checklistDescription = $state(task?.checklistDescription ?? '');
let frequency = $state(sanitizeFrequency(task?.frequency));
let order = $state(task?.order ?? nextOrder);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!description.trim()) {
error = 'Please provide a task description';
return;
}
if (isEdit && task) {
await updateTask({
id: task.id,
description: description.trim(),
checklistDescription: checklistDescription.trim() || null,
frequency,
isConditional: false,
order
});
} else {
await createTask({
areaId,
description: description.trim(),
checklistDescription: checklistDescription.trim() || null,
frequency,
isConditional: false,
order
});
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save task';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
Task Description <span class="text-red-500">*</span>
</label>
<textarea
id="description"
bind:value={description}
disabled={submitting}
rows="2"
placeholder="e.g., Empty trash cans and replace liners"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<div>
<label for="checklistDescription" class="mb-1.5 block text-sm font-medium text-theme">
Checklist Note
</label>
<input
id="checklistDescription"
type="text"
bind:value={checklistDescription}
disabled={submitting}
placeholder="Optional short note for checklist"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="mt-1 text-xs text-theme-muted">Shortened version shown on service checklists</p>
</div>
<div>
<label for="frequency" class="mb-1.5 block text-sm font-medium text-theme">
Frequency <span class="text-red-500">*</span>
</label>
<div class="relative w-full">
<select
id="frequency"
bind:value={frequency}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each FREQUENCY_OPTIONS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
<div>
<label for="order" class="mb-1.5 block text-sm font-medium text-theme"> Display Order </label>
<input
id="order"
type="number"
min="0"
bind:value={order}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Add Task'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,575 @@
<script lang="ts">
import {
calendarService,
type CalendarEvent,
type Attendee,
type CreateEventInput,
type EventReminder,
type EventReminders,
EVENT_COLORS
} from '$lib/services/calendar';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
interface TeamProfile {
id: string;
fullName: string;
email?: string | null;
role: string;
status: string;
}
interface InitialValues {
summary?: string;
location?: string;
date?: string;
}
interface Props {
teamProfiles: TeamProfile[];
event?: CalendarEvent | null;
initialValues?: InitialValues;
onSuccess?: (eventId: string) => void;
onCancel?: () => void;
}
let { teamProfiles, event = null, initialValues, onSuccess, onCancel }: Props = $props();
let submitting = $state(false);
let error = $state('');
// Form fields - prioritize event (edit mode), then initialValues (pre-population), then defaults
let summary = $state(event?.summary ?? initialValues?.summary ?? '');
let description = $state(event?.description ?? '');
let location = $state(event?.location ?? initialValues?.location ?? '');
let isAllDay = $state(event ? !event.start.dateTime && !!event.start.date : false);
// After midnight toggle - for when work starts after midnight but belongs to previous business day
let isAfterMidnight = $state(false);
// Helper to increment a date string by 1 day
function incrementDate(dateStr: string): string {
const date = new SvelteDate(dateStr + 'T12:00:00'); // noon to avoid DST issues
date.setDate(date.getDate() + 1);
return date.toISOString().split('T')[0];
}
// Parse existing event dates/times
function parseDateTime(eventDateTime: { dateTime?: string; date?: string } | undefined): {
date: string;
time: string;
} {
if (!eventDateTime) {
const now = new Date();
return {
date: now.toISOString().split('T')[0],
time: '09:00'
};
}
if (eventDateTime.dateTime) {
const dt = new Date(eventDateTime.dateTime);
return {
date: dt.toISOString().split('T')[0],
time: dt.toTimeString().slice(0, 5)
};
}
if (eventDateTime.date) {
return {
date: eventDateTime.date,
time: '09:00'
};
}
const now = new Date();
return {
date: now.toISOString().split('T')[0],
time: '09:00'
};
}
const startParsed = parseDateTime(event?.start);
const endParsed = parseDateTime(event?.end);
// Use initialValues for date if no event is provided
let startDate = $state(event ? startParsed.date : (initialValues?.date ?? startParsed.date));
let startTime = $state(startParsed.time);
let endDate = $state(event ? endParsed.date : (initialValues?.date ?? endParsed.date));
let endTime = $state(endParsed.time);
// Actual calendar dates (incremented by 1 day if after midnight is checked)
let actualStartDate = $derived(
isAfterMidnight && !isAllDay ? incrementDate(startDate) : startDate
);
let actualEndDate = $derived(isAfterMidnight && !isAllDay ? incrementDate(endDate) : endDate);
// Selected attendees (set of email addresses)
let selectedAttendeeEmails = new SvelteSet<string>(event?.attendees?.map((a) => a.email) ?? []);
// Reminder settings
let useDefaultReminders = $state(event?.reminders?.useDefault ?? true);
let customReminders = $state<EventReminder[]>(event?.reminders?.overrides ?? []);
// Event color
let selectedColorId = $state<string | undefined>(event?.colorId);
// Active team members with emails (ensure email is non-null)
let availableAttendees = $derived(
teamProfiles.filter(
(p): p is typeof p & { email: string } => p.status === 'ACTIVE' && !!p.email
)
);
function toggleAttendee(email: string) {
if (selectedAttendeeEmails.has(email)) {
selectedAttendeeEmails.delete(email);
} else {
selectedAttendeeEmails.add(email);
}
}
function isAttendeeSelected(email: string): boolean {
return selectedAttendeeEmails.has(email);
}
// Reminder helper functions
const reminderPresets = [
{ label: '10 minutes before', minutes: 10 },
{ label: '30 minutes before', minutes: 30 },
{ label: '1 hour before', minutes: 60 },
{ label: '1 day before', minutes: 1440 },
{ label: '1 week before', minutes: 10080 }
];
function addReminder() {
customReminders = [...customReminders, { method: 'popup', minutes: 30 }];
}
function removeReminder(index: number) {
customReminders = customReminders.filter((_, i) => i !== index);
}
function handleReminderMethodChange(index: number, e: Event) {
const method = (e.currentTarget as HTMLSelectElement).value as 'email' | 'popup';
customReminders = customReminders.map((r, i) => (i === index ? { ...r, method } : r));
}
function handleReminderMinutesChange(index: number, e: Event) {
const minutes = parseInt((e.currentTarget as HTMLSelectElement).value);
customReminders = customReminders.map((r, i) => (i === index ? { ...r, minutes } : r));
}
// Convert local date/time to UTC ISO string for the API
function toUtcIsoString(date: string, time: string): string {
// Parse as local time (browser's timezone - US/Eastern for our team)
const localDateTime = new Date(`${date}T${time}:00`);
// Convert to UTC ISO string (includes Z suffix)
return localDateTime.toISOString();
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!summary.trim()) {
error = 'Please enter an event title';
return;
}
// Build attendees from selected emails
const attendees: Attendee[] = Array.from(selectedAttendeeEmails).map((email) => {
const profile = teamProfiles.find((p) => p.email === email);
return {
email,
displayName: profile?.fullName
};
});
// Build reminders
const reminders: EventReminders | undefined = useDefaultReminders
? { useDefault: true }
: customReminders.length > 0
? { useDefault: false, overrides: customReminders }
: undefined;
// Build event input - use actualStartDate/actualEndDate for timed events (handles after-midnight shift)
// For timed events, convert to UTC ISO string so the scheduler API can parse it
const input: CreateEventInput = {
summary: summary.trim(),
description: description.trim() || undefined,
location: location.trim() || undefined,
start: isAllDay
? { date: startDate }
: { dateTime: toUtcIsoString(actualStartDate, startTime) },
end: isAllDay
? { date: endDate }
: { dateTime: toUtcIsoString(actualEndDate, endTime) },
attendees: attendees.length > 0 ? attendees : undefined,
reminders,
colorId: selectedColorId
};
let eventId: string;
if (event) {
// Update existing event
const result = await calendarService.updateEvent(event.id, input);
if (result.error) {
error = result.error.message;
return;
}
eventId = event.id;
} else {
// Create new event
const result = await calendarService.createEvent(input);
if (result.error) {
error = result.error.message;
return;
}
eventId = result.data!.id;
}
offCanvas.closeRight();
onSuccess?.(eventId);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save event';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<form onsubmit={handleSubmit} class="flex h-full flex-col">
<div class="flex-1 space-y-6 overflow-y-auto p-6">
<!-- Event Title -->
<div>
<label for="summary" class="mb-1.5 block text-sm font-medium text-theme">Event Title</label>
<input
type="text"
id="summary"
bind:value={summary}
placeholder="e.g., Team Meeting"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
required
/>
</div>
<!-- All Day Toggle -->
<div class="flex items-center gap-3">
<input
type="checkbox"
id="allDay"
bind:checked={isAllDay}
class="h-4 w-4 rounded border-theme text-accent7-500 focus:ring-accent7-500"
/>
<label for="allDay" class="text-sm font-medium text-theme">All-day event</label>
</div>
<!-- After Midnight Toggle (only for timed events) -->
{#if !isAllDay}
<div
class="flex items-center gap-3 rounded-lg border border-amber-400/50 bg-amber-50 p-3 dark:border-amber-500/30 dark:bg-amber-900/20"
>
<input
type="checkbox"
id="afterMidnight"
bind:checked={isAfterMidnight}
class="h-4 w-4 rounded border-amber-400 text-amber-500 focus:ring-amber-500"
/>
<label for="afterMidnight" class="flex-1">
<span class="block text-sm font-medium text-amber-800 dark:text-amber-200">
Work begins after midnight
</span>
{#if isAfterMidnight}
<span class="block text-xs text-amber-600 dark:text-amber-400">
Calendar event will be scheduled for {actualStartDate} (next calendar day)
</span>
{:else}
<span class="block text-xs text-amber-600 dark:text-amber-400">
Check if work starts after midnight but belongs to the previous business day
</span>
{/if}
</label>
</div>
{/if}
<!-- Date/Time Fields -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Start -->
<div>
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">Start</label>
<input
type="date"
id="startDate"
bind:value={startDate}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
required
/>
{#if !isAllDay}
<input
type="time"
id="startTime"
bind:value={startTime}
class="mt-2 w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
required
/>
{/if}
</div>
<!-- End -->
<div>
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme">End</label>
<input
type="date"
id="endDate"
bind:value={endDate}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
required
/>
{#if !isAllDay}
<input
type="time"
id="endTime"
bind:value={endTime}
class="mt-2 w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
required
/>
{/if}
</div>
</div>
<!-- Location -->
<div>
<label for="location" class="mb-1.5 block text-sm font-medium text-theme"
>Location (optional)</label
>
<input
type="text"
id="location"
bind:value={location}
placeholder="e.g., Conference Room A"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-1.5 block text-sm font-medium text-theme"
>Description (optional)</label
>
<textarea
id="description"
bind:value={description}
rows="3"
placeholder="Add event details..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
></textarea>
</div>
<!-- Event Color -->
<div>
<span class="mb-2 block text-sm font-medium text-theme">Color (optional)</span>
<div class="flex flex-wrap gap-2">
<!-- No color option -->
<button
type="button"
onclick={() => (selectedColorId = undefined)}
class="flex h-8 w-8 items-center justify-center rounded-full border-2 transition-all
{selectedColorId === undefined
? 'border-accent7-500 ring-2 ring-accent7-500/30'
: 'border-theme hover:border-theme-secondary'}"
aria-label="No color"
title="No color"
>
<svg class="h-4 w-4 text-theme-muted" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{#each EVENT_COLORS as color (color.id)}
<button
type="button"
onclick={() => (selectedColorId = color.id)}
class="h-8 w-8 rounded-full border-2 transition-all {color.bg}
{selectedColorId === color.id
? 'border-accent7-500 ring-2 ring-accent7-500/30'
: 'border-transparent hover:scale-110'}"
aria-label={color.name}
title={color.name}
></button>
{/each}
</div>
</div>
<!-- Attendees -->
<div>
<span class="mb-2 block text-sm font-medium text-theme">Attendees</span>
{#if availableAttendees.length > 0}
<div
class="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-theme bg-theme-secondary p-3"
>
{#each availableAttendees as profile (profile.id)}
{@const selected = isAttendeeSelected(profile.email)}
<button
type="button"
onclick={() => toggleAttendee(profile.email)}
class="flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors {selected
? 'bg-accent7-500/20 text-accent7-700 dark:text-accent7-300'
: 'hover:bg-black/5 dark:hover:bg-white/10'}"
>
<span
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full {selected
? 'bg-accent7-500 text-white'
: 'border border-theme bg-theme'}"
>
{#if selected}
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-theme">{profile.fullName}</span
>
<span class="block truncate text-xs text-theme-muted">{profile.email}</span>
</span>
</button>
{/each}
</div>
{#if selectedAttendeeEmails.size > 0}
<p class="mt-2 text-xs text-theme-muted">
{selectedAttendeeEmails.size} attendee{selectedAttendeeEmails.size !== 1 ? 's' : ''} selected
</p>
{/if}
{:else}
<p class="text-sm text-theme-muted italic">
No team members with email addresses available.
</p>
{/if}
</div>
<!-- Reminders -->
<div>
<span class="mb-2 block text-sm font-medium text-theme">Reminders</span>
<!-- Use Default Toggle -->
<div class="mb-3 flex items-center gap-3">
<input
type="checkbox"
id="useDefaultReminders"
bind:checked={useDefaultReminders}
class="h-4 w-4 rounded border-theme text-accent7-500 focus:ring-accent7-500"
/>
<label for="useDefaultReminders" class="text-sm text-theme">
Use default reminders (30 minutes before via popup)
</label>
</div>
<!-- Custom Reminders -->
{#if !useDefaultReminders}
<div class="space-y-3 rounded-lg border border-theme bg-theme-secondary p-3">
{#if customReminders.length === 0}
<p class="text-sm text-theme-muted italic">No reminders set. Add one below.</p>
{:else}
{#each customReminders as reminder, index (index)}
<div class="flex items-center gap-2">
<!-- Reminder Method -->
<select
onchange={(e) => handleReminderMethodChange(index, e)}
class="rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
>
<option value="popup" selected={reminder.method === 'popup'}>Popup</option>
<option value="email" selected={reminder.method === 'email'}>Email</option>
</select>
<!-- Reminder Time -->
<select
onchange={(e) => handleReminderMinutesChange(index, e)}
class="flex-1 rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
>
{#each reminderPresets as preset (preset.minutes)}
<option value={preset.minutes} selected={reminder.minutes === preset.minutes}
>{preset.label}</option
>
{/each}
</select>
<!-- Remove Button -->
<button
type="button"
onclick={() => removeReminder(index)}
aria-label="Remove reminder"
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg text-theme-muted transition-colors hover:bg-error-500/10 hover:text-error-500"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/each}
{/if}
<!-- Add Reminder Button -->
<button
type="button"
onclick={addReminder}
class="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-theme py-2 text-sm text-theme-muted transition-colors hover:border-accent7-500 hover:text-accent7-500"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Reminder
</button>
</div>
{/if}
</div>
{#if error}
<div class="rounded-lg bg-error-500/10 p-4">
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 border-t border-theme bg-theme p-6">
<button type="button" onclick={handleCancel} class="btn-ghost" disabled={submitting}>
Cancel
</button>
<button
type="submit"
class="inline-flex items-center gap-2 rounded-lg bg-accent7-500 px-4 py-2 font-medium text-white transition-colors hover:bg-accent7-600 disabled:opacity-50"
disabled={submitting || !summary.trim()}
>
{#if submitting}
{event ? 'Updating...' : 'Creating...'}
{:else}
{event ? 'Update Event' : 'Create Event'}
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,145 @@
<script lang="ts">
import { type CalendarEvent, getEventColor } from '$lib/services/calendar';
import {SvelteDate} from "svelte/reactivity";
interface Props {
events: CalendarEvent[];
weekStart: Date;
onEventClick?: (event: CalendarEvent) => void;
}
let { events, weekStart, onEventClick }: Props = $props();
// Generate 7 days starting from weekStart (store as timestamps to avoid mutable Date in derived)
let dayTimestamps = $derived.by(() => {
return Array.from({ length: 7 }, (_, i) => {
const date = new SvelteDate(weekStart);
date.setDate(date.getDate() + i);
return date.getTime();
});
});
// Convert timestamps to Date objects for display
function getDateFromTimestamp(ts: number): Date {
return new Date(ts);
}
// Format date for grouping (YYYY-MM-DD)
function formatDateKey(date: Date): string {
return date.toISOString().split('T')[0];
}
// Get event date key
function getEventDateKey(event: CalendarEvent): string {
if (event.start.date) {
return event.start.date;
}
if (event.start.dateTime) {
return event.start.dateTime.split('T')[0];
}
return '';
}
// Format time for display
function formatEventTime(event: CalendarEvent): string {
if (event.start.date) {
return 'All day';
}
if (event.start.dateTime) {
const start = new Date(event.start.dateTime);
return start.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
return '';
}
// Group events by day (use plain object instead of Map for reactivity)
let eventsByDay = $derived.by(() => {
const grouped: Record<string, CalendarEvent[]> = {};
// Initialize each day with empty array
for (const ts of dayTimestamps) {
grouped[formatDateKey(getDateFromTimestamp(ts))] = [];
}
// Add events to their respective days
for (const event of events) {
const dateKey = getEventDateKey(event);
if (grouped[dateKey]) {
grouped[dateKey].push(event);
}
}
// Sort events within each day by time
for (const dayEvents of Object.values(grouped)) {
dayEvents.sort((a, b) => {
const aTime = a.start.dateTime || a.start.date || '';
const bTime = b.start.dateTime || b.start.date || '';
return aTime.localeCompare(bTime);
});
}
return grouped;
});
// Check if a date is today
function isToday(date: Date): boolean {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
}
</script>
<div class="grid grid-cols-7 gap-2">
{#each dayTimestamps as ts (ts)}
{@const day = getDateFromTimestamp(ts)}
{@const dateKey = formatDateKey(day)}
{@const dayEvents = eventsByDay[dateKey] ?? []}
{@const today = isToday(day)}
<div
class="min-h-[200px] rounded-lg border p-2
{today ? 'border-accent7-500 bg-accent7-500/5' : 'border-theme bg-theme-card'}"
>
<!-- Day Header -->
<div class="mb-2 text-center">
<span class="block text-xs font-medium uppercase text-theme-muted">
{day.toLocaleDateString('en-US', { weekday: 'short' })}
</span>
<span
class="block text-lg font-semibold
{today ? 'text-accent7-600 dark:text-accent7-400' : 'text-theme'}"
>
{day.getDate()}
</span>
</div>
<!-- Events -->
<div class="space-y-1">
{#each dayEvents as event (event.id)}
{@const eventColor = getEventColor(event.colorId)}
<button
type="button"
onclick={() => onEventClick?.(event)}
class="w-full rounded px-2 py-1 text-left text-xs transition-colors
{eventColor
? `${eventColor.bg} ${eventColor.text}`
: 'bg-accent7-500/20 text-accent7-700 dark:text-accent7-300'}
hover:opacity-80"
>
<span class="block truncate font-medium">{event.summary}</span>
<span class="block truncate opacity-75">{formatEventTime(event)}</span>
</button>
{/each}
{#if dayEvents.length === 0}
<div class="py-4 text-center text-xs text-theme-muted">No events</div>
{/if}
</div>
</div>
{/each}
</div>

View File

@ -0,0 +1,206 @@
<script lang="ts">
import { CreateCustomerStore, UpdateCustomerStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { GetCustomer$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Customer = NonNullable<GetCustomer$result['customer']>;
interface Props {
customer?: Customer;
onSuccess?: () => void;
onCancel?: () => void;
}
let { customer, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!customer);
let submitting = $state(false);
let error = $state('');
let name = $state(customer?.name ?? '');
let billingEmail = $state(customer?.billingEmail ?? '');
let billingTerms = $state(customer?.billingTerms ?? '');
let startDate = $state(customer?.startDate ?? '');
let endDate = $state(customer?.endDate ?? '');
let status = $state(customer?.status ?? 'ACTIVE');
const createStore = new CreateCustomerStore();
const updateStore = new UpdateCustomerStore();
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!name || !billingEmail || !startDate) {
error = 'Please provide name, billing email, and start date';
return;
}
if (isEdit && customer) {
const res = await updateStore.mutate({
input: {
id: customer.id,
name,
billingEmail,
billingTerms,
startDate,
endDate: endDate || null,
status
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: {
name,
billingEmail,
billingTerms,
startDate,
status
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save customer';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Customer Name <span class="text-red-500">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="Enter customer name"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="billingEmail" class="mb-1.5 block text-sm font-medium text-theme">
Billing Email <span class="text-red-500">*</span>
</label>
<input
id="billingEmail"
type="email"
bind:value={billingEmail}
disabled={submitting}
placeholder="Enter billing email"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="billingTerms" class="mb-1.5 block text-sm font-medium text-theme">
Billing Terms
</label>
<input
id="billingTerms"
type="text"
bind:value={billingTerms}
disabled={submitting}
placeholder="Enter billing terms (optional)"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
Start Date <span class="text-red-500">*</span>
</label>
<input
id="startDate"
type="date"
bind:value={startDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
<input
id="endDate"
type="date"
bind:value={endDate}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
{#if isEdit}
<div>
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
<select
id="status"
bind:value={status}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
{/if}
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Customer'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,210 @@
<script lang="ts">
import { UpdateCustomerStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import { fromGlobalId } from '$lib/utils/relay';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
interface WaveCustomer {
id: string;
name: string;
email: string | null;
isArchived: boolean;
}
interface Props {
customerId: string;
currentWaveCustomerId?: string | null;
waveCustomers: WaveCustomer[];
onSuccess?: () => void;
}
let { customerId, currentWaveCustomerId, waveCustomers, onSuccess }: Props = $props();
let submitting = $state(false);
let error = $state('');
let searchQuery = $state('');
// Store the decoded Wave customer ID (matches what backend stores)
let selectedCustomerId = $derived<string | null>(null);
// Sync selectedCustomerId when currentWaveCustomerId prop changes (e.g., when form reopens)
// currentWaveCustomerId from backend is already decoded
$effect(() => {
selectedCustomerId = currentWaveCustomerId ?? null;
});
// Decode Wave ID to match backend storage format
function decodeWaveId(id: string): string {
return fromGlobalId(id);
}
// Filter customers by search query
let filteredCustomers = $derived.by(() => {
const active = waveCustomers.filter((c) => !c.isArchived);
if (!searchQuery.trim()) return active;
const query = searchQuery.toLowerCase();
return active.filter((c) => {
const name = c.name.toLowerCase();
const email = c.email?.toLowerCase() ?? '';
return name.includes(query) || email.includes(query);
});
});
const updateStore = new UpdateCustomerStore();
async function handleSave() {
submitting = true;
error = '';
try {
const res = await updateStore.mutate({
input: {
id: customerId,
waveCustomerId: selectedCustomerId ?? ''
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
}
</script>
<div class="flex h-full flex-col">
{#if error}
<div class="mb-4 alert-error">
{error}
</div>
{/if}
<!-- Search -->
<div class="mb-4">
<div class="relative">
<svg
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Search Wave customers..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-4 pl-10 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<!-- Customer List -->
<div class="mb-4 min-h-0 flex-1 overflow-y-auto">
{#if filteredCustomers.length === 0 && !searchQuery}
<div class="py-8 text-center text-theme-muted">No Wave customers found.</div>
{:else if filteredCustomers.length === 0}
<div class="py-8 text-center text-theme-muted">No customers match your search.</div>
{:else}
<div class="space-y-2">
<!-- Option to unlink -->
<button
type="button"
onclick={() => (selectedCustomerId = null)}
class="w-full rounded-lg border p-3 text-left transition-colors {selectedCustomerId ===
null
? 'border-primary-500 bg-primary-500/10'
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
>
<span class="flex items-center gap-3">
<span
class="flex h-5 w-5 items-center justify-center rounded-full border-2 {selectedCustomerId ===
null
? 'border-primary-500'
: 'border-theme-muted'}"
>
{#if selectedCustomerId === null}
<div class="h-2.5 w-2.5 rounded-full bg-primary-500"></div>
{/if}
</span>
<span class="text-theme-muted italic">No Wave customer (unlink)</span>
</span>
</button>
{#each filteredCustomers as customer (customer.id)}
{@const decodedId = decodeWaveId(customer.id)}
<button
type="button"
onclick={() => (selectedCustomerId = decodedId)}
class="w-full rounded-lg border p-3 text-left transition-colors {selectedCustomerId ===
decodedId
? 'border-primary-500 bg-primary-500/10'
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
>
<span class="flex items-start gap-3">
<span
class="mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 {selectedCustomerId ===
decodedId
? 'border-primary-500'
: 'border-theme-muted'}"
>
{#if selectedCustomerId === decodedId}
<div class="h-2.5 w-2.5 rounded-full bg-primary-500"></div>
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="font-medium text-theme">{customer.name}</span>
{#if customer.email}
<p class="truncate text-sm text-theme-secondary">{customer.email}</p>
{/if}
<span class="truncate font-mono text-xs text-theme-muted">{decodedId}</span>
</span>
</span>
</button>
{/each}
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex items-center gap-3 border-t border-theme pt-4">
<button
type="button"
onclick={handleSave}
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>Saving...</span>
</span>
{:else}
Save
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</div>

View File

@ -0,0 +1,93 @@
<script lang="ts">
import SectionHeader from '$lib/components/admin/SectionHeader.svelte';
import { IconEdit } from '$lib/components/icons';
interface WaveCustomer {
id: string;
name: string;
email: string | null;
isArchived: boolean;
}
interface Props {
waveCustomerId: string | null | undefined;
linkedWaveCustomer: WaveCustomer | null;
onLink: () => void;
}
let { waveCustomerId, linkedWaveCustomer, onLink }: Props = $props();
</script>
<div class="card-padded">
<SectionHeader
title="Wave Integration"
buttonText={waveCustomerId ? 'Change' : 'Link'}
buttonIcon="none"
onButtonClick={onLink}
/>
{#if linkedWaveCustomer}
<!-- Linked Wave customer shown prominently -->
<div
class="rounded-lg border-2 border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20"
>
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-lg font-bold text-blue-700 dark:text-blue-400">
{linkedWaveCustomer.name}
</span>
<span class="badge-primary">Linked</span>
</div>
{#if linkedWaveCustomer.email}
<p class="mt-1 text-sm text-blue-600 dark:text-blue-500">
{linkedWaveCustomer.email}
</p>
{/if}
<p class="mt-1 font-mono text-xs text-blue-500 dark:text-blue-600">
{waveCustomerId}
</p>
</div>
<button
type="button"
onclick={onLink}
class="shrink-0 rounded p-1.5 text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/40"
aria-label="Change linked Wave customer"
>
<IconEdit />
</button>
</div>
</div>
{:else if waveCustomerId}
<!-- Has ID but couldn't find matching Wave customer -->
<div
class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20"
>
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-yellow-700 dark:text-yellow-400">
Linked to unknown Wave customer
</span>
<span class="badge-warning">Unknown</span>
</div>
<p class="mt-1 font-mono text-xs text-yellow-600 dark:text-yellow-500">
{waveCustomerId}
</p>
</div>
<button
type="button"
onclick={onLink}
class="shrink-0 rounded p-1.5 text-yellow-600 hover:bg-yellow-100 dark:text-yellow-400 dark:hover:bg-yellow-900/40"
aria-label="Change linked Wave customer"
>
<IconEdit />
</button>
</div>
</div>
{:else}
<p class="text-center text-theme-muted">
No Wave customer linked. Click "Link" to connect this customer to Wave.
</p>
{/if}
</div>

View File

@ -0,0 +1,210 @@
<script lang="ts">
import SelectionModal from '$lib/components/shared/SelectionModal.svelte';
import { AdminProjectsStore, UpdateInvoiceStore } from '$houdini';
import { SvelteSet } from 'svelte/reactivity';
import type { GetInvoice$result } from '$houdini';
import type { AccountAddressInfo } from '$lib/utils/lookup';
type Invoice = NonNullable<GetInvoice$result['invoice']>;
interface Props {
open: boolean;
invoice: Invoice;
accountLookup: Map<string, AccountAddressInfo>;
onSuccess: () => void;
}
let { open = $bindable(false), invoice, accountLookup, onSuccess }: Props = $props();
let loading = $state(false);
let saving = $state(false);
let error = $state('');
let availableProjects = $state<
{
id: string;
name: string;
date: string;
amount: string | number | null;
accountAddressId: string | null;
customerId: string;
}[]
>([]);
let selectedProjectIds = new SvelteSet<string>();
const projectsStore = new AdminProjectsStore();
const updateStore = new UpdateInvoiceStore();
// Get customer UUID from the invoice
let customerUuid = $derived(invoice.customerId);
// Get already included project IDs
let includedProjectIds = $derived(new Set(invoice.projects.map((p) => p.id)));
// Load available projects when modal opens
$effect(() => {
if (open) {
loadAvailableProjects();
selectedProjectIds.clear();
}
});
async function loadAvailableProjects() {
loading = true;
error = '';
try {
const result = await projectsStore.fetch({
variables: {
filters: {
customerId: { exact: customerUuid },
status: { exact: 'COMPLETED' }
}
}
});
availableProjects = (result.data?.projects ?? []).filter(
(project) => !includedProjectIds.has(project.id)
);
} catch (err) {
console.error('Failed to load projects:', err);
error = 'Failed to load projects';
} finally {
loading = false;
}
}
function formatCurrency(value: string | number | null | undefined): string {
if (value === null || value === undefined) return '$0.00';
const num = typeof value === 'string' ? parseFloat(value) : value;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(num);
}
function formatDate(dateStr: string): string {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function getProjectDisplayName(project: {
name: string;
date: string;
accountAddressId: string | null;
customerId: string;
}): string {
const dateStr = formatDate(project.date);
let locationName = '';
if (project.accountAddressId) {
const accountInfo = accountLookup.get(project.accountAddressId);
if (accountInfo) {
locationName = accountInfo.accountName;
}
}
if (locationName) {
return `${dateStr} - ${locationName} - ${project.name}`;
}
return `${dateStr} - ${project.name}`;
}
function toggleProject(projectId: string) {
if (selectedProjectIds.has(projectId)) {
selectedProjectIds.delete(projectId);
} else {
selectedProjectIds.add(projectId);
}
}
function selectAll() {
for (const project of availableProjects) {
selectedProjectIds.add(project.id);
}
}
function selectNone() {
selectedProjectIds.clear();
}
// Calculate total of selected projects
let selectedTotal = $derived(
availableProjects
.filter((p) => selectedProjectIds.has(p.id))
.reduce((sum, p) => sum + parseFloat(String(p.amount ?? 0)), 0)
);
async function handleSave() {
if (selectedProjectIds.size === 0) return;
saving = true;
error = '';
try {
const existingIds = invoice.projects.map((p) => p.id);
const newIds = [...selectedProjectIds];
const allIds = [...existingIds, ...newIds];
await updateStore.mutate({
input: {
id: invoice.id,
projectIds: allIds
}
});
open = false;
onSuccess();
} catch (err) {
console.error('Failed to add projects:', err);
error = 'Failed to add projects';
} finally {
saving = false;
}
}
function handleClose() {
open = false;
}
</script>
<SelectionModal
bind:open
title="Add Projects"
subtitle="Select completed projects to add to this invoice."
itemName="Project"
{loading}
{saving}
{error}
selectedCount={selectedProjectIds.size}
hasItems={availableProjects.length > 0}
emptyMessage="No additional completed projects available for this customer."
showTotal={true}
{selectedTotal}
colorScheme="accent"
onSave={handleSave}
onClose={handleClose}
{selectAll}
{selectNone}
>
{#each availableProjects as project (project.id)}
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme interactive p-3"
>
<input
type="checkbox"
checked={selectedProjectIds.has(project.id)}
onchange={() => toggleProject(project.id)}
class="checkbox-base"
/>
<span class="min-w-0 flex-1">
<span class="text-sm font-medium text-theme">{getProjectDisplayName(project)}</span>
</span>
<span class="text-sm font-semibold text-accent-600 dark:text-accent-400">
{formatCurrency(project.amount)}
</span>
</label>
{/each}
</SelectionModal>

View File

@ -0,0 +1,173 @@
<script lang="ts">
import SelectionModal from '$lib/components/shared/SelectionModal.svelte';
import { UpdateInvoiceStore } from '$houdini';
import { SvelteSet } from 'svelte/reactivity';
import type { GetInvoice$result, Accounts$result } from '$houdini';
type Invoice = NonNullable<GetInvoice$result['invoice']>;
type Account = Accounts$result['accounts'][number];
interface Props {
open: boolean;
invoice: Invoice;
accounts: Account[];
onSuccess: () => void;
}
let { open = $bindable(false), invoice, accounts, onSuccess }: Props = $props();
let saving = $state(false);
let error = $state('');
let selectedRevenueIds = new SvelteSet<string>();
const updateStore = new UpdateInvoiceStore();
// Get customer UUID from the invoice
let customerUuid = $derived(invoice.customerId);
// Get already included revenue IDs
let includedRevenueIds = $derived(new Set(invoice.revenues.map((r) => r.id)));
// Get accounts belonging to this customer with their revenues
let customerAccounts = $derived(accounts.filter((a) => a.customerId === customerUuid));
// Flatten all revenues from customer accounts that aren't already included
let availableRevenues = $derived(
customerAccounts.flatMap((account) =>
(account.revenues ?? [])
.filter((r) => !includedRevenueIds.has(r.id))
.map((r) => ({
...r,
accountId: account.id,
accountName: account.name
}))
)
);
// Reset selection when modal opens
$effect(() => {
if (open) {
selectedRevenueIds.clear();
}
});
function formatCurrency(value: string | number): string {
const num = typeof value === 'string' ? parseFloat(value) : value;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(num);
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '';
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function toggleRevenue(revenueId: string) {
if (selectedRevenueIds.has(revenueId)) {
selectedRevenueIds.delete(revenueId);
} else {
selectedRevenueIds.add(revenueId);
}
}
function selectAll() {
for (const revenue of availableRevenues) {
selectedRevenueIds.add(revenue.id);
}
}
function selectNone() {
selectedRevenueIds.clear();
}
// Calculate total of selected revenues
let selectedTotal = $derived(
availableRevenues
.filter((r) => selectedRevenueIds.has(r.id))
.reduce((sum, r) => sum + parseFloat(String(r.amount)), 0)
);
async function handleSave() {
if (selectedRevenueIds.size === 0) return;
saving = true;
error = '';
try {
const existingIds = invoice.revenues.map((r) => r.id);
const newIds = [...selectedRevenueIds];
const allIds = [...existingIds, ...newIds];
await updateStore.mutate({
input: {
id: invoice.id,
revenueIds: allIds
}
});
open = false;
onSuccess();
} catch (err) {
console.error('Failed to add revenues:', err);
error = 'Failed to add revenues';
} finally {
saving = false;
}
}
function handleClose() {
open = false;
}
</script>
<SelectionModal
bind:open
title="Add Revenues"
subtitle="Select account revenues to add to this invoice."
itemName="Revenue"
{saving}
{error}
selectedCount={selectedRevenueIds.size}
hasItems={availableRevenues.length > 0}
emptyMessage="No additional revenues available for this customer's accounts."
showTotal={true}
{selectedTotal}
colorScheme="secondary"
onSave={handleSave}
onClose={handleClose}
{selectAll}
{selectNone}
>
{#each availableRevenues as revenue (revenue.id)}
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme interactive p-3"
>
<input
type="checkbox"
checked={selectedRevenueIds.has(revenue.id)}
onchange={() => toggleRevenue(revenue.id)}
class="checkbox-base"
/>
<span class="min-w-0 flex-1">
<span class="block text-sm font-medium text-theme">{revenue.accountName}</span>
<span class="block text-xs text-theme-muted">
{formatDate(revenue.startDate)}
{#if revenue.endDate}
- {formatDate(revenue.endDate)}
{:else}
- Ongoing
{/if}
</span>
</span>
<span class="text-sm font-semibold text-secondary-600 dark:text-secondary-400">
{formatCurrency(revenue.amount)}
</span>
</label>
{/each}
</SelectionModal>

View File

@ -0,0 +1,230 @@
<script lang="ts">
import { CreateInvoiceStore, UpdateInvoiceStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import { toGlobalId } from '$lib/utils/relay';
import type { Customers$result } from '$houdini';
type Customer = Customers$result['customers'][number];
// Invoice can come from either AdminInvoices or GetInvoice query
interface InvoiceData {
id: string;
date: string;
customerId: string;
status: string;
datePaid?: string | null;
paymentType?: string | null;
}
interface Props {
customers: Customer[];
invoice?: InvoiceData | null;
onSuccess?: () => void;
onCancel?: () => void;
}
let { customers, invoice = null, onSuccess, onCancel }: Props = $props();
let submitting = $state(false);
let error = $state('');
// Convert customerId (UUID) to GlobalID for the select dropdown
function getCustomerGlobalId(customerId: string | undefined): string {
if (!customerId) return '';
// customerId is a UUID, need to convert to GlobalID to match customer.id
return toGlobalId('CustomerType', customerId);
}
// Form fields
let customerId = $state(getCustomerGlobalId(invoice?.customerId));
let date = $state(invoice?.date ?? '');
let status = $state(invoice?.status ?? 'DRAFT');
let datePaid = $state(invoice?.datePaid ?? '');
let paymentType = $state(invoice?.paymentType ?? '');
// Active customers only
let activeCustomers = $derived(customers.filter((c) => c.status === 'ACTIVE'));
// Show payment fields when status is PAID
let showPaymentFields = $derived(status === 'PAID');
const createStore = new CreateInvoiceStore();
const updateStore = new UpdateInvoiceStore();
const statusOptions = [
{ value: 'DRAFT', label: 'Draft' },
{ value: 'SENT', label: 'Sent' },
{ value: 'PAID', label: 'Paid' },
{ value: 'OVERDUE', label: 'Overdue' },
{ value: 'CANCELLED', label: 'Cancelled' }
];
const paymentTypeOptions = [
{ value: '', label: 'Select payment type...' },
{ value: 'CHECK', label: 'Check' },
{ value: 'CREDIT_CARD', label: 'Credit Card' },
{ value: 'BANK_TRANSFER', label: 'Bank Transfer' },
{ value: 'CASH', label: 'Cash' }
];
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!customerId || !date) {
error = 'Please select a customer and date';
return;
}
if (invoice) {
// Update existing invoice
const res = await updateStore.mutate({
input: {
id: invoice.id,
customerId,
date,
status,
datePaid: showPaymentFields && datePaid ? datePaid : null,
paymentType: showPaymentFields && paymentType ? paymentType : null
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
} else {
// Create new invoice
const res = await createStore.mutate({
input: {
customerId,
date,
status,
datePaid: showPaymentFields && datePaid ? datePaid : null,
paymentType: showPaymentFields && paymentType ? paymentType : null
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save invoice';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<form onsubmit={handleSubmit} class="flex h-full flex-col">
<div class="flex-1 space-y-6 overflow-y-auto p-6">
<!-- Customer -->
<div>
<label for="customer" class="mb-1.5 block text-sm font-medium text-theme">Customer</label>
<select
id="customer"
bind:value={customerId}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
required
>
<option value="">Select customer...</option>
{#each activeCustomers as customer (customer.id)}
<option value={customer.id}>{customer.name}</option>
{/each}
</select>
</div>
<!-- Date -->
<div>
<label for="date" class="mb-1.5 block text-sm font-medium text-theme">Invoice Date</label>
<input
type="date"
id="date"
bind:value={date}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<!-- Status -->
<div>
<label for="status" class="mb-1.5 block text-sm font-medium text-theme">Status</label>
<select
id="status"
bind:value={status}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each statusOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Payment fields (shown when status is PAID) -->
{#if showPaymentFields}
<div class="space-y-4 rounded-lg border border-theme bg-theme-secondary p-4">
<h4 class="text-sm font-medium text-theme">Payment Details</h4>
<!-- Date Paid -->
<div>
<label for="datePaid" class="mb-1.5 block text-sm font-medium text-theme">Date Paid</label
>
<input
type="date"
id="datePaid"
bind:value={datePaid}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<!-- Payment Type -->
<div>
<label for="paymentType" class="mb-1.5 block text-sm font-medium text-theme"
>Payment Type</label
>
<select
id="paymentType"
bind:value={paymentType}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each paymentTypeOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
{/if}
{#if error}
<div class="rounded-lg bg-error-500/10 p-4">
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 border-t border-theme bg-theme p-6">
<button type="button" onclick={handleCancel} class="btn-ghost" disabled={submitting}>
Cancel
</button>
<button type="submit" class="btn-primary" disabled={submitting || !customerId || !date}>
{#if submitting}
{invoice ? 'Updating...' : 'Creating...'}
{:else}
{invoice ? 'Update Invoice' : 'Create Invoice'}
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,136 @@
<script lang="ts">
type InvoiceTab = 'details' | 'wave';
interface Props {
activeTab: InvoiceTab;
waveLinked: boolean;
validationErrors: number;
onTabChange: (tab: InvoiceTab) => void;
}
let { activeTab, waveLinked, validationErrors, onTabChange }: Props = $props();
const tabs: { id: InvoiceTab; label: string }[] = [
{ id: 'details', label: 'Details' },
{ id: 'wave', label: 'Wave' }
];
// Mobile tab navigation
let currentTabIndex = $derived(tabs.findIndex((t) => t.id === activeTab));
function goToPrevTab() {
const newIndex = currentTabIndex > 0 ? currentTabIndex - 1 : tabs.length - 1;
onTabChange(tabs[newIndex].id);
}
function goToNextTab() {
const newIndex = currentTabIndex < tabs.length - 1 ? currentTabIndex + 1 : 0;
onTabChange(tabs[newIndex].id);
}
function getBadgeForTab(tabId: InvoiceTab): { text: string; class: string } | null {
if (tabId === 'wave') {
if (waveLinked) {
return {
text: 'Linked',
class: 'bg-success-500/20 text-success-600 dark:text-success-400'
};
}
if (validationErrors > 0) {
return {
text: String(validationErrors),
class: 'bg-warning-500/20 text-warning-600 dark:text-warning-400'
};
}
}
return null;
}
</script>
<!-- Mobile: Carousel style -->
<div class="mb-6 md:hidden">
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-card p-2">
<button
type="button"
onclick={goToPrevTab}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Previous tab"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex items-center gap-2">
<span class="font-medium text-theme">{tabs[currentTabIndex].label}</span>
{#if getBadgeForTab(tabs[currentTabIndex].id)}
{@const badge = getBadgeForTab(tabs[currentTabIndex].id)}
{#if badge}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {badge.class}">
{badge.text}
</span>
{/if}
{/if}
</div>
<button
type="button"
onclick={goToNextTab}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Next tab"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Tab indicators -->
<div class="mt-2 flex justify-center gap-1.5">
{#each tabs as tab, i (tab.id)}
<button
type="button"
onclick={() => onTabChange(tab.id)}
class="h-1.5 rounded-full transition-all {i === currentTabIndex
? 'w-4 bg-accent6-500'
: 'w-1.5 bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500'}"
aria-label={tab.label}
></button>
{/each}
</div>
</div>
<!-- Desktop: Horizontal tabs -->
<div class="mb-6 hidden md:block">
<div class="border-b border-theme">
<nav class="flex gap-1" aria-label="Invoice tabs">
{#each tabs as tab (tab.id)}
{@const badge = getBadgeForTab(tab.id)}
<button
type="button"
onclick={() => onTabChange(tab.id)}
class="relative px-4 py-3 text-sm font-medium transition-colors
{activeTab === tab.id
? 'text-accent6-600 dark:text-accent6-400'
: 'text-theme-muted hover:text-theme'}"
>
<span class="flex items-center gap-2">
{tab.label}
{#if badge}
<span
class="rounded-full px-2 py-0.5 text-xs font-medium
{activeTab === tab.id ? badge.class : 'bg-theme-secondary text-theme-muted'}"
>
{badge.text}
</span>
{/if}
</span>
{#if activeTab === tab.id}
<span class="absolute right-0 bottom-0 left-0 h-0.5 bg-accent6-500"></span>
{/if}
</button>
{/each}
</nav>
</div>
</div>

View File

@ -0,0 +1,278 @@
<script lang="ts">
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { WaveCustomer } from './types';
interface Props {
customer?: WaveCustomer | null;
onSuccess?: () => void;
}
let { customer = null, onSuccess }: Props = $props();
let isSubmitting = $state(false);
let error = $state('');
// Basic info
let name = $state(customer?.name ?? '');
let firstName = $state(customer?.firstName ?? '');
let lastName = $state(customer?.lastName ?? '');
let email = $state(customer?.email ?? '');
let phone = $state(customer?.phone ?? '');
// Address
let addressLine1 = $state(customer?.address?.addressLine1 ?? '');
let addressLine2 = $state(customer?.address?.addressLine2 ?? '');
let city = $state(customer?.address?.city ?? '');
let provinceCode = $state(customer?.address?.province?.code ?? '');
let countryCode = $state(customer?.address?.country?.code ?? 'US');
let postalCode = $state(customer?.address?.postalCode ?? '');
// Notes
let internalNotes = $state(customer?.internalNotes ?? '');
let isEditing = $derived(!!customer);
</script>
<form
method="POST"
action={isEditing ? '?/updateCustomer' : '?/createCustomer'}
use:enhance={() => {
isSubmitting = true;
error = '';
return async ({ result }) => {
isSubmitting = false;
if (result.type === 'failure') {
const errorMsg = result.data?.error;
error = typeof errorMsg === 'string' ? errorMsg : 'An error occurred';
} else if (result.type === 'success') {
await invalidateAll();
offCanvas.closeRight();
onSuccess?.();
}
};
}}
class="flex h-full flex-col"
>
{#if isEditing}
<input type="hidden" name="id" value={customer?.id} />
{/if}
<div class="flex-1 space-y-4 overflow-y-auto">
{#if error}
<div class="rounded-lg border border-danger bg-danger p-3 text-sm text-danger">
{error}
</div>
{/if}
<!-- Basic Info Section -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-theme-muted">Basic Information</h4>
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Business/Display Name <span class="text-error-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={name}
required
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="Customer or business name"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="firstName" class="mb-1.5 block text-sm font-medium text-theme">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
bind:value={firstName}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="First name"
/>
</div>
<div>
<label for="lastName" class="mb-1.5 block text-sm font-medium text-theme">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
bind:value={lastName}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="Last name"
/>
</div>
</div>
<div>
<label for="email" class="mb-1.5 block text-sm font-medium text-theme">Email</label>
<input
type="email"
id="email"
name="email"
bind:value={email}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="customer@example.com"
/>
</div>
<div>
<label for="phone" class="mb-1.5 block text-sm font-medium text-theme">Phone</label>
<input
type="tel"
id="phone"
name="phone"
bind:value={phone}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="(555) 123-4567"
/>
</div>
</div>
<!-- Address Section -->
<div class="space-y-4 border-t border-theme pt-4">
<h4 class="text-sm font-medium text-theme-muted">Address</h4>
<div>
<label for="addressLine1" class="mb-1.5 block text-sm font-medium text-theme">
Street Address
</label>
<input
type="text"
id="addressLine1"
name="addressLine1"
bind:value={addressLine1}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="123 Main St"
/>
</div>
<div>
<label for="addressLine2" class="mb-1.5 block text-sm font-medium text-theme">
Apt, Suite, etc.
</label>
<input
type="text"
id="addressLine2"
name="addressLine2"
bind:value={addressLine2}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="Suite 100"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="city" class="mb-1.5 block text-sm font-medium text-theme">City</label>
<input
type="text"
id="city"
name="city"
bind:value={city}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="City"
/>
</div>
<div>
<label for="provinceCode" class="mb-1.5 block text-sm font-medium text-theme">
State/Province
</label>
<input
type="text"
id="provinceCode"
name="provinceCode"
bind:value={provinceCode}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="CA"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="postalCode" class="mb-1.5 block text-sm font-medium text-theme">
Postal Code
</label>
<input
type="text"
id="postalCode"
name="postalCode"
bind:value={postalCode}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="12345"
/>
</div>
<div>
<label for="countryCode" class="mb-1.5 block text-sm font-medium text-theme">
Country
</label>
<select
id="countryCode"
name="countryCode"
bind:value={countryCode}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="space-y-4 border-t border-theme pt-4">
<h4 class="text-sm font-medium text-theme-muted">Internal Notes</h4>
<div>
<label for="internalNotes" class="mb-1.5 block text-sm font-medium text-theme">
Notes
</label>
<textarea
id="internalNotes"
name="internalNotes"
bind:value={internalNotes}
rows="3"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="Private notes about this customer (not visible to customer)"
></textarea>
</div>
</div>
</div>
<div class="mt-6 flex gap-3 border-t border-theme pt-4">
<button
type="button"
onclick={() => offCanvas.closeRight()}
disabled={isSubmitting}
class="flex-1 rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name}
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
<IconSpinner class="h-4 w-4" />
<span>{isEditing ? 'Saving...' : 'Creating...'}</span>
{:else}
{isEditing ? 'Save Changes' : 'Create Customer'}
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,248 @@
<script lang="ts">
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import WaveCustomerForm from './WaveCustomerForm.svelte';
import type { WaveCustomer } from './types';
interface Props {
customers: WaveCustomer[];
onDelete: (customer: WaveCustomer) => void;
}
let { customers, onDelete }: Props = $props();
let searchQuery = $state('');
let filteredCustomers = $derived.by(() => {
if (!searchQuery.trim()) return customers;
const query = searchQuery.toLowerCase();
return customers.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
(c.email && c.email.toLowerCase().includes(query)) ||
(c.firstName && c.firstName.toLowerCase().includes(query)) ||
(c.lastName && c.lastName.toLowerCase().includes(query))
);
});
function formatCurrency(value: string): string {
const num = parseFloat(value.replace(/,/g, ''));
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function openCreateForm() {
offCanvas.showRight({
title: 'New Customer',
content: createFormContent
});
}
function openEditForm(customer: WaveCustomer) {
selectedCustomer = customer;
offCanvas.showRight({
title: 'Edit Customer',
content: editFormContent
});
}
let selectedCustomer = $state<WaveCustomer | null>(null);
</script>
{#snippet createFormContent()}
<WaveCustomerForm />
{/snippet}
{#snippet editFormContent()}
<WaveCustomerForm customer={selectedCustomer} />
{/snippet}
<div class="space-y-4">
<!-- Header with search and add button -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="relative flex-1 sm:max-w-xs">
<svg
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Search customers..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme-card py-2 pr-4 pl-10 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
/>
</div>
<button
type="button"
onclick={openCreateForm}
class="flex items-center justify-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Customer
</button>
</div>
<!-- Customers List -->
{#if filteredCustomers.length > 0}
<div class="grid grid-cols-1 gap-4">
{#each filteredCustomers as customer (customer.id)}
<div
class="flex min-w-0 items-start justify-between gap-4 rounded-lg border border-theme bg-theme-card p-4"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium text-theme">{customer.name}</h3>
{#if parseFloat(customer.overdueAmount.value) > 0}
<span
class="rounded-full bg-error-500/10 px-2 py-0.5 text-xs font-medium text-error-600 dark:text-error-400"
>
Overdue
</span>
{/if}
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-theme-muted">
{#if customer.email}
<span class="flex items-center gap-1.5">
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
{customer.email}
</span>
{/if}
{#if customer.phone}
<span class="flex items-center gap-1.5">
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
{customer.phone}
</span>
{/if}
</div>
{#if parseFloat(customer.outstandingAmount.value) > 0}
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm">
<span class="text-theme-secondary">
Outstanding: <span class="font-medium text-theme"
>{formatCurrency(customer.outstandingAmount.value)}</span
>
</span>
{#if parseFloat(customer.overdueAmount.value) > 0}
<span class="text-error-600 dark:text-error-400">
Overdue: <span class="font-medium"
>{formatCurrency(customer.overdueAmount.value)}</span
>
</span>
{/if}
</div>
{/if}
</div>
<div class="flex flex-shrink-0 items-center gap-2">
<button
type="button"
onclick={() => openEditForm(customer)}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Edit customer"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={() => onDelete(customer)}
class="rounded-lg p-2 text-theme-muted hover:bg-error-500/10 hover:text-error-600 dark:hover:text-error-400"
aria-label="Delete customer"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
{/each}
</div>
{:else if searchQuery}
<div class="empty-state py-12">
<svg
class="empty-state-icon text-accent6-400"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h2 class="empty-state-title">No Customers Found</h2>
<p class="empty-state-text">No customers match your search criteria.</p>
</div>
{:else}
<div class="empty-state py-12">
<svg
class="empty-state-icon text-accent6-400"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<h2 class="empty-state-title">No Customers</h2>
<p class="empty-state-text">Get started by adding your first customer.</p>
<button
type="button"
onclick={openCreateForm}
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Customer
</button>
</div>
{/if}
</div>

View File

@ -0,0 +1,571 @@
<script lang="ts">
import { enhance } from '$app/forms';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import type { WaveInvoice, WaveInvoiceItem, WaveProduct } from './types';
interface EditableLineItem {
productId: string;
productName: string;
description: string;
quantity: number;
unitPrice: number;
}
interface Props {
waveInvoice: WaveInvoice;
waveProducts: WaveProduct[];
onSuccess: () => void;
onCancel: () => void;
}
let { waveInvoice, waveProducts, onSuccess, onCancel }: Props = $props();
let submitting = $state(false);
let error = $state('');
// Form state - pre-populated from Wave invoice
let invoiceDate = $state(waveInvoice.invoiceDate);
let dueDate = $state(waveInvoice.dueDate);
let memo = $state(waveInvoice.memo || '');
let footer = $state(waveInvoice.footer || '');
// Line items state - built from Wave invoice items
let lineItems = $state<EditableLineItem[]>(buildLineItemsFromInvoice());
// Add line item state
let showAddLineItem = $state(false);
let newProductId = $state('');
let newDescription = $state('');
let newQuantity = $state(1);
let newUnitPrice = $state(0);
// Discount state - initialize from existing Wave invoice discount
let hasDiscount = $state(parseFloat(waveInvoice.discountTotal.value) > 0);
let discountType = $state<'PERCENTAGE' | 'FIXED'>('PERCENTAGE');
let discountName = $state('Discount');
let discountValue = $state(0);
// Active (non-archived) products for the dropdown
let activeProducts = $derived(waveProducts.filter((p) => !p.isArchived));
function buildLineItemsFromInvoice(): EditableLineItem[] {
return waveInvoice.items.map((item: WaveInvoiceItem) => ({
productId: item.product.id,
productName: item.product.name,
description: item.description || '',
quantity: item.quantity,
unitPrice: item.unitPrice
}));
}
// Calculate totals
let subtotal = $derived(lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0));
let discountAmount = $derived(() => {
if (!hasDiscount || discountValue <= 0) return 0;
if (discountType === 'PERCENTAGE') {
return subtotal * (discountValue / 100);
}
return discountValue;
});
let total = $derived(subtotal - discountAmount());
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
}
function updateLineItem(index: number, field: keyof EditableLineItem, value: string | number) {
lineItems[index] = { ...lineItems[index], [field]: value };
}
function removeLineItem(index: number) {
lineItems = lineItems.filter((_, i) => i !== index);
}
function handleProductSelect(productId: string) {
const product = waveProducts.find((p) => p.id === productId);
if (product) {
newUnitPrice = product.unitPrice;
newDescription = product.description || '';
}
}
function addLineItem() {
if (!newProductId) return;
const product = waveProducts.find((p) => p.id === newProductId);
if (!product) return;
lineItems = [
...lineItems,
{
productId: product.id,
productName: product.name,
description: newDescription,
quantity: newQuantity,
unitPrice: newUnitPrice
}
];
// Reset add line item form
showAddLineItem = false;
newProductId = '';
newDescription = '';
newQuantity = 1;
newUnitPrice = 0;
}
function cancelAddLineItem() {
showAddLineItem = false;
newProductId = '';
newDescription = '';
newQuantity = 1;
newUnitPrice = 0;
}
// Build items JSON for form submission
// Wave API expects the product's Global ID
let itemsJson = $derived(
JSON.stringify(
lineItems.map((item) => ({
productId: item.productId,
description: item.description,
quantity: item.quantity.toString(),
unitPrice: item.unitPrice.toString()
}))
)
);
// Build discount JSON for form submission
let discountJson = $derived(() => {
if (!hasDiscount || discountValue <= 0) return null;
return JSON.stringify({
type: discountType,
name: discountName,
value: discountValue
});
});
</script>
<form
method="POST"
action="?/updateWaveInvoice"
use:enhance={() => {
submitting = true;
error = '';
return async ({ result }) => {
submitting = false;
if (result.type === 'failure') {
const errorMsg = result.data?.error;
error = typeof errorMsg === 'string' ? errorMsg : 'Failed to update invoice';
} else if (result.type === 'success') {
onSuccess();
}
};
}}
class="space-y-6"
>
<!-- Hidden fields for form action -->
<input type="hidden" name="invoiceId" value={waveInvoice.id} />
<input type="hidden" name="items" value={itemsJson} />
<input type="hidden" name="discount" value={discountJson() || ''} />
<!-- Header -->
<div class="card-padded">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-theme">
Edit Invoice #{waveInvoice.invoiceNumber}
</h3>
<p class="mt-1 text-sm text-theme-muted">Modify invoice details and line items.</p>
</div>
<button
type="button"
onclick={onCancel}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Cancel"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if error}
<div class="mx-4 alert-error">
{error}
</div>
{/if}
<!-- Invoice Details -->
<div class="card-padded">
<h4 class="mb-4 text-sm font-medium text-theme-muted">Invoice Details</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="invoiceDate" class="mb-1 block text-sm font-medium text-theme">
Invoice Date
</label>
<input
type="date"
id="invoiceDate"
name="invoiceDate"
bind:value={invoiceDate}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="dueDate" class="mb-1 block text-sm font-medium text-theme">Due Date</label>
<input
type="date"
id="dueDate"
name="dueDate"
bind:value={dueDate}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
</div>
<!-- Line Items -->
<div class="card-padded">
<div class="mb-4 flex items-center justify-between">
<h4 class="text-sm font-medium text-theme-muted">
Line Items ({lineItems.length})
</h4>
{#if !showAddLineItem}
<button
type="button"
onclick={() => (showAddLineItem = true)}
class="flex items-center gap-1.5 rounded-lg bg-accent6-500/10 px-3 py-1.5 text-sm font-medium text-accent6-600 transition-colors hover:bg-accent6-500/20 dark:text-accent6-400"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Item
</button>
{/if}
</div>
<div class="space-y-4">
{#each lineItems as item, index (item.productId + '-' + index)}
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="mb-3 flex items-start justify-between gap-2">
<div>
<span class="text-sm font-medium text-theme">{item.productName}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-theme">
{formatCurrency(item.quantity * item.unitPrice)}
</span>
<button
type="button"
onclick={() => removeLineItem(index)}
class="rounded p-1 text-theme-muted transition-colors hover:bg-error-500/10 hover:text-error-600 dark:hover:text-error-400"
title="Remove line item"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="sm:col-span-2">
<label for="line-item-desc-{index}" class="mb-1 block text-xs text-theme-muted"
>Description</label
>
<input
type="text"
id="line-item-desc-{index}"
value={item.description}
onchange={(e) => updateLineItem(index, 'description', e.currentTarget.value)}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label for="line-item-qty-{index}" class="mb-1 block text-xs text-theme-muted"
>Qty</label
>
<input
type="number"
id="line-item-qty-{index}"
min="0"
step="1"
value={item.quantity}
onchange={(e) =>
updateLineItem(index, 'quantity', parseFloat(e.currentTarget.value) || 0)}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="line-item-price-{index}" class="mb-1 block text-xs text-theme-muted"
>Price</label
>
<input
type="number"
id="line-item-price-{index}"
min="0"
step="0.01"
value={item.unitPrice}
onchange={(e) =>
updateLineItem(index, 'unitPrice', parseFloat(e.currentTarget.value) || 0)}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
</div>
</div>
{/each}
<!-- Add Line Item Form -->
{#if showAddLineItem}
<div class="rounded-lg border-2 border-dashed border-accent6-500/30 bg-accent6-500/5 p-4">
<h5 class="mb-3 text-sm font-medium text-theme">Add New Line Item</h5>
<div class="space-y-3">
<div>
<label for="new-product" class="mb-1 block text-xs text-theme-muted">Product</label>
<select
id="new-product"
bind:value={newProductId}
onchange={(e) => handleProductSelect(e.currentTarget.value)}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
>
<option value="">Select a product...</option>
{#each activeProducts as product (product.id)}
<option value={product.id}>{product.name}</option>
{/each}
</select>
</div>
<div>
<label for="new-description" class="mb-1 block text-xs text-theme-muted"
>Description</label
>
<input
type="text"
id="new-description"
bind:value={newDescription}
placeholder="Optional description..."
class="placeholder-theme-muted w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="new-quantity" class="mb-1 block text-xs text-theme-muted"
>Quantity</label
>
<input
type="number"
id="new-quantity"
min="1"
step="1"
bind:value={newQuantity}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="new-price" class="mb-1 block text-xs text-theme-muted">Unit Price</label
>
<input
type="number"
id="new-price"
min="0"
step="0.01"
bind:value={newUnitPrice}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<div class="flex items-center gap-2 pt-2">
<button
type="button"
onclick={addLineItem}
disabled={!newProductId}
class="rounded-lg bg-accent6-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
>
Add Item
</button>
<button
type="button"
onclick={cancelAddLineItem}
class="rounded-lg border border-theme interactive bg-theme px-3 py-1.5 text-sm font-medium text-theme"
>
Cancel
</button>
</div>
</div>
</div>
{/if}
</div>
<!-- Subtotal -->
<div class="mt-4 flex items-center justify-between border-t border-theme pt-4">
<span class="font-medium text-theme">Subtotal</span>
<span class="text-lg font-bold text-theme">
{formatCurrency(subtotal)}
</span>
</div>
<!-- Discount -->
{#if hasDiscount && discountAmount() > 0}
<div class="flex items-center justify-between py-2">
<span class="text-theme-secondary">Discount ({discountName})</span>
<span class="font-medium text-success-600 dark:text-success-400">
-{formatCurrency(discountAmount())}
</span>
</div>
{/if}
<!-- Total -->
<div class="flex items-center justify-between border-t border-theme pt-4">
<span class="font-medium text-theme">Total</span>
<span class="text-lg font-bold text-accent6-600 dark:text-accent6-400">
{formatCurrency(total)}
</span>
</div>
</div>
<!-- Discount Section -->
<div class="card-padded">
<div class="mb-4 flex items-center justify-between">
<h4 class="text-sm font-medium text-theme-muted">Discount</h4>
<label class="inline-flex cursor-pointer items-center gap-2">
<input type="checkbox" bind:checked={hasDiscount} class="peer sr-only" />
<span
class="relative h-5 w-9 rounded-full bg-gray-300 peer-checked:bg-accent6-500 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full dark:bg-gray-600"
></span>
<span class="text-sm text-theme">{hasDiscount ? 'Enabled' : 'Disabled'}</span>
</label>
</div>
{#if hasDiscount}
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label for="discountType" class="mb-1 block text-sm font-medium text-theme">Type</label>
<select
id="discountType"
bind:value={discountType}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
>
<option value="PERCENTAGE">Percentage</option>
<option value="FIXED">Fixed Amount</option>
</select>
</div>
<div>
<label for="discountName" class="mb-1 block text-sm font-medium text-theme">Name</label>
<input
type="text"
id="discountName"
bind:value={discountName}
placeholder="Discount"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="discountValue" class="mb-1 block text-sm font-medium text-theme">
{discountType === 'PERCENTAGE' ? 'Percentage (%)' : 'Amount ($)'}
</label>
<input
type="number"
id="discountValue"
min="0"
step={discountType === 'PERCENTAGE' ? '1' : '0.01'}
max={discountType === 'PERCENTAGE' ? '100' : undefined}
bind:value={discountValue}
placeholder={discountType === 'PERCENTAGE' ? '10' : '50.00'}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
{#if discountValue > 0}
<p class="text-sm text-theme-muted">
{#if discountType === 'PERCENTAGE'}
{discountValue}% off = -{formatCurrency(discountAmount())}
{:else}
Fixed discount of {formatCurrency(discountValue)}
{/if}
</p>
{/if}
</div>
{/if}
</div>
<!-- Notes -->
<div class="card-padded">
<h4 class="mb-4 text-sm font-medium text-theme-muted">Additional Notes</h4>
<div class="space-y-4">
<div>
<label for="memo" class="mb-1 block text-sm font-medium text-theme">Memo (Notes)</label>
<textarea
id="memo"
name="memo"
bind:value={memo}
rows="2"
placeholder="Internal notes for the invoice..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
></textarea>
</div>
<div>
<label for="footer" class="mb-1 block text-sm font-medium text-theme">Footer</label>
<textarea
id="footer"
name="footer"
bind:value={footer}
rows="2"
placeholder="Footer text shown on the invoice..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
></textarea>
</div>
</div>
</div>
<!-- Actions -->
<div class="card-padded">
<div class="flex items-center gap-3">
<button
type="submit"
disabled={submitting || lineItems.length === 0}
class="flex-1 rounded-lg bg-accent6-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>Saving Changes...</span>
</span>
{:else}
Save Changes
{/if}
</button>
<button
type="button"
onclick={onCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2.5 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</div>
</form>

View File

@ -0,0 +1,714 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { fromGlobalId } from '$lib/utils/relay';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import type {
NexusInvoice,
NexusRevenue,
NexusProject,
WaveProduct,
WaveLineItem,
WaveDiscount
} from './types';
import { SvelteDate } from 'svelte/reactivity';
interface Props {
invoice: NexusInvoice;
waveCustomerId: string;
revenues: NexusRevenue[];
projects: NexusProject[];
waveProducts: WaveProduct[];
onSuccess: () => void;
onCancel: () => void;
}
let { invoice, waveCustomerId, revenues, projects, waveProducts, onSuccess, onCancel }: Props =
$props();
let submitting = $state(false);
let error = $state('');
// Form state
let invoiceDate = $state(invoice.date);
let dueDate = $state(calculateDueDate(invoice.date, 15));
let memo = $state(
'Thank you for choosing Nexus Cleaning Solutions! We appreciate your business. If you have any feedback or requests, please reach out to us.'
);
let footer = $state('');
// Line items state
let lineItems = $state<WaveLineItem[]>(buildInitialLineItems());
// Additional line item state
let showAddLineItem = $state(false);
let newProductId = $state('');
let newDescription = $state('');
let newQuantity = $state(1);
let newUnitPrice = $state(0);
// Discount state
let hasDiscount = $state(false);
let discountType = $state<'PERCENTAGE' | 'FIXED'>('PERCENTAGE');
let discountName = $state('Discount');
let discountValue = $state(0);
// Available products for adding (non-archived only)
let availableProducts = $derived(waveProducts.filter((p) => !p.isArchived));
function calculateDueDate(fromDate: string, daysToAdd: number): string {
const date = new SvelteDate(fromDate + 'T00:00:00');
date.setDate(date.getDate() + daysToAdd);
return date.toISOString().split('T')[0];
}
function getProduct(waveServiceId: string | null): WaveProduct | null {
if (!waveServiceId) return null;
return waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) ?? null;
}
function getProductById(productId: string): WaveProduct | null {
return waveProducts.find((p) => p.id === productId) ?? null;
}
function getProductName(waveServiceId: string | null): string {
const product = getProduct(waveServiceId);
return product?.name ?? 'Unknown Product';
}
function getProductDescription(waveServiceId: string | null): string {
const product = getProduct(waveServiceId);
return product?.description ?? '';
}
function buildInitialLineItems(): WaveLineItem[] {
const items: WaveLineItem[] = [];
// Add revenues as line items
for (const revenue of revenues) {
if (!revenue.waveServiceId) continue;
items.push({
sourceType: 'revenue',
sourceId: revenue.id,
productId: revenue.waveServiceId,
productName: getProductName(revenue.waveServiceId),
description: getProductDescription(revenue.waveServiceId),
quantity: 1,
unitPrice: parseFloat(String(revenue.amount))
});
}
// Add projects as line items
for (const project of projects) {
if (!project.waveServiceId) continue;
items.push({
sourceType: 'project',
sourceId: project.id,
productId: project.waveServiceId,
productName: getProductName(project.waveServiceId),
description: getProductDescription(project.waveServiceId),
quantity: 1,
unitPrice: parseFloat(String(project.amount))
});
}
return items;
}
// Calculate totals
let subtotal = $derived(lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0));
let discountAmount = $derived(() => {
if (!hasDiscount || discountValue <= 0) return 0;
if (discountType === 'PERCENTAGE') {
return subtotal * (discountValue / 100);
}
return discountValue;
});
let total = $derived(subtotal - discountAmount());
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
}
function updateLineItem(index: number, field: keyof WaveLineItem, value: string | number) {
lineItems[index] = { ...lineItems[index], [field]: value };
}
function removeLineItem(index: number) {
lineItems = lineItems.filter((_, i) => i !== index);
}
function addLineItem() {
if (!newProductId) return;
const product = getProductById(newProductId);
if (!product) return;
const decodedProductId = fromGlobalId(product.id);
lineItems = [
...lineItems,
{
sourceType: 'additional',
sourceId: `additional-${Date.now()}`,
productId: decodedProductId,
productName: product.name,
description: newDescription || product.description || '',
quantity: newQuantity,
unitPrice: newUnitPrice || product.unitPrice
}
];
// Reset form
showAddLineItem = false;
newProductId = '';
newDescription = '';
newQuantity = 1;
newUnitPrice = 0;
}
function onProductSelect(productId: string) {
newProductId = productId;
const product = getProductById(productId);
if (product) {
newDescription = product.description || '';
newUnitPrice = product.unitPrice;
}
}
// Find the Wave product Global ID for a given decoded ID
function getWaveProductGlobalId(decodedId: string): string | null {
const product = waveProducts.find((p) => fromGlobalId(p.id) === decodedId);
return product?.id ?? null;
}
// Find the Wave customer Global ID
function getWaveCustomerGlobalId(): string {
// waveCustomerId from backend is stored as "businessId;Customer:customerId"
// Wave API expects base64 encoded "Business:<businessId>;Customer:<customerId>" format
// which is the same format, just needs to be re-encoded
return btoa(`Business:${waveCustomerId}`);
}
// Build items JSON for form submission
let itemsJson = $derived(
JSON.stringify(
lineItems.map((item) => {
const productGlobalId = getWaveProductGlobalId(item.productId);
return {
productId: productGlobalId,
description: item.description,
quantity: item.quantity.toString(),
unitPrice: item.unitPrice.toString()
};
})
)
);
// Build discount JSON for form submission
let discountJson = $derived(() => {
if (!hasDiscount || discountValue <= 0) return '';
const discount: WaveDiscount = {
type: discountType,
name: discountName,
value: discountValue
};
return JSON.stringify(discount);
});
function getSourceTypeBadgeClass(sourceType: string): string {
switch (sourceType) {
case 'revenue':
return 'bg-secondary-500/20 text-secondary-600 dark:text-secondary-400';
case 'project':
return 'bg-accent-500/20 text-accent-600 dark:text-accent-400';
case 'additional':
return 'bg-primary-500/20 text-primary-600 dark:text-primary-400';
default:
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
}
}
function getSourceTypeLabel(sourceType: string): string {
switch (sourceType) {
case 'revenue':
return 'Revenue';
case 'project':
return 'Project';
case 'additional':
return 'Additional';
default:
return sourceType;
}
}
</script>
<form
method="POST"
action="?/createWaveInvoice"
use:enhance={() => {
submitting = true;
error = '';
return async ({ result }) => {
submitting = false;
if (result.type === 'failure') {
const errorMsg = result.data?.error;
error = typeof errorMsg === 'string' ? errorMsg : 'Failed to create invoice';
} else if (result.type === 'success') {
onSuccess();
}
};
}}
class="space-y-6"
>
<!-- Hidden fields for form action -->
<input type="hidden" name="customerId" value={getWaveCustomerGlobalId()} />
<input type="hidden" name="items" value={itemsJson} />
<input type="hidden" name="discount" value={discountJson()} />
<!-- Header -->
<div class="card-padded">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-theme">Create Wave Invoice</h3>
<button
type="button"
onclick={onCancel}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Cancel"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Warning Box -->
<div class="warning-box">
<div class="flex gap-3">
<svg class="warning-box-icon" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<p class="warning-box-title">Review before submitting</p>
<p class="warning-box-text">
Please review and adjust the descriptions and amounts below. This will create a draft
invoice in Wave — you'll be able to preview and make final edits before sending.
</p>
</div>
</div>
</div>
{#if error}
<div class="mx-4 alert-error">
{error}
</div>
{/if}
<!-- Invoice Details -->
<div class="card-padded">
<h4 class="mb-4 text-sm font-medium text-theme-muted">Invoice Details</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="invoiceDate" class="mb-1 block text-sm font-medium text-theme">
Invoice Date
</label>
<input
type="date"
id="invoiceDate"
name="invoiceDate"
bind:value={invoiceDate}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="dueDate" class="mb-1 block text-sm font-medium text-theme">Due Date</label>
<input
type="date"
id="dueDate"
name="dueDate"
bind:value={dueDate}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
</div>
<!-- Line Items -->
<div class="card-padded">
<div class="mb-4 flex items-center justify-between">
<h4 class="text-sm font-medium text-theme-muted">Line Items</h4>
<button
type="button"
onclick={() => (showAddLineItem = !showAddLineItem)}
class="flex items-center gap-1.5 rounded-lg bg-primary-500/10 px-3 py-1.5 text-sm font-medium text-primary-600 transition-colors hover:bg-primary-500/20 dark:text-primary-400"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Item
</button>
</div>
<!-- Add Line Item Form -->
{#if showAddLineItem}
<div class="mb-4 rounded-lg border border-primary-500/30 bg-primary-500/5 p-4">
<h5 class="mb-3 text-sm font-medium text-theme">Add Line Item</h5>
<div class="space-y-3">
<div>
<label for="new-product" class="mb-1 block text-xs text-theme-muted">Product</label>
<select
id="new-product"
onchange={(e) => onProductSelect(e.currentTarget.value)}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
>
<option value="">Select a product...</option>
{#each availableProducts as product (product.id)}
<option value={product.id}>
{product.name}
{#if product.unitPrice > 0}
({formatCurrency(product.unitPrice)})
{/if}
</option>
{/each}
</select>
</div>
{#if newProductId}
<div>
<label for="new-description" class="mb-1 block text-xs text-theme-muted"
>Description</label
>
<input
type="text"
id="new-description"
bind:value={newDescription}
placeholder="Optional description..."
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="new-quantity" class="mb-1 block text-xs text-theme-muted"
>Quantity</label
>
<input
type="number"
id="new-quantity"
min="0.01"
step="0.01"
bind:value={newQuantity}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="new-price" class="mb-1 block text-xs text-theme-muted">Unit Price</label
>
<input
type="number"
id="new-price"
min="0"
step="0.01"
bind:value={newUnitPrice}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<div class="flex gap-2">
<button
type="button"
onclick={addLineItem}
class="rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600"
>
Add
</button>
<button
type="button"
onclick={() => {
showAddLineItem = false;
newProductId = '';
}}
class="rounded-lg border border-theme interactive px-3 py-1.5 text-sm font-medium text-theme"
>
Cancel
</button>
</div>
{/if}
</div>
</div>
{/if}
<div class="space-y-4">
{#each lineItems as item, index (item.sourceId)}
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
<div class="mb-3 flex items-start justify-between gap-2">
<div class="flex items-center gap-2">
<span
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {getSourceTypeBadgeClass(
item.sourceType
)}"
>
{getSourceTypeLabel(item.sourceType)}
</span>
<span class="text-sm font-medium text-theme">{item.productName}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-theme">
{formatCurrency(item.quantity * item.unitPrice)}
</span>
{#if item.sourceType === 'additional'}
<button
type="button"
onclick={() => removeLineItem(index)}
class="rounded p-1 text-theme-muted transition-colors hover:bg-error-500/10 hover:text-error-600 dark:hover:text-error-400"
title="Remove item"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="sm:col-span-2">
<label for="line-item-desc-{index}" class="mb-1 block text-xs text-theme-muted"
>Description</label
>
<input
type="text"
id="line-item-desc-{index}"
value={item.description}
onchange={(e) => updateLineItem(index, 'description', e.currentTarget.value)}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label for="line-item-qty-{index}" class="mb-1 block text-xs text-theme-muted"
>Qty</label
>
<input
type="number"
id="line-item-qty-{index}"
min="0"
step="1"
value={item.quantity}
onchange={(e) =>
updateLineItem(index, 'quantity', parseFloat(e.currentTarget.value) || 0)}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="line-item-price-{index}" class="mb-1 block text-xs text-theme-muted"
>Price</label
>
<input
type="number"
id="line-item-price-{index}"
min="0"
step="0.01"
value={item.unitPrice}
onchange={(e) =>
updateLineItem(index, 'unitPrice', parseFloat(e.currentTarget.value) || 0)}
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
</div>
</div>
{/each}
</div>
<!-- Totals -->
<div class="mt-4 space-y-2 border-t border-theme pt-4">
<div class="flex items-center justify-between">
<span class="text-sm text-theme-muted">Subtotal</span>
<span class="text-sm font-medium text-theme">
{formatCurrency(subtotal)}
</span>
</div>
{#if hasDiscount && discountValue > 0}
<div class="flex items-center justify-between text-error-600 dark:text-error-400">
<span class="text-sm">
{discountName}
{#if discountType === 'PERCENTAGE'}
({discountValue}%)
{/if}
</span>
<span class="text-sm font-medium">
-{formatCurrency(discountAmount())}
</span>
</div>
{/if}
<div class="flex items-center justify-between border-t border-theme pt-2">
<span class="font-medium text-theme">Total</span>
<span class="text-lg font-bold text-accent6-600 dark:text-accent6-400">
{formatCurrency(total)}
</span>
</div>
</div>
</div>
<!-- Discount Section -->
<div class="card-padded">
<div class="mb-4 flex items-center justify-between">
<h4 class="text-sm font-medium text-theme-muted">Discount</h4>
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
bind:checked={hasDiscount}
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-theme">Apply discount</span>
</label>
</div>
{#if hasDiscount}
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label for="discountType" class="mb-1 block text-sm font-medium text-theme">Type</label>
<select
id="discountType"
bind:value={discountType}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
>
<option value="PERCENTAGE">Percentage (%)</option>
<option value="FIXED">Fixed Amount ($)</option>
</select>
</div>
<div>
<label for="discountName" class="mb-1 block text-sm font-medium text-theme">Name</label>
<input
type="text"
id="discountName"
bind:value={discountName}
placeholder="e.g., Loyalty Discount"
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="discountValue" class="mb-1 block text-sm font-medium text-theme">
{discountType === 'PERCENTAGE' ? 'Percentage' : 'Amount'}
</label>
<div class="relative">
{#if discountType === 'FIXED'}
<span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-theme-muted"
>$</span
>
{/if}
<input
type="number"
id="discountValue"
min="0"
step={discountType === 'PERCENTAGE' ? '1' : '0.01'}
max={discountType === 'PERCENTAGE' ? '100' : undefined}
bind:value={discountValue}
class="w-full rounded-lg border border-theme bg-theme py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 {discountType ===
'FIXED'
? 'pr-3 pl-7'
: 'px-3'}"
/>
{#if discountType === 'PERCENTAGE'}
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-theme-muted"
>%</span
>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Notes -->
<div class="card-padded">
<h4 class="mb-4 text-sm font-medium text-theme-muted">Additional Notes</h4>
<div class="space-y-4">
<div>
<label for="memo" class="mb-1 block text-sm font-medium text-theme">Memo (Notes)</label>
<textarea
id="memo"
name="memo"
bind:value={memo}
rows="2"
placeholder="Internal notes for the invoice..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
></textarea>
</div>
<div>
<label for="footer" class="mb-1 block text-sm font-medium text-theme">Footer</label>
<textarea
id="footer"
name="footer"
bind:value={footer}
rows="2"
placeholder="Footer text shown on the invoice..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
></textarea>
</div>
</div>
</div>
<!-- Actions -->
<div class="card-padded">
<div class="flex items-center gap-3">
<button
type="submit"
disabled={submitting || lineItems.length === 0}
class="flex-1 rounded-lg bg-accent6-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>Creating Invoice...</span>
</span>
{:else}
Create Draft Invoice
{/if}
</button>
<button
type="button"
onclick={onCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2.5 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</div>
</form>

View File

@ -0,0 +1,416 @@
<script lang="ts">
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import type { WaveInvoice } from './types';
interface Props {
waveInvoice: WaveInvoice;
onSend: () => void;
onApprove?: () => void;
onRefresh: () => void;
onEdit?: () => void;
onDelete?: () => void;
isSending?: boolean;
isApproving?: boolean;
isRefreshing?: boolean;
isDeleting?: boolean;
}
let {
waveInvoice,
onSend,
onApprove,
onRefresh,
onEdit,
onDelete,
isSending = false,
isApproving = false,
isRefreshing = false,
isDeleting = false
}: Props = $props();
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
});
}
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}
function formatCurrency(value: string, symbol: string = '$'): string {
const num = parseFloat(value.replace(/,/g, ''));
return `${symbol}${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function getStatusClasses(status: string): string {
switch (status) {
case 'DRAFT':
return 'bg-gray-500/10 text-gray-600 dark:text-gray-400';
case 'SAVED':
return 'bg-gray-500/10 text-gray-600 dark:text-gray-400';
case 'SENT':
return 'bg-primary-500/10 text-primary-600 dark:text-primary-400';
case 'VIEWED':
return 'bg-accent2-500/10 text-accent2-600 dark:text-accent2-400';
case 'PAID':
return 'bg-success-500/10 text-success-600 dark:text-success-400';
case 'PARTIAL':
return 'bg-warning-500/10 text-warning-600 dark:text-warning-400';
case 'OVERDUE':
return 'bg-error-500/10 text-error-600 dark:text-error-400';
default:
return 'bg-gray-500/10 text-gray-600 dark:text-gray-400';
}
}
function getStatusLabel(status: string): string {
switch (status) {
case 'DRAFT':
return 'Draft';
case 'SAVED':
return 'Saved';
case 'SENT':
return 'Sent';
case 'VIEWED':
return 'Viewed';
case 'PAID':
return 'Paid';
case 'PARTIAL':
return 'Partial';
case 'OVERDUE':
return 'Overdue';
default:
return status;
}
}
let canApprove = $derived(waveInvoice.status === 'DRAFT');
let canSend = $derived(waveInvoice.status === 'SAVED');
let canEdit = $derived(waveInvoice.status === 'DRAFT' || waveInvoice.status === 'SAVED');
let canDelete = $derived(waveInvoice.status === 'DRAFT' || waveInvoice.status === 'SAVED');
</script>
<div class="space-y-6">
<!-- Header with Status and Actions -->
<div class="card-padded">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-accent6-500/20">
<svg
class="h-6 w-6 text-accent6-600 dark:text-accent6-400"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold text-theme">
Invoice #{waveInvoice.invoiceNumber}
</h3>
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {getStatusClasses(
waveInvoice.status
)}"
>
{getStatusLabel(waveInvoice.status)}
</span>
</div>
<p class="text-sm text-theme-muted">{waveInvoice.customer.name}</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<a
href={waveInvoice.pdfUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-lg interactive bg-theme px-3 py-2 text-sm font-medium text-theme"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Download PDF
</a>
{#if canEdit && onEdit}
<button
type="button"
onclick={onEdit}
class="inline-flex items-center gap-1.5 rounded-lg interactive bg-theme px-3 py-2 text-sm font-medium text-theme"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Edit
</button>
{/if}
{#if canApprove && onApprove}
<button
type="button"
onclick={onApprove}
disabled={isApproving}
class="inline-flex items-center gap-1.5 rounded-lg bg-success-500 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-success-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isApproving}
<IconSpinner class="h-4 w-4" />
<span>Approving...</span>
{:else}
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Approve
{/if}
</button>
{/if}
{#if canSend}
<button
type="button"
onclick={onSend}
disabled={isSending}
class="inline-flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSending}
<IconSpinner class="h-4 w-4" />
<span>Sending...</span>
{:else}
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
Send Invoice
{/if}
</button>
{/if}
<button
type="button"
onclick={onRefresh}
disabled={isRefreshing}
class="inline-flex items-center gap-1.5 rounded-lg bg-theme px-3 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
aria-label="Refresh"
>
{#if isRefreshing}
<IconSpinner class="h-4 w-4" />
{:else}
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{/if}
</button>
{#if canDelete && onDelete}
<button
type="button"
onclick={onDelete}
disabled={isDeleting}
class="inline-flex items-center gap-1.5 rounded-lg bg-error-500/10 px-3 py-2 text-sm font-medium text-error-600 transition-colors hover:bg-error-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-error-400"
>
{#if isDeleting}
<IconSpinner class="h-4 w-4" />
<span>Deleting...</span>
{:else}
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Delete
{/if}
</button>
{/if}
</div>
</div>
</div>
<!-- Invoice Summary -->
<div class="card-padded">
<h4 class="mb-4 text-sm font-medium text-theme-muted">Invoice Summary</h4>
<div class="space-y-3">
<div class="flex items-center justify-between py-2">
<span class="text-theme-secondary">Subtotal</span>
<span class="font-medium text-theme">
{formatCurrency(waveInvoice.subtotal.value, waveInvoice.subtotal.currency.symbol)}
</span>
</div>
{#if parseFloat(waveInvoice.discountTotal.value) > 0}
<div class="flex items-center justify-between py-2">
<span class="text-theme-secondary">Discounts</span>
<span class="font-medium text-success-600 dark:text-success-400">
-{formatCurrency(
waveInvoice.discountTotal.value,
waveInvoice.discountTotal.currency.symbol
)}
</span>
</div>
{/if}
{#if parseFloat(waveInvoice.taxTotal.value) > 0}
<div class="flex items-center justify-between py-2">
<span class="text-theme-secondary">Tax</span>
<span class="font-medium text-theme">
{formatCurrency(waveInvoice.taxTotal.value, waveInvoice.taxTotal.currency.symbol)}
</span>
</div>
{/if}
<div class="flex items-center justify-between border-t border-theme py-3">
<span class="font-medium text-theme">Total</span>
<span class="text-lg font-bold text-accent6-600 dark:text-accent6-400">
{formatCurrency(waveInvoice.total.value, waveInvoice.total.currency.symbol)}
</span>
</div>
{#if parseFloat(waveInvoice.amountPaid.value) > 0}
<div class="flex items-center justify-between py-2">
<span class="text-theme-secondary">Amount Paid</span>
<span class="font-medium text-success-600 dark:text-success-400">
{formatCurrency(waveInvoice.amountPaid.value, waveInvoice.amountPaid.currency.symbol)}
</span>
</div>
{/if}
{#if parseFloat(waveInvoice.amountDue.value) > 0}
<div class="flex items-center justify-between py-2">
<span class="font-medium text-theme">Amount Due</span>
<span class="text-lg font-bold text-theme">
{formatCurrency(waveInvoice.amountDue.value, waveInvoice.amountDue.currency.symbol)}
</span>
</div>
{/if}
</div>
</div>
<!-- Line Items -->
<div class="card-padded">
<h4 class="mb-4 text-sm font-medium text-theme-muted">
Line Items ({waveInvoice.items.length})
</h4>
<div class="space-y-2">
{#each waveInvoice.items as item, index (index)}
<div class="rounded-lg border border-theme bg-theme-secondary p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="font-medium text-theme">{item.product.name}</p>
{#if item.description}
<p class="mt-0.5 text-sm text-theme-muted">{item.description}</p>
{/if}
<p class="mt-1 text-xs text-theme-muted">
{item.quantity} x {formatCurrency(String(item.unitPrice))}
</p>
</div>
<span class="font-semibold text-theme">
{formatCurrency(item.total.value)}
</span>
</div>
</div>
{/each}
</div>
</div>
<!-- Invoice Details -->
<div class="card-padded">
<h4 class="mb-4 text-sm font-medium text-theme-muted">Invoice Details</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p class="text-sm text-theme-muted">Invoice Date</p>
<p class="font-medium text-theme">{formatDate(waveInvoice.invoiceDate)}</p>
</div>
<div>
<p class="text-sm text-theme-muted">Due Date</p>
<p class="font-medium text-theme">{formatDate(waveInvoice.dueDate)}</p>
</div>
{#if waveInvoice.poNumber}
<div>
<p class="text-sm text-theme-muted">PO Number</p>
<p class="font-medium text-theme">{waveInvoice.poNumber}</p>
</div>
{/if}
{#if waveInvoice.lastSentAt}
<div>
<p class="text-sm text-theme-muted">Last Sent</p>
<p class="font-medium text-theme">
{formatDateTime(waveInvoice.lastSentAt)}
{#if waveInvoice.lastSentVia}
<span class="text-theme-muted">via {waveInvoice.lastSentVia}</span>
{/if}
</p>
</div>
{/if}
{#if waveInvoice.lastViewedAt}
<div>
<p class="text-sm text-theme-muted">Last Viewed</p>
<p class="font-medium text-theme">{formatDateTime(waveInvoice.lastViewedAt)}</p>
</div>
{/if}
</div>
</div>
<!-- Notes -->
{#if waveInvoice.memo || waveInvoice.footer}
<div class="card-padded">
<h4 class="mb-4 text-sm font-medium text-theme-muted">Notes</h4>
<div class="space-y-4">
{#if waveInvoice.memo}
<div>
<p class="text-sm font-medium text-theme">Memo</p>
<p class="mt-1 text-sm text-theme-secondary">{waveInvoice.memo}</p>
</div>
{/if}
{#if waveInvoice.footer}
<div>
<p class="text-sm font-medium text-theme">Footer</p>
<p class="mt-1 text-sm text-theme-secondary">{waveInvoice.footer}</p>
</div>
{/if}
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,293 @@
<script lang="ts">
import { UpdateRevenueStore, UpdateProjectStore } from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import type { WaveProduct } from './types';
interface Props {
open: boolean;
entityType: 'revenue' | 'project';
entityId: string;
entityDescription: string;
currentWaveServiceId: string | null;
waveProducts: WaveProduct[];
onSuccess: () => void;
onCancel: () => void;
}
let {
open,
entityType,
entityId,
entityDescription,
currentWaveServiceId,
waveProducts,
onSuccess,
onCancel
}: Props = $props();
let submitting = $state(false);
let error = $state('');
let searchQuery = $state('');
let selectedProductId = $state<string | null>(null);
// Reset state when modal opens
$effect(() => {
if (open) {
selectedProductId = currentWaveServiceId;
searchQuery = '';
error = '';
}
});
// Decode Wave ID to match backend storage format
function decodeWaveId(id: string): string {
return fromGlobalId(id);
}
// Filter products by search query
let filteredProducts = $derived.by(() => {
const active = waveProducts.filter((p) => !p.isArchived);
if (!searchQuery.trim()) return active;
const query = searchQuery.toLowerCase();
return active.filter((p) => {
const name = p.name.toLowerCase();
const description = p.description?.toLowerCase() ?? '';
return name.includes(query) || description.includes(query);
});
});
// Format currency
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
}
const updateRevenueStore = new UpdateRevenueStore();
const updateProjectStore = new UpdateProjectStore();
async function handleSave() {
submitting = true;
error = '';
try {
if (entityType === 'revenue') {
const res = await updateRevenueStore.mutate({
input: {
id: entityId,
waveServiceId: selectedProductId ?? ''
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
} else {
const res = await updateProjectStore.mutate({
input: {
id: entityId,
waveServiceId: selectedProductId ?? ''
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
}
onSuccess();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save';
} finally {
submitting = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onCancel();
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onCancel()}
>
<div
class="flex max-h-[80vh] w-full max-w-lg flex-col rounded-xl bg-theme shadow-xl"
onclick={(e) => e.stopPropagation()}
onkeydown={() => {}}
role="document"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<div>
<h2 class="text-lg font-semibold text-theme">Link Wave Product</h2>
<p class="mt-0.5 text-sm text-theme-muted">{entityDescription}</p>
</div>
<button
type="button"
onclick={onCancel}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Close"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="flex min-h-0 flex-1 flex-col p-6">
{#if error}
<div class="mb-4 alert-error">
{error}
</div>
{/if}
<!-- Search -->
<div class="mb-4">
<div class="relative">
<svg
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Search Wave products..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-4 pl-10 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<!-- Product List -->
<div class="min-h-0 flex-1 overflow-y-auto">
{#if filteredProducts.length === 0 && !searchQuery}
<div class="py-8 text-center text-theme-muted">No Wave products found.</div>
{:else if filteredProducts.length === 0}
<div class="py-8 text-center text-theme-muted">No products match your search.</div>
{:else}
<div class="space-y-2">
<!-- Option to unlink -->
<button
type="button"
onclick={() => (selectedProductId = null)}
class="w-full rounded-lg border p-3 text-left transition-colors {selectedProductId ===
null
? 'border-primary-500 bg-primary-500/10'
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
>
<span class="flex items-center gap-3">
<span
class="flex h-5 w-5 items-center justify-center rounded-full border-2 {selectedProductId ===
null
? 'border-primary-500'
: 'border-theme-muted'}"
>
{#if selectedProductId === null}
<div class="h-2.5 w-2.5 rounded-full bg-primary-500"></div>
{/if}
</span>
<span class="text-theme-muted italic">No product (unlink)</span>
</span>
</button>
{#each filteredProducts as product (product.id)}
{@const decodedId = decodeWaveId(product.id)}
<button
type="button"
onclick={() => (selectedProductId = decodedId)}
class="w-full rounded-lg border p-3 text-left transition-colors {selectedProductId ===
decodedId
? 'border-primary-500 bg-primary-500/10'
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
>
<span class="flex items-start gap-3">
<span
class="mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 {selectedProductId ===
decodedId
? 'border-primary-500'
: 'border-theme-muted'}"
>
{#if selectedProductId === decodedId}
<div class="h-2.5 w-2.5 rounded-full bg-primary-500"></div>
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="flex items-center justify-between gap-2">
<span class="font-medium text-theme">{product.name}</span>
<span
class="text-sm font-semibold text-secondary-600 dark:text-secondary-400"
>
{formatCurrency(product.unitPrice)}
</span>
</span>
{#if product.description}
<p class="mt-0.5 truncate text-sm text-theme-muted">
{product.description}
</p>
{/if}
</span>
</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Footer -->
<div class="flex items-center gap-3 border-t border-theme px-6 py-4">
<button
type="button"
onclick={handleSave}
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>Saving...</span>
</span>
{:else}
Save
{/if}
</button>
<button
type="button"
onclick={onCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,163 @@
<script lang="ts">
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { WaveProduct } from './types';
interface IncomeAccount {
id: string;
name: string;
subtype: { value: string };
}
interface Props {
product?: WaveProduct | null;
incomeAccounts?: IncomeAccount[];
onSuccess?: () => void;
}
let { product = null, incomeAccounts = [], onSuccess }: Props = $props();
let isSubmitting = $state(false);
let error = $state('');
let name = $state(product?.name ?? '');
let unitPrice = $state(product?.unitPrice?.toString() ?? '0');
let description = $state(product?.description ?? '');
// Default to existing product's income account, or find "Sales" account, or first available
function getDefaultAccountId() {
if (product?.incomeAccount?.id) return product.incomeAccount.id;
const salesAccount = incomeAccounts.find((a) => a.name === 'Sales');
if (salesAccount) return salesAccount.id;
return incomeAccounts[0]?.id ?? '';
}
let incomeAccountId = $state(getDefaultAccountId());
let isEditing = $derived(!!product);
</script>
<form
method="POST"
action={isEditing ? '?/updateProduct' : '?/createProduct'}
use:enhance={() => {
isSubmitting = true;
error = '';
return async ({ result }) => {
isSubmitting = false;
if (result.type === 'failure') {
const errorMsg = result.data?.error;
error = typeof errorMsg === 'string' ? errorMsg : 'An error occurred';
} else if (result.type === 'success') {
await invalidateAll();
offCanvas.closeRight();
onSuccess?.();
}
};
}}
class="flex h-full flex-col"
>
{#if isEditing}
<input type="hidden" name="id" value={product?.id} />
{/if}
<div class="flex-1 space-y-4">
{#if error}
<div class="rounded-lg border border-danger bg-danger p-3 text-sm text-danger">
{error}
</div>
{/if}
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Name <span class="text-error-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={name}
required
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="Product or service name"
/>
</div>
<div>
<label for="unitPrice" class="mb-1.5 block text-sm font-medium text-theme">
Unit Price
</label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
<input
type="number"
id="unitPrice"
name="unitPrice"
bind:value={unitPrice}
min="0"
step="0.01"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="0.00"
/>
</div>
</div>
<div>
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
Description
</label>
<textarea
id="description"
name="description"
bind:value={description}
rows="3"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
placeholder="Optional description"
></textarea>
</div>
{#if incomeAccounts.length > 0}
<div>
<label for="incomeAccountId" class="mb-1.5 block text-sm font-medium text-theme">
Income Account <span class="text-error-500">*</span>
</label>
<select
id="incomeAccountId"
name="incomeAccountId"
bind:value={incomeAccountId}
required
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
>
{#each incomeAccounts as account (account.id)}
<option value={account.id}>{account.name}</option>
{/each}
</select>
</div>
{/if}
</div>
<div class="mt-6 flex gap-3 border-t border-theme pt-4">
<button
type="button"
onclick={() => offCanvas.closeRight()}
disabled={isSubmitting}
class="flex-1 rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name}
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
<IconSpinner class="h-4 w-4" />
<span>{isEditing ? 'Saving...' : 'Creating...'}</span>
{:else}
{isEditing ? 'Save Changes' : 'Create Product'}
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,260 @@
<script lang="ts">
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import WaveProductForm from './WaveProductForm.svelte';
import type { WaveProduct } from './types';
interface IncomeAccount {
id: string;
name: string;
subtype: { value: string };
}
interface Props {
products: WaveProduct[];
archivedProducts: WaveProduct[];
incomeAccounts: IncomeAccount[];
onArchive: (product: WaveProduct) => void;
}
let { products, archivedProducts, incomeAccounts, onArchive }: Props = $props();
let searchQuery = $state('');
let showArchived = $state(false);
let activeProducts = $derived.by(() => {
const list = showArchived ? archivedProducts : products;
if (!searchQuery.trim()) return list;
const query = searchQuery.toLowerCase();
return list.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
(p.description && p.description.toLowerCase().includes(query))
);
});
function formatCurrency(value: number): string {
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function openCreateForm() {
offCanvas.showRight({
title: 'New Product/Service',
content: createFormContent
});
}
function openEditForm(product: WaveProduct) {
selectedProduct = product;
offCanvas.showRight({
title: 'Edit Product/Service',
content: editFormContent
});
}
let selectedProduct = $state<WaveProduct | null>(null);
</script>
{#snippet createFormContent()}
<WaveProductForm {incomeAccounts} />
{/snippet}
{#snippet editFormContent()}
<WaveProductForm product={selectedProduct} {incomeAccounts} />
{/snippet}
<div class="space-y-4">
<!-- Header with search, toggle, and add button -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
<div class="relative flex-1 sm:max-w-xs">
<svg
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Search products..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme-card py-2 pr-4 pl-10 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
/>
</div>
{#if archivedProducts.length > 0}
<label class="flex items-center gap-2 text-sm text-theme-secondary">
<input
type="checkbox"
bind:checked={showArchived}
class="h-4 w-4 rounded border-theme text-accent6-500 focus:ring-accent6-500"
/>
Show archived ({archivedProducts.length})
</label>
{/if}
</div>
{#if !showArchived}
<button
type="button"
onclick={openCreateForm}
class="flex items-center justify-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Product
</button>
{/if}
</div>
<!-- Products List -->
{#if activeProducts.length > 0}
<div class="grid grid-cols-1 gap-4">
{#each activeProducts as product (product.id)}
<div
class="flex min-w-0 items-start justify-between gap-4 rounded-lg border border-theme bg-theme-card p-4"
class:opacity-60={showArchived}
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium text-theme">{product.name}</h3>
{#if showArchived}
<span
class="rounded-full bg-gray-500/10 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400"
>
Archived
</span>
{/if}
{#if product.isSold}
<span
class="rounded-full bg-success-500/10 px-2 py-0.5 text-xs font-medium text-success-600 dark:text-success-400"
>
Sold
</span>
{/if}
{#if product.isBought}
<span
class="rounded-full bg-primary-500/10 px-2 py-0.5 text-xs font-medium text-primary-600 dark:text-primary-400"
>
Bought
</span>
{/if}
</div>
{#if product.description}
<p class="mt-1 line-clamp-2 text-sm text-theme-muted">{product.description}</p>
{/if}
<div class="mt-2 flex flex-wrap items-baseline gap-x-4 gap-y-1">
<p class="text-lg font-semibold text-accent6-600 dark:text-accent6-400">
{formatCurrency(product.unitPrice)}
</p>
{#if product.incomeAccount}
<span class="text-sm text-theme-muted">
{product.incomeAccount.name}
</span>
{/if}
</div>
</div>
{#if !showArchived}
<div class="flex flex-shrink-0 items-center gap-2">
<button
type="button"
onclick={() => openEditForm(product)}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Edit product"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={() => onArchive(product)}
class="rounded-lg p-2 text-theme-muted hover:bg-warning-500/10 hover:text-warning-600 dark:hover:text-warning-400"
aria-label="Archive product"
title="Archive product"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
</button>
</div>
{/if}
</div>
{/each}
</div>
{:else if searchQuery}
<div class="empty-state py-12">
<svg
class="empty-state-icon text-accent6-400"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h2 class="empty-state-title">No Products Found</h2>
<p class="empty-state-text">No products match your search criteria.</p>
</div>
{:else}
<div class="empty-state py-12">
<svg
class="empty-state-icon text-accent6-400"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<h2 class="empty-state-title">No Products</h2>
<p class="empty-state-text">Get started by creating your first product or service.</p>
<button
type="button"
onclick={openCreateForm}
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Product
</button>
</div>
{/if}
</div>

View File

@ -0,0 +1,129 @@
<script lang="ts">
export type WaveTab = 'invoices' | 'products' | 'customers';
interface Props {
activeTab: WaveTab;
invoiceCount: number;
productCount: number;
customerCount: number;
onTabChange: (tab: WaveTab) => void;
}
let { activeTab, invoiceCount, productCount, customerCount, onTabChange }: Props = $props();
const tabs: { id: WaveTab; label: string; icon: string }[] = [
{ id: 'invoices', label: 'Invoices', icon: 'invoice' },
{ id: 'products', label: 'Products/Services', icon: 'product' },
{ id: 'customers', label: 'Customers', icon: 'customer' }
];
// Mobile tab navigation
let currentTabIndex = $derived(tabs.findIndex((t) => t.id === activeTab));
function goToPrevTab() {
const newIndex = currentTabIndex > 0 ? currentTabIndex - 1 : tabs.length - 1;
onTabChange(tabs[newIndex].id);
}
function goToNextTab() {
const newIndex = currentTabIndex < tabs.length - 1 ? currentTabIndex + 1 : 0;
onTabChange(tabs[newIndex].id);
}
function getCountForTab(tabId: WaveTab): number {
switch (tabId) {
case 'invoices':
return invoiceCount;
case 'products':
return productCount;
case 'customers':
return customerCount;
default:
return 0;
}
}
</script>
<!-- Mobile: Carousel style -->
<div class="mb-6 md:hidden">
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-card p-2">
<button
type="button"
onclick={goToPrevTab}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Previous tab"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex items-center gap-2">
<span class="font-medium text-theme">{tabs[currentTabIndex].label}</span>
<span
class="rounded-full bg-accent6-500/20 px-2 py-0.5 text-xs font-medium text-accent6-600 dark:text-accent6-400"
>
{getCountForTab(tabs[currentTabIndex].id)}
</span>
</div>
<button
type="button"
onclick={goToNextTab}
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Next tab"
>
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Tab indicators -->
<div class="mt-2 flex justify-center gap-1.5">
{#each tabs as tab, i (tab.id)}
<button
type="button"
onclick={() => onTabChange(tab.id)}
class="h-1.5 rounded-full transition-all {i === currentTabIndex
? 'w-4 bg-accent6-500'
: 'w-1.5 bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500'}"
aria-label={tab.label}
></button>
{/each}
</div>
</div>
<!-- Desktop: Horizontal tabs -->
<div class="mb-6 hidden md:block">
<div class="border-b border-theme">
<nav class="flex gap-1" aria-label="Wave tabs">
{#each tabs as tab (tab.id)}
<button
type="button"
onclick={() => onTabChange(tab.id)}
class="relative px-4 py-3 text-sm font-medium transition-colors
{activeTab === tab.id
? 'text-accent6-600 dark:text-accent6-400'
: 'text-theme-muted hover:text-theme'}"
>
<span class="flex items-center gap-2">
{tab.label}
<span
class="rounded-full px-2 py-0.5 text-xs font-medium
{activeTab === tab.id
? 'bg-accent6-500/20 text-accent6-600 dark:text-accent6-400'
: 'bg-theme-secondary text-theme-muted'}"
>
{getCountForTab(tab.id)}
</span>
</span>
{#if activeTab === tab.id}
<span class="absolute right-0 bottom-0 left-0 h-0.5 bg-accent6-500"></span>
{/if}
</button>
{/each}
</nav>
</div>
</div>

View File

@ -0,0 +1,356 @@
<script lang="ts">
import type { NexusRevenue, NexusProject, WaveProduct } from './types';
import { fromGlobalId } from '$lib/utils/relay';
interface CustomerInfo {
customerName: string;
waveCustomerId: string | null;
}
interface Account {
id: string;
name: string;
}
interface Props {
customer: CustomerInfo;
revenues: NexusRevenue[];
projects: NexusProject[];
waveProducts: WaveProduct[];
accounts: Account[];
onLinkCustomer: () => void;
onLinkRevenue: (revenueId: string, accountName: string) => void;
onLinkProject: (projectId: string, projectName: string) => void;
onCreateInvoice: () => void;
}
let {
customer,
revenues,
projects,
waveProducts,
accounts,
onLinkCustomer,
onLinkRevenue,
onLinkProject,
onCreateInvoice
}: Props = $props();
// Validation checks
let customerLinked = $derived(!!customer.waveCustomerId);
let unlinkedRevenues = $derived(revenues.filter((r) => !r.waveServiceId));
let unlinkedProjects = $derived(projects.filter((p) => !p.waveServiceId));
let linkedRevenues = $derived(revenues.filter((r) => r.waveServiceId));
let linkedProjects = $derived(projects.filter((p) => p.waveServiceId));
let hasLinkedItems = $derived(linkedRevenues.length > 0 || linkedProjects.length > 0);
let totalErrors = $derived(
(customerLinked ? 0 : 1) + unlinkedRevenues.length + unlinkedProjects.length
);
let isValid = $derived(totalErrors === 0 && hasLinkedItems);
// Get product name for a linked item (waveServiceId is stored as decoded UUID)
function getProductName(waveServiceId: string | null): string {
if (!waveServiceId) return 'Not linked';
const product = waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId);
return product?.name ?? 'Unknown product';
}
// Get account name for revenue (accountId is UUID, account.id is GlobalID)
function getAccountName(accountId: string): string {
const account = accounts.find((a) => fromGlobalId(a.id) === accountId);
return account?.name ?? 'Unknown Account';
}
// Format date
function formatShortDate(dateStr: string | null): string {
if (!dateStr) return '';
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
// Format currency
function formatCurrency(value: string | number): string {
const num = typeof value === 'string' ? parseFloat(value.replace(/,/g, '')) : value;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(num);
}
</script>
<div class="space-y-6">
<!-- Status Summary -->
<div class="card-padded">
{#if isValid}
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-success-500/20">
<svg
class="h-5 w-5 text-success-600 dark:text-success-400"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<h3 class="font-medium text-theme">Ready to create Wave invoice</h3>
<p class="text-sm text-theme-muted">
All items are linked to Wave. Click below to create the invoice.
</p>
</div>
</div>
<button
type="button"
onclick={onCreateInvoice}
class="mt-4 w-full rounded-lg bg-accent6-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent6-600"
>
Create Wave Invoice
</button>
{:else if !hasLinkedItems && customerLinked && totalErrors === 0}
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-warning-500/20">
<svg
class="h-5 w-5 text-warning-600 dark:text-warning-400"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h3 class="font-medium text-theme">No line items</h3>
<p class="text-sm text-theme-muted">
Add at least one linked revenue or project to create the invoice.
</p>
</div>
</div>
{:else}
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-warning-500/20">
<svg
class="h-5 w-5 text-warning-600 dark:text-warning-400"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h3 class="font-medium text-theme">
{totalErrors} item{totalErrors !== 1 ? 's' : ''} need{totalErrors === 1 ? 's' : ''} linking
</h3>
<p class="text-sm text-theme-muted">
Link all items to Wave products before creating the invoice.
</p>
</div>
</div>
{/if}
</div>
<!-- Customer Section -->
<div class="card-padded">
<h3 class="mb-4 text-sm font-medium text-theme-muted">Customer</h3>
<div
class="rounded-lg border p-4 {customerLinked
? 'border-success-200 bg-success-50 dark:border-success-800 dark:bg-success-900/20'
: 'border-warning-200 bg-warning-50 dark:border-warning-800 dark:bg-warning-900/20'}"
>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-theme">{customer.customerName}</span>
{#if customerLinked}
<span class="badge-success">Linked</span>
{:else}
<span class="badge-warning">Not Linked</span>
{/if}
</div>
{#if customerLinked}
<p class="mt-1 truncate text-sm text-theme-muted">
Wave Customer ID: {customer.waveCustomerId}
</p>
{:else}
<p class="mt-1 text-sm text-warning-600 dark:text-warning-400">
Link this customer to a Wave customer to continue.
</p>
{/if}
</div>
<button
type="button"
onclick={onLinkCustomer}
class="shrink-0 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {customerLinked
? 'bg-theme text-theme-secondary hover:bg-black/5 dark:hover:bg-white/10'
: 'bg-warning-500 text-white hover:bg-warning-600'}"
>
{customerLinked ? 'Change' : 'Link'}
</button>
</div>
</div>
</div>
<!-- Revenues Section -->
<div class="card-padded">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-sm font-medium text-theme-muted">Revenues</h3>
{#if unlinkedRevenues.length > 0}
<span
class="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-600 dark:text-warning-400"
>
{unlinkedRevenues.length} unlinked
</span>
{:else if revenues.length > 0}
<span
class="rounded-full bg-success-500/20 px-2 py-0.5 text-xs font-medium text-success-600 dark:text-success-400"
>
All linked
</span>
{/if}
</div>
{#if revenues.length === 0}
<p class="text-sm text-theme-muted italic">No revenues on this invoice.</p>
{:else}
<div class="space-y-2">
{#each revenues as revenue (revenue.id)}
{@const accountName = getAccountName(revenue.accountId)}
{@const isLinked = !!revenue.waveServiceId}
<div
class="rounded-lg border p-3 {isLinked
? 'border-theme bg-theme-secondary'
: 'border-warning-200 bg-warning-50 dark:border-warning-800 dark:bg-warning-900/20'}"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-theme">{accountName}</span>
{#if isLinked}
<span class="badge-success">Linked</span>
{:else}
<span class="badge-warning">Not Linked</span>
{/if}
</div>
<p class="text-xs text-theme-muted">
{formatShortDate(revenue.startDate)}
{#if revenue.endDate}
- {formatShortDate(revenue.endDate)}
{:else}
- Ongoing
{/if}
</p>
{#if isLinked}
<p class="mt-1 text-xs text-theme-muted">
Product: {getProductName(revenue.waveServiceId)}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-secondary-600 dark:text-secondary-400">
{formatCurrency(revenue.amount)}
</span>
<button
type="button"
onclick={() => onLinkRevenue(revenue.id, accountName)}
class="rounded px-2 py-1 text-xs font-medium transition-colors {isLinked
? 'text-theme-muted hover:bg-black/5 dark:hover:bg-white/10'
: 'bg-warning-500 text-white hover:bg-warning-600'}"
>
{isLinked ? 'Change' : 'Link'}
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Projects Section -->
<div class="card-padded">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-sm font-medium text-theme-muted">Projects</h3>
{#if unlinkedProjects.length > 0}
<span
class="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-600 dark:text-warning-400"
>
{unlinkedProjects.length} unlinked
</span>
{:else if projects.length > 0}
<span
class="rounded-full bg-success-500/20 px-2 py-0.5 text-xs font-medium text-success-600 dark:text-success-400"
>
All linked
</span>
{/if}
</div>
{#if projects.length === 0}
<p class="text-sm text-theme-muted italic">No projects on this invoice.</p>
{:else}
<div class="space-y-2">
{#each projects as project (project.id)}
{@const isLinked = !!project.waveServiceId}
<div
class="rounded-lg border p-3 {isLinked
? 'border-theme bg-theme-secondary'
: 'border-warning-200 bg-warning-50 dark:border-warning-800 dark:bg-warning-900/20'}"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-theme">{project.name}</span>
{#if isLinked}
<span class="badge-success">Linked</span>
{:else}
<span class="badge-warning">Not Linked</span>
{/if}
</div>
<p class="text-xs text-theme-muted">{formatShortDate(project.date)}</p>
{#if isLinked}
<p class="mt-1 text-xs text-theme-muted">
Product: {getProductName(project.waveServiceId)}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-accent-600 dark:text-accent-400">
{formatCurrency(project.amount)}
</span>
<button
type="button"
onclick={() => onLinkProject(project.id, project.name)}
class="rounded px-2 py-1 text-xs font-medium transition-colors {isLinked
? 'text-theme-muted hover:bg-black/5 dark:hover:bg-white/10'
: 'bg-warning-500 text-white hover:bg-warning-600'}"
>
{isLinked ? 'Change' : 'Link'}
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,146 @@
// Wave types (duplicated from page.server.ts since we can't import from there)
export interface WaveAddress {
addressLine1: string | null;
addressLine2: string | null;
city: string | null;
province: { code: string; name: string } | null;
country: { code: string; name: string } | null;
postalCode: string | null;
}
export interface WaveCustomer {
id: string;
name: string;
firstName: string | null;
lastName: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
address: WaveAddress | null;
currency: { code: string; symbol: string } | null;
internalNotes: string | null;
outstandingAmount: { value: string; currency: { code: string } };
overdueAmount: { value: string; currency: { code: string } };
isArchived: boolean | null;
createdAt: string;
modifiedAt: string;
}
// Base WaveProduct with required fields - used across all pages
export interface WaveProduct {
id: string;
name: string;
description: string | null;
unitPrice: number;
isArchived: boolean;
// Optional extended fields - available on Wave admin page
isSold?: boolean;
isBought?: boolean;
incomeAccount?: { id: string; name: string } | null;
expenseAccount?: { id: string; name: string } | null;
defaultSalesTaxes?: Array<{ id: string; name: string; abbreviation: string }>;
createdAt?: string;
modifiedAt?: string;
}
export interface WaveInvoiceItem {
product: { id: string; name: string };
description: string | null;
quantity: number;
unitPrice: number;
subtotal: { value: string };
total: { value: string };
}
export interface WaveInvoice {
id: string;
invoiceNumber: string;
invoiceDate: string;
dueDate: string;
status: string;
title: string;
subhead: string | null;
poNumber: string | null;
memo: string | null;
footer: string | null;
pdfUrl: string;
viewUrl: string;
customer: { id: string; name: string; email: string | null };
items: WaveInvoiceItem[];
subtotal: { value: string; currency: { code: string; symbol: string } };
taxTotal: { value: string; currency: { code: string; symbol: string } };
discountTotal: { value: string; currency: { code: string; symbol: string } };
total: { value: string; currency: { code: string; symbol: string } };
amountDue: { value: string; currency: { code: string; symbol: string } };
amountPaid: { value: string; currency: { code: string; symbol: string } };
lastSentAt: string | null;
lastSentVia: string | null;
lastViewedAt: string | null;
}
// Types for revenue and project data from Nexus invoice
export interface NexusRevenue {
id: string;
accountId: string;
amount: string | number;
startDate: string;
endDate: string | null;
waveServiceId: string | null;
}
export interface NexusProject {
id: string;
name: string;
date: string;
amount: string | number;
customerId: string;
accountAddressId: string | null;
waveServiceId: string | null;
}
export interface NexusInvoice {
id: string;
date: string;
customerId: string;
status: string;
datePaid: string | null;
paymentType: string | null;
waveInvoiceId: string | null;
revenues: NexusRevenue[];
projects: NexusProject[];
}
// Types for the invoice form
export interface WaveLineItem {
sourceType: 'revenue' | 'project' | 'additional';
sourceId: string;
productId: string;
productName: string;
description: string;
quantity: number;
unitPrice: number;
}
export interface WaveDiscount {
type: 'PERCENTAGE' | 'FIXED';
name: string;
value: number; // percentage (0-100) or fixed amount
}
export interface WaveInvoiceFormData {
invoiceDate: string;
dueDate: string;
memo: string;
footer: string;
items: WaveLineItem[];
discount?: WaveDiscount;
}
// Validation types
export interface ValidationIssue {
type: 'customer' | 'revenue' | 'project';
id: string;
description: string;
}
export type InvoiceTab = 'details' | 'wave';

View File

@ -0,0 +1,95 @@
<script lang="ts">
interface Props {
selected: string[];
}
let { selected = $bindable([]) }: Props = $props();
const channels = [
{ value: 'IN_APP', label: 'In-App', description: 'Notifications appear in the app' },
{ value: 'EMAIL', label: 'Email', description: 'Send email notifications' },
{ value: 'SMS', label: 'SMS', description: 'Send text message notifications' }
];
function toggle(value: string) {
if (selected.includes(value)) {
selected = selected.filter((v) => v !== value);
} else {
selected = [...selected, value];
}
}
</script>
<div>
<span class="mb-2 block text-sm font-medium text-theme">
Notification Channels <span class="text-error-500">*</span>
</span>
<div class="flex flex-wrap gap-3">
{#each channels as channel (channel.value)}
<button
type="button"
onclick={() => toggle(channel.value)}
class="flex items-center gap-2 rounded-lg border px-4 py-2.5 text-left transition-colors {selected.includes(
channel.value
)
? 'border-primary-500 bg-primary-500/10 text-primary-600 dark:text-primary-400'
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
>
<span class="flex h-5 w-5 items-center justify-center">
{#if channel.value === 'IN_APP'}
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{:else if channel.value === 'EMAIL'}
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
{:else}
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
{/if}
</span>
<span class="flex flex-col">
<span class="text-sm font-medium">{channel.label}</span>
<span class="text-xs text-theme-muted">{channel.description}</span>
</span>
{#if selected.includes(channel.value)}
<svg
class="ml-2 h-5 w-5 text-primary-500"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
{/each}
</div>
{#if selected.length === 0}
<p class="mt-2 text-xs text-error-500">Please select at least one channel</p>
{/if}
</div>

View File

@ -0,0 +1,241 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { EVENT_TYPE_CATEGORIES } from '$lib/data/eventTypes';
interface Props {
selected: string[];
}
let { selected = $bindable([]) }: Props = $props();
let searchFilter = $state('');
let selectedSet = new SvelteSet<string>(selected);
let expandedCategories = new SvelteSet<string>();
// Sync external changes to internal set
$effect(() => {
// Only sync if the arrays are actually different
const currentValues = [...selectedSet];
const isSame =
currentValues.length === selected.length && currentValues.every((v) => selected.includes(v));
if (!isSame) {
selectedSet.clear();
for (const v of selected) {
selectedSet.add(v);
}
}
});
// Sync internal set back to parent
$effect(() => {
const newSelected = [...selectedSet];
// Only update if actually changed to avoid infinite loops
if (newSelected.length !== selected.length || !newSelected.every((v) => selected.includes(v))) {
selected = newSelected;
}
});
// Filter categories by search
let filteredCategories = $derived(
EVENT_TYPE_CATEGORIES.map((cat) => ({
...cat,
types: cat.types.filter(
(t) =>
t.label.toLowerCase().includes(searchFilter.toLowerCase()) ||
t.value.toLowerCase().includes(searchFilter.toLowerCase())
)
})).filter((cat) => cat.types.length > 0)
);
function toggle(value: string) {
if (selectedSet.has(value)) {
selectedSet.delete(value);
} else {
selectedSet.add(value);
}
}
function selectAllInCategory(categoryName: string) {
const category = filteredCategories.find((c) => c.name === categoryName);
if (category) {
for (const type of category.types) {
selectedSet.add(type.value);
}
}
}
function selectNoneInCategory(categoryName: string) {
const category = filteredCategories.find((c) => c.name === categoryName);
if (category) {
for (const type of category.types) {
selectedSet.delete(type.value);
}
}
}
function selectAll() {
for (const cat of filteredCategories) {
for (const type of cat.types) {
selectedSet.add(type.value);
}
}
}
function selectNone() {
selectedSet.clear();
}
function toggleCategory(categoryName: string) {
if (expandedCategories.has(categoryName)) {
expandedCategories.delete(categoryName);
} else {
expandedCategories.add(categoryName);
}
}
function getSelectedCountInCategory(categoryName: string): number {
const category = EVENT_TYPE_CATEGORIES.find((c) => c.name === categoryName);
if (!category) return 0;
return category.types.filter((t) => selectedSet.has(t.value)).length;
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-theme">
Event Types <span class="text-error-500">*</span>
<span class="ml-1 text-primary-500">({selectedSet.size} selected)</span>
</span>
<div class="flex gap-3">
<button
type="button"
onclick={selectAll}
class="text-xs font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Select All
</button>
<button
type="button"
onclick={selectNone}
class="text-xs font-medium text-theme-muted hover:underline"
>
Clear
</button>
</div>
</div>
<!-- Search -->
<div class="relative">
<svg
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={searchFilter}
placeholder="Search event types..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-9 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<!-- Category Groups -->
<div class="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-theme bg-theme-card">
{#each filteredCategories as category (category.name)}
{@const selectedCount = getSelectedCountInCategory(category.name)}
{@const totalCount = category.types.length}
{@const isExpanded = expandedCategories.has(category.name)}
<div class="border-b border-theme last:border-b-0">
<!-- Category Header -->
<button
type="button"
onclick={() => toggleCategory(category.name)}
class="flex w-full items-center justify-between interactive px-3 py-2.5 text-left"
>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4 text-theme-muted transition-transform {isExpanded ? 'rotate-90' : ''}"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span class="text-sm font-medium text-theme">{category.label}</span>
</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold {selectedCount > 0
? 'bg-primary-500 text-white'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
>
{selectedCount}/{totalCount}
</span>
</button>
<!-- Category Content -->
{#if isExpanded}
<div class="border-t border-theme bg-black/5 dark:bg-white/5">
<!-- Category Actions -->
<div class="flex items-center gap-4 px-3 py-1.5">
<button
type="button"
onclick={() => selectAllInCategory(category.name)}
class="text-xs font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Select All
</button>
<button
type="button"
onclick={() => selectNoneInCategory(category.name)}
class="text-xs font-medium text-theme-muted hover:underline"
>
Select None
</button>
</div>
<!-- Event Types -->
<div class="space-y-0.5 px-3 pb-2">
{#each category.types as eventType (eventType.value)}
<label
class="flex cursor-pointer items-center gap-2 rounded interactive px-2 py-1.5"
>
<input
type="checkbox"
checked={selectedSet.has(eventType.value)}
onchange={() => toggle(eventType.value)}
class="border-theme-muted h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
/>
<span class="text-sm text-theme">{eventType.label}</span>
</label>
{/each}
</div>
</div>
{/if}
</div>
{/each}
{#if filteredCategories.length === 0}
<p class="px-3 py-4 text-center text-sm text-theme-muted italic">
No event types match "{searchFilter}"
</p>
{/if}
</div>
{#if selectedSet.size === 0}
<p class="text-xs text-error-500">Please select at least one event type</p>
{/if}
</div>

View File

@ -0,0 +1,396 @@
<script lang="ts">
import {
CreateNotificationRuleStore,
UpdateNotificationRuleStore,
type EventTypeChoices$options,
type NotificationChannelChoices$options,
type RoleChoices$options
} from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import EventTypeSelector from './EventTypeSelector.svelte';
import ChannelSelector from './ChannelSelector.svelte';
import TargetSelector from './TargetSelector.svelte';
import TemplateEditor from './TemplateEditor.svelte';
import type { AdminNotificationRules$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type NotificationRule = AdminNotificationRules$result['notificationRules'][number];
interface TeamProfile {
id: string;
fullName: string;
email?: string | null;
role: string;
}
interface CustomerProfile {
id: string;
fullName: string;
email?: string | null;
}
interface Props {
rule?: NotificationRule | null;
teamProfiles: TeamProfile[];
customerProfiles: CustomerProfile[];
onSuccess?: () => void;
onCancel?: () => void;
}
let { rule = null, teamProfiles, customerProfiles, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!rule);
let submitting = $state(false);
let error = $state('');
// Form fields
let name = $state(rule?.name ?? '');
let description = $state(rule?.description ?? '');
let eventTypes = $state<string[]>(rule?.eventTypes ?? []);
let channels = $state<string[]>(rule?.channels ?? ['IN_APP']);
let targetRoles = $state<string[]>(rule?.targetRoles ?? []);
let targetTeamProfileIds = $state<string[]>(rule?.targetTeamProfileIds ?? []);
let targetCustomerProfileIds = $state<string[]>(rule?.targetCustomerProfileIds ?? []);
let templateSubject = $state(rule?.templateSubject ?? '');
let templateBody = $state(rule?.templateBody ?? '');
let conditions = $state<string>(rule?.conditions ? JSON.stringify(rule.conditions, null, 2) : '');
let isActive = $state(rule?.isActive ?? true);
let showAdvanced = $state(false);
let conditionsError = $state('');
const createStore = new CreateNotificationRuleStore();
const updateStore = new UpdateNotificationRuleStore();
const conditionsExample = '{"status": "COMPLETED"}';
const roleOptions = [
{ value: 'ADMIN', label: 'Admin' },
{ value: 'TEAM_LEADER', label: 'Team Leader' },
{ value: 'TEAM_MEMBER', label: 'Team Member' }
];
function toggleRole(role: string) {
if (targetRoles.includes(role)) {
targetRoles = targetRoles.filter((r) => r !== role);
} else {
targetRoles = [...targetRoles, role];
}
}
function parseConditions(): Record<string, unknown> | null {
if (!conditions.trim()) return null;
try {
const parsed = JSON.parse(conditions);
conditionsError = '';
return parsed;
} catch {
conditionsError = 'Invalid JSON';
return null;
}
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
// Validation
if (!name.trim()) {
error = 'Please provide a name for the rule';
return;
}
if (eventTypes.length === 0) {
error = 'Please select at least one event type';
return;
}
if (channels.length === 0) {
error = 'Please select at least one notification channel';
return;
}
// Parse conditions if provided
let parsedConditions: Record<string, unknown> | null = null;
if (conditions.trim()) {
parsedConditions = parseConditions();
if (conditionsError) {
error = 'Invalid JSON in conditions field';
return;
}
}
if (isEdit && rule) {
const res = await updateStore.mutate({
input: {
id: rule.id,
name: name.trim(),
description: description.trim() || null,
eventTypes: eventTypes as EventTypeChoices$options[],
channels: channels as NotificationChannelChoices$options[],
targetRoles: targetRoles.length > 0 ? (targetRoles as RoleChoices$options[]) : null,
targetTeamProfileIds: targetTeamProfileIds.length > 0 ? targetTeamProfileIds : null,
targetCustomerProfileIds:
targetCustomerProfileIds.length > 0 ? targetCustomerProfileIds : null,
templateSubject: templateSubject.trim() || null,
templateBody: templateBody.trim() || null,
conditions: parsedConditions,
isActive
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: {
name: name.trim(),
description: description.trim() || '',
eventTypes: eventTypes as EventTypeChoices$options[],
channels: channels as NotificationChannelChoices$options[],
targetRoles: targetRoles.length > 0 ? (targetRoles as RoleChoices$options[]) : [],
targetTeamProfileIds: targetTeamProfileIds.length > 0 ? targetTeamProfileIds : [],
targetCustomerProfileIds:
targetCustomerProfileIds.length > 0 ? targetCustomerProfileIds : [],
templateSubject: templateSubject.trim() || '',
templateBody: templateBody.trim() || '',
conditions: parsedConditions,
isActive
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save notification rule';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<!-- Basic Info -->
<div class="space-y-4">
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">Basic Info</h3>
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Name <span class="text-error-500">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="e.g., Service Completion Alert"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
Description
</label>
<textarea
id="description"
bind:value={description}
disabled={submitting}
rows={2}
placeholder="Describe when this rule triggers and what it does"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<!-- Active Toggle -->
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={isActive}
disabled={submitting}
class="border-theme-muted h-5 w-5 rounded text-primary-500 focus:ring-primary-500"
/>
<span class="text-sm font-medium text-theme">Active</span>
<span class="text-xs text-theme-muted">
{isActive ? 'This rule will trigger notifications' : 'This rule is disabled'}
</span>
</label>
</div>
<hr class="border-theme" />
<!-- Event Types -->
<div class="space-y-4">
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">
Trigger Events
</h3>
<EventTypeSelector bind:selected={eventTypes} />
</div>
<hr class="border-theme" />
<!-- Channels -->
<div class="space-y-4">
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">
Delivery Channels
</h3>
<ChannelSelector bind:selected={channels} />
</div>
<hr class="border-theme" />
<!-- Targets -->
<div class="space-y-4">
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">Recipients</h3>
<!-- Target by Role -->
<div>
<span class="mb-2 block text-sm font-medium text-theme">Target by Role</span>
<p class="mb-2 text-xs text-theme-muted">
Leave empty to notify all roles. Select specific roles to limit recipients.
</p>
<div class="flex flex-wrap gap-2">
{#each roleOptions as role (role.value)}
<button
type="button"
onclick={() => toggleRole(role.value)}
disabled={submitting}
class="rounded-lg border px-3 py-1.5 text-sm transition-colors {targetRoles.includes(
role.value
)
? 'border-primary-500 bg-primary-500/10 text-primary-600 dark:text-primary-400'
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
>
{role.label}
</button>
{/each}
</div>
{#if targetRoles.length === 0}
<p class="mt-2 text-xs text-success-600 dark:text-success-400">
All team members will receive notifications
</p>
{/if}
</div>
<!-- Specific Team Members -->
<TargetSelector
label="Specific Team Members"
profiles={teamProfiles}
bind:selectedIds={targetTeamProfileIds}
placeholder="Search team members..."
/>
<!-- Specific Customers -->
<TargetSelector
label="Specific Customers"
profiles={customerProfiles}
bind:selectedIds={targetCustomerProfileIds}
placeholder="Search customers..."
/>
</div>
<hr class="border-theme" />
<!-- Template -->
<div class="space-y-4">
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">
Message Template
</h3>
<TemplateEditor
bind:subject={templateSubject}
bind:body={templateBody}
selectedEventTypes={eventTypes}
/>
</div>
<!-- Advanced Options -->
<div>
<button
type="button"
onclick={() => (showAdvanced = !showAdvanced)}
class="flex items-center gap-2 text-sm font-medium text-theme-secondary hover:text-theme"
>
<svg
class="h-4 w-4 transition-transform {showAdvanced ? 'rotate-90' : ''}"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Advanced Options
</button>
{#if showAdvanced}
<div class="mt-4 space-y-4">
<div>
<label for="conditions" class="mb-1.5 block text-sm font-medium text-theme">
Conditions (JSON)
</label>
<p class="mb-2 text-xs text-theme-muted">
Add JSON conditions to filter events. Example: {conditionsExample}
</p>
<textarea
id="conditions"
bind:value={conditions}
oninput={() => parseConditions()}
disabled={submitting}
rows={3}
placeholder={conditionsExample}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 font-mono text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
{#if conditionsError}
<p class="mt-1 text-xs text-error-500">{conditionsError}</p>
{/if}
</div>
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Rule'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,205 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
interface Profile {
id: string;
fullName: string;
email?: string | null;
role?: string;
}
interface Props {
label: string;
profiles: Profile[];
selectedIds: string[];
placeholder?: string;
}
let { label, profiles, selectedIds = $bindable([]), placeholder = 'Search...' }: Props = $props();
let searchQuery = $state('');
let expanded = $state(false);
let selectedSet = new SvelteSet<string>(selectedIds);
// Sync external changes to internal set
$effect(() => {
const currentValues = [...selectedSet];
const isSame =
currentValues.length === selectedIds.length &&
currentValues.every((v) => selectedIds.includes(v));
if (!isSame) {
selectedSet.clear();
for (const v of selectedIds) {
selectedSet.add(v);
}
}
});
// Sync internal set back to parent
$effect(() => {
const newSelected = [...selectedSet];
if (
newSelected.length !== selectedIds.length ||
!newSelected.every((v) => selectedIds.includes(v))
) {
selectedIds = newSelected;
}
});
let filteredProfiles = $derived(
profiles.filter(
(p) =>
p.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
(p.email?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false)
)
);
function toggle(id: string) {
if (selectedSet.has(id)) {
selectedSet.delete(id);
} else {
selectedSet.add(id);
}
}
function selectAll() {
for (const profile of filteredProfiles) {
selectedSet.add(profile.id);
}
}
function selectNone() {
selectedSet.clear();
}
function getSelectedNames(): string {
if (selectedSet.size === 0) return 'None selected';
if (selectedSet.size <= 2) {
return profiles
.filter((p) => selectedSet.has(p.id))
.map((p) => p.fullName)
.join(', ');
}
return `${selectedSet.size} selected`;
}
</script>
<div class="space-y-2">
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border interactive px-4 py-2.5 text-left {selectedSet.size >
0
? 'border-primary-500 bg-primary-500/5'
: 'border-theme bg-theme'}"
onclick={() => (expanded = !expanded)}
>
<span class="flex flex-col">
<span class="flex items-center gap-2">
<span class="text-sm font-medium text-theme">{label}</span>
{#if selectedSet.size > 0}
<span
class="inline-flex items-center rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-medium text-white"
>
{selectedSet.size}
</span>
{/if}
</span>
<span
class="text-xs {selectedSet.size > 0
? 'text-primary-600 dark:text-primary-400'
: 'text-theme-muted'}">{getSelectedNames()}</span
>
</span>
<svg
class="h-5 w-5 text-theme-muted transition-transform {expanded ? 'rotate-180' : ''}"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if expanded}
<div class="rounded-lg border border-theme bg-theme-card">
<!-- Search -->
<div class="border-b border-theme p-3">
<div class="relative">
<svg
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={searchQuery}
{placeholder}
class="placeholder-theme-muted w-full rounded-md border border-theme bg-theme py-1.5 pr-3 pl-9 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<!-- Select All / None -->
<div class="flex items-center gap-4 border-b border-theme px-3 py-2">
<button
type="button"
onclick={selectAll}
class="text-xs font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Select All
</button>
<button
type="button"
onclick={selectNone}
class="text-xs font-medium text-theme-muted hover:underline"
>
Select None
</button>
<span class="ml-auto text-xs text-theme-muted">{selectedSet.size} selected</span>
</div>
<!-- List -->
<div class="max-h-48 overflow-y-auto">
{#if filteredProfiles.length === 0}
<p class="px-3 py-4 text-center text-sm text-theme-muted italic">No profiles found</p>
{:else}
{#each filteredProfiles as profile (profile.id)}
<label class="flex cursor-pointer items-center gap-3 interactive px-3 py-2">
<input
type="checkbox"
checked={selectedSet.has(profile.id)}
onchange={() => toggle(profile.id)}
class="border-theme-muted h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
/>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-theme">{profile.fullName}</span>
{#if profile.email}
<span class="block truncate text-xs text-theme-muted">{profile.email}</span>
{/if}
</span>
{#if profile.role}
<span
class="flex-shrink-0 rounded-full bg-gray-500/10 px-2 py-0.5 text-xs text-theme-muted"
>
{profile.role === 'ADMIN'
? 'Admin'
: profile.role === 'TEAM_LEADER'
? 'Team Leader'
: 'Member'}
</span>
{/if}
</label>
{/each}
{/if}
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,113 @@
<script lang="ts">
import {
UNIVERSAL_FIELDS,
getActiveDomainsForEvents,
type MetadataDomain
} from '$lib/data/notificationMetadata';
interface Props {
subject: string;
body: string;
selectedEventTypes?: string[];
}
let { subject = $bindable(''), body = $bindable(''), selectedEventTypes = [] }: Props = $props();
let showVariableHints = $state(false);
// Compute active domains based on selected event types
let activeDomains = $derived<MetadataDomain[]>(getActiveDomainsForEvents(selectedEventTypes));
const subjectPlaceholder = 'e.g., New {event_type} notification';
const bodyPlaceholder = 'e.g., A new {event_type} event occurred for {entity_type} {entity_id}.';
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-theme">Message Template</h3>
<button
type="button"
onclick={() => (showVariableHints = !showVariableHints)}
class="text-xs text-primary-600 hover:underline dark:text-primary-400"
>
{showVariableHints ? 'Hide' : 'Show'} Variables
</button>
</div>
{#if showVariableHints}
<div class="space-y-3 rounded-lg border border-theme bg-theme-card p-3">
<!-- Universal fields -->
<div>
<p class="mb-2 text-xs font-medium text-theme">Universal Variables:</p>
<div class="flex flex-wrap gap-2">
{#each UNIVERSAL_FIELDS as field (field.name)}
<span class="group relative">
<code
class="cursor-help rounded bg-primary-500/10 px-1.5 py-0.5 text-xs text-primary-600 dark:text-primary-400"
>
{`{${field.name}}`}
</code>
<span
class="absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white group-hover:block"
>
{field.description}
</span>
</span>
{/each}
</div>
</div>
<!-- Domain-specific fields -->
{#if activeDomains.length > 0}
{#each activeDomains as domain (domain.name)}
<div>
<p class="mb-2 text-xs font-medium text-theme">{domain.label} Fields:</p>
<div class="flex flex-wrap gap-2">
{#each domain.fields as field (field.name)}
<span class="group relative">
<code
class="cursor-help rounded bg-secondary-500/10 px-1.5 py-0.5 text-xs text-secondary-600 dark:text-secondary-400"
>
{`{${field.name}}`}
</code>
<span
class="absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white group-hover:block"
>
{field.description}
</span>
</span>
{/each}
</div>
</div>
{/each}
{:else}
<p class="text-xs text-theme-muted">
Select event types above to see available metadata fields.
</p>
{/if}
</div>
{/if}
<div>
<label for="template-subject" class="mb-1.5 block text-sm font-medium text-theme">Subject</label
>
<input
id="template-subject"
type="text"
bind:value={subject}
placeholder={subjectPlaceholder}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label for="template-body" class="mb-1.5 block text-sm font-medium text-theme">Body</label>
<textarea
id="template-body"
bind:value={body}
rows={4}
placeholder={bodyPlaceholder}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
></textarea>
</div>
</div>

View File

@ -0,0 +1,259 @@
<script lang="ts">
import { CreateCustomerProfileStore, UpdateCustomerProfileStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import { fromGlobalId } from '$lib/utils/relay';
import type { AdminProfiles$result, Customers$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type CustomerProfile = AdminProfiles$result['customerProfiles'][number];
type Customer = Customers$result['customers'][number];
interface Props {
profile?: CustomerProfile;
customers: Customer[];
onSuccess?: () => void;
onCancel?: () => void;
}
let { profile, customers, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!profile);
let submitting = $state(false);
let error = $state('');
let firstName = $state(profile?.firstName ?? '');
let lastName = $state(profile?.lastName ?? '');
let email = $state(profile?.email ?? '');
let phone = $state(profile?.phone ?? '');
let status = $state(profile?.status ?? 'ACTIVE');
let notes = $state(profile?.notes ?? '');
let selectedCustomerIds = $state<string[]>(profile?.customers.map((c) => c.id) ?? []);
const createStore = new CreateCustomerProfileStore();
const updateStore = new UpdateCustomerProfileStore();
const statusOptions = [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'PENDING', label: 'Pending' },
{ value: 'INACTIVE', label: 'Inactive' }
];
// Active customers only for selection
let activeCustomers = $derived(customers.filter((c) => c.status === 'ACTIVE'));
function toggleCustomer(customerId: string) {
if (selectedCustomerIds.includes(customerId)) {
selectedCustomerIds = selectedCustomerIds.filter((id) => id !== customerId);
} else {
selectedCustomerIds = [...selectedCustomerIds, customerId];
}
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!firstName || !lastName) {
error = 'Please provide first name and last name';
return;
}
if (isEdit && profile) {
const res = await updateStore.mutate({
input: {
id: profile.id,
firstName,
lastName,
email: email || null,
phone: phone || null,
status,
notes: notes || null,
customerIds: selectedCustomerIds.length > 0 ? selectedCustomerIds : null
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: {
firstName,
lastName,
email: email || null,
phone: phone || null,
status,
notes: notes || null,
customerIds: selectedCustomerIds.length > 0 ? selectedCustomerIds : null
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save customer profile';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
{#if isEdit && profile}
<div>
<span class="mb-1.5 block text-sm font-medium text-theme">Profile ID</span>
<p class="font-mono text-sm text-theme-muted">{fromGlobalId(profile.id)}</p>
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="mb-1.5 block text-sm font-medium text-theme">
First Name <span class="text-red-500">*</span>
</label>
<input
id="firstName"
type="text"
bind:value={firstName}
disabled={submitting}
placeholder="Enter first name"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="lastName" class="mb-1.5 block text-sm font-medium text-theme">
Last Name <span class="text-red-500">*</span>
</label>
<input
id="lastName"
type="text"
bind:value={lastName}
disabled={submitting}
placeholder="Enter last name"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div>
<label for="email" class="mb-1.5 block text-sm font-medium text-theme"> Email </label>
<input
id="email"
type="email"
bind:value={email}
disabled={submitting}
placeholder="Enter email address"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="phone" class="mb-1.5 block text-sm font-medium text-theme"> Phone </label>
<input
id="phone"
type="tel"
bind:value={phone}
disabled={submitting}
placeholder="Enter phone number"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
<select
id="status"
bind:value={status}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each statusOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Customer Access -->
<div>
<span class="mb-1.5 block text-sm font-medium text-theme">Customer Access</span>
<p class="mb-2 text-xs text-theme-muted">Select which customers this profile can access.</p>
{#if activeCustomers.length > 0}
<div
class="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-theme bg-theme-secondary p-3"
>
{#each activeCustomers as customer (customer.id)}
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={selectedCustomerIds.includes(customer.id)}
onchange={() => toggleCustomer(customer.id)}
disabled={submitting}
class="h-4 w-4 rounded border-theme text-primary-500 focus:ring-primary-500"
/>
<span class="text-sm text-theme">{customer.name}</span>
</label>
{/each}
</div>
{:else}
<p class="text-sm text-theme-muted italic">No active customers available.</p>
{/if}
</div>
<div>
<label for="notes" class="mb-1.5 block text-sm font-medium text-theme"> Notes </label>
<textarea
id="notes"
bind:value={notes}
disabled={submitting}
rows="3"
placeholder="Additional notes (optional)"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Customer Profile'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,242 @@
<script lang="ts">
import { CreateTeamProfileStore, UpdateTeamProfileStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import { fromGlobalId } from '$lib/utils/relay';
import type { AdminProfiles$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type TeamProfile = AdminProfiles$result['teamProfiles'][number];
interface Props {
profile?: TeamProfile;
onSuccess?: () => void;
onCancel?: () => void;
}
let { profile, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!profile);
let submitting = $state(false);
let error = $state('');
let firstName = $state(profile?.firstName ?? '');
let lastName = $state(profile?.lastName ?? '');
let email = $state(profile?.email ?? '');
let phone = $state(profile?.phone ?? '');
let role = $state(profile?.role ?? 'TEAM_MEMBER');
let status = $state(profile?.status ?? 'ACTIVE');
let notes = $state(profile?.notes ?? '');
const createStore = new CreateTeamProfileStore();
const updateStore = new UpdateTeamProfileStore();
const roleOptions = [
{ value: 'ADMIN', label: 'Admin' },
{ value: 'TEAM_LEADER', label: 'Team Leader' },
{ value: 'TEAM_MEMBER', label: 'Team Member' }
];
const statusOptions = [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'PENDING', label: 'Pending' },
{ value: 'INACTIVE', label: 'Inactive' }
];
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!firstName || !lastName || !role) {
error = 'Please provide first name, last name, and role';
return;
}
if (isEdit && profile) {
const res = await updateStore.mutate({
input: {
id: profile.id,
firstName,
lastName,
email: email || null,
phone: phone || null,
role,
status,
notes: notes || null
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: {
firstName,
lastName,
email: email || null,
phone: phone || null,
role,
status,
notes: notes || null
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save team member';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
{#if isEdit && profile}
<div>
<span class="mb-1.5 block text-sm font-medium text-theme">Profile ID</span>
<p class="font-mono text-sm text-theme-muted">{fromGlobalId(profile.id)}</p>
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="mb-1.5 block text-sm font-medium text-theme">
First Name <span class="text-red-500">*</span>
</label>
<input
id="firstName"
type="text"
bind:value={firstName}
disabled={submitting}
placeholder="Enter first name"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="lastName" class="mb-1.5 block text-sm font-medium text-theme">
Last Name <span class="text-red-500">*</span>
</label>
<input
id="lastName"
type="text"
bind:value={lastName}
disabled={submitting}
placeholder="Enter last name"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div>
<label for="email" class="mb-1.5 block text-sm font-medium text-theme"> Email </label>
<input
id="email"
type="email"
bind:value={email}
disabled={submitting}
placeholder="Enter email address"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="phone" class="mb-1.5 block text-sm font-medium text-theme"> Phone </label>
<input
id="phone"
type="tel"
bind:value={phone}
disabled={submitting}
placeholder="Enter phone number"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="role" class="mb-1.5 block text-sm font-medium text-theme">
Role <span class="text-red-500">*</span>
</label>
<select
id="role"
bind:value={role}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each roleOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div>
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
<select
id="status"
bind:value={status}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each statusOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div>
<label for="notes" class="mb-1.5 block text-sm font-medium text-theme"> Notes </label>
<textarea
id="notes"
bind:value={notes}
disabled={submitting}
rows="3"
placeholder="Additional notes (optional)"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Team Member'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,126 @@
<script lang="ts">
import { createProjectScopeCategory, updateProjectScopeCategory } from '$lib/utils/scopes';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { ProjectScope$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Category = NonNullable<ProjectScope$result['projectScope']>['projectAreas'][number];
interface Props {
scopeId: string;
category?: Category;
nextOrder?: number;
onSuccess?: () => void;
onCancel?: () => void;
}
let { scopeId, category, nextOrder = 0, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!category);
let submitting = $state(false);
let error = $state('');
let name = $state(category?.name ?? '');
let order = $state(category?.order ?? nextOrder);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!name.trim()) {
error = 'Please provide a category name';
return;
}
if (isEdit && category) {
await updateProjectScopeCategory({
id: category.id,
name: name.trim(),
order
});
} else {
await createProjectScopeCategory({
scopeId,
name: name.trim(),
order
});
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save category';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Category Name <span class="text-red-500">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="e.g., Kitchen, Exterior, Deep Clean"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="order" class="mb-1.5 block text-sm font-medium text-theme"> Display Order </label>
<input
id="order"
type="number"
min="0"
bind:value={order}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="mt-1 text-xs text-theme-muted">Lower numbers appear first</p>
</div>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Add Category'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,298 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import { goto } from '$app/navigation';
import { fromGlobalId } from '$lib/utils/relay';
interface TeamMember {
id: string;
fullName: string;
role: string;
}
interface AccountInfo {
accountName: string;
addressName: string;
}
interface CustomerInfo {
customerName: string;
}
interface WaveProduct {
id: string;
name: string;
description: string | null;
unitPrice: number | string;
isArchived: boolean;
}
interface Props {
projectId: string;
projectName: string | null;
status: string;
date: string | null;
amount: number | string | null;
labor: number | string | null;
address: string | null;
teamMembers: TeamMember[];
notes: string | null;
calendarEventId?: string | null;
waveServiceId?: string | null;
waveProducts?: WaveProduct[];
onScheduleEvent?: () => void;
accountInfo?: AccountInfo | null;
customerInfo?: CustomerInfo | null;
isDispatched?: boolean;
}
let {
projectId,
projectName,
status,
date,
amount,
labor,
address,
teamMembers,
notes,
calendarEventId,
waveServiceId,
waveProducts = [],
onScheduleEvent,
accountInfo,
customerInfo,
isDispatched = false
}: Props = $props();
// Find linked Wave product
let linkedProduct = $derived(
waveServiceId ? waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) : null
);
// Non-admin team members for messaging (exclude ADMIN role)
let messagableMembers = $derived(teamMembers.filter((m) => m.role !== 'ADMIN'));
// Build the subject line for messages
let messageSubject = $derived(() => {
const formattedDate = formatDate(date) || 'Unknown Date';
const accountName = accountInfo?.accountName || customerInfo?.customerName || 'Unknown';
const name = projectName || 'Project';
return `${formattedDate} - Project: ${name} - ${accountName}`;
});
// Navigate to new message page with pre-filled params
function messageTeamMember(memberId: string) {
const params = new URLSearchParams({
entityType: 'Project',
entityId: projectId,
participantIds: memberId,
conversationType: 'DIRECT',
subject: messageSubject()
});
goto(`/messages/new?${params.toString()}`);
}
// Navigate to new message page with all team members
function messageAllTeamMembers() {
const memberIds = messagableMembers.map((m) => m.id).join(',');
const params = new URLSearchParams({
entityType: 'Project',
entityId: projectId,
participantIds: memberIds,
conversationType: 'GROUP',
subject: messageSubject()
});
goto(`/messages/new?${params.toString()}`);
}
function formatCurrency(value: number | string | null | undefined): string {
if (value == null) return '$0';
const num = typeof value === 'string' ? parseFloat(value) : value;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(num);
}
function getStatusBadgeClass(projectStatus: string): string {
switch (projectStatus) {
case 'SCHEDULED':
return 'badge-primary';
case 'IN_PROGRESS':
return 'badge-accent';
default:
return 'badge-secondary';
}
}
</script>
<div class="mb-6 rounded-xl border border-theme bg-theme-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-theme">Project Details</h2>
{#if calendarEventId}
<a
href="/admin/calendar/{calendarEventId}"
class="inline-flex items-center gap-2 rounded-lg bg-accent7-500/10 px-3 py-1.5 text-sm font-medium text-accent7-600 transition-colors hover:bg-accent7-500/20 dark:text-accent7-400"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
View Event
</a>
{:else if onScheduleEvent}
<button
type="button"
onclick={onScheduleEvent}
class="inline-flex items-center gap-2 rounded-lg bg-accent7-500/10 px-3 py-1.5 text-sm font-medium text-accent7-600 transition-colors hover:bg-accent7-500/20 dark:text-accent7-400"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Schedule Event
</button>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- Status -->
<div>
<p class="detail-label">Status</p>
<div class="flex flex-wrap gap-2">
<span
class="inline-block rounded-full px-3 py-1 text-xs font-medium {getStatusBadgeClass(
status
)}"
>
{status.replace('_', ' ')}
</span>
{#if isDispatched}
<span
class="inline-block rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
>
Dispatched
</span>
{/if}
</div>
</div>
<!-- Date -->
<div>
<p class="detail-label">Date</p>
<p class="text-theme">{formatDate(date)}</p>
</div>
<!-- Amount -->
<div>
<p class="detail-label">Amount</p>
<p class="font-semibold text-theme">{formatCurrency(amount)}</p>
</div>
<!-- Labor -->
<div>
<p class="detail-label">Labor</p>
<p class="text-theme">{formatCurrency(labor)}</p>
</div>
</div>
<!-- Address -->
{#if address}
<div class="mt-4 border-t border-theme pt-4">
<p class="detail-label">Address</p>
<p class="text-theme">{address}</p>
</div>
{/if}
<!-- Team Members -->
{#if teamMembers.length > 0}
<div class="mt-4 border-t border-theme pt-4">
<div class="mb-2 flex items-center justify-between">
<p class="detail-label">Team</p>
{#if messagableMembers.length >= 2}
<button
type="button"
onclick={messageAllTeamMembers}
class="inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-primary-700"
>
<svg class="h-3.5 w-3.5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
Message Team
</button>
{/if}
</div>
<div class="space-y-2">
{#each teamMembers as member (member.id)}
<div class="flex items-center gap-2">
<span
class="inline-flex h-6 w-6 items-center justify-center rounded text-xs font-bold {member.role ===
'TEAM_LEADER'
? 'bg-accent-500/20 text-accent-600 dark:text-accent-400'
: 'bg-secondary-500/20 text-secondary-600 dark:text-secondary-400'}"
>
{member.role === 'TEAM_LEADER' ? 'TL' : 'TM'}
</span>
{#if member.role !== 'ADMIN'}
<button
type="button"
onclick={() => messageTeamMember(member.id)}
class="rounded-full p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Message {member.fullName}"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</button>
{:else}
<!-- Spacer to maintain alignment for admin members -->
<span class="h-6 w-6"></span>
{/if}
<span class="text-theme">{member.fullName}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Wave Integration -->
{#if waveServiceId}
<div class="mt-4 border-t border-theme pt-4">
<p class="detail-label">Wave Integration</p>
<div class="flex items-center gap-2">
<span class="badge-primary">Linked</span>
{#if linkedProduct}
<span class="text-theme">{linkedProduct.name}</span>
{/if}
</div>
<p class="mt-1 font-mono text-xs text-theme-muted">{waveServiceId}</p>
</div>
{/if}
<!-- Notes -->
{#if notes}
<div class="mt-4 border-t border-theme pt-4">
<p class="detail-label">Notes</p>
<p class="whitespace-pre-wrap text-theme-secondary">{notes}</p>
</div>
{/if}
</div>

View File

@ -0,0 +1,769 @@
<script lang="ts">
import { CreateProjectStore, UpdateProjectStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import { fromGlobalId } from '$lib/utils/relay';
import type { GetProject$result, Customers$result, Accounts$result, Team$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Project = NonNullable<GetProject$result['project']>;
type Customer = Customers$result['customers'][number];
type Account = Accounts$result['accounts'][number];
type TeamProfile = Team$result['teamProfiles'][number];
interface WaveProduct {
id: string;
name: string;
description: string | null;
unitPrice: number | string;
isArchived: boolean;
}
interface Props {
project?: Project;
customers: Customer[];
accounts: Account[];
teamProfiles: TeamProfile[];
waveProducts?: WaveProduct[];
onSuccess?: () => void;
onCancel?: () => void;
}
let {
project,
customers,
accounts,
teamProfiles,
waveProducts = [],
onSuccess,
onCancel
}: Props = $props();
let isEdit = $derived(!!project);
let submitting = $state(false);
let error = $state('');
// Helper to find customer Relay Global ID from UUID
function findCustomerGlobalId(customerUuid: string | null | undefined): string {
if (!customerUuid) return '';
// Find customer whose Global ID decodes to this UUID
const customer = customers.find((c) => fromGlobalId(c.id) === customerUuid);
return customer?.id ?? '';
}
// Helper to find address Relay Global ID from UUID
function findAddressGlobalId(addressUuid: string | null | undefined): string {
if (!addressUuid) return '';
// Search through all accounts for this address UUID
for (const account of accounts) {
const address = account.addresses?.find((a) => fromGlobalId(a.id) === addressUuid);
if (address) return address.id;
}
return '';
}
// Helper to find account that contains an address UUID
function findAccountForAddressUuid(addressUuid: string | null | undefined): string {
if (!addressUuid) return '';
for (const account of accounts) {
const hasAddress = account.addresses?.some((a) => fromGlobalId(a.id) === addressUuid);
if (hasAddress) return account.id;
}
return '';
}
// Helper to convert team member UUIDs (pk) to Relay Global IDs
function findTeamMemberGlobalIds(memberPks: { pk: string }[] | null | undefined): string[] {
if (!memberPks) return [];
return memberPks
.map((m) => {
// Find team profile whose Global ID decodes to this pk UUID
const profile = teamProfiles.find((p) => fromGlobalId(p.id) === m.pk);
return profile?.id;
})
.filter((id): id is string => !!id);
}
// Form fields - convert project UUIDs to Relay Global IDs for form
let customerId = $state(findCustomerGlobalId(project?.customerId));
let selectedAccountId = $state(findAccountForAddressUuid(project?.accountAddressId));
let accountAddressId = $state(findAddressGlobalId(project?.accountAddressId));
let useManualAddress = $state(
!project?.accountAddressId && !!(project?.streetAddress || project?.city)
);
let name = $state(project?.name ?? '');
let date = $state(project?.date ?? '');
let amount = $state(project?.amount?.toString() ?? '');
let labor = $state(project?.labor?.toString() ?? '');
let status = $state(project?.status ?? 'SCHEDULED');
let streetAddress = $state(project?.streetAddress ?? '');
let city = $state(project?.city ?? '');
let stateCode = $state(project?.state ?? '');
let zipCode = $state(project?.zipCode ?? '');
let notes = $state(project?.notes ?? '');
let selectedTeamMemberIds = $state<string[]>(findTeamMemberGlobalIds(project?.teamMembers));
let waveServiceId = $state(project?.waveServiceId ?? '');
// Find linked Wave product for display
let linkedProduct = $derived(
waveServiceId ? waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) : null
);
// Filter accounts for selected customer
// customerId state is now a Relay Global ID
// account.customerId is a UUID, so we need to convert for comparison
let customerAccounts = $derived(() => {
if (!customerId) return [];
const customerUuid = fromGlobalId(customerId);
return accounts.filter((a) => a.customerId === customerUuid);
});
// Get addresses for selected account
let accountAddresses = $derived(() => {
if (!selectedAccountId) return [];
const account = customerAccounts().find((a) => a.id === selectedAccountId);
return account?.addresses ?? [];
});
// Get currently selected address details for display
let selectedAddressDetails = $derived(() => {
if (!accountAddressId) return null;
for (const account of customerAccounts()) {
const address = account.addresses?.find((a) => a.id === accountAddressId);
if (address) {
return { account, address };
}
}
return null;
});
// When customer changes, reset account and address
function handleCustomerChange() {
selectedAccountId = '';
accountAddressId = '';
useManualAddress = false;
}
// When account changes, auto-select if only one address, otherwise reset
function handleAccountChange() {
const addresses = accountAddresses();
if (addresses.length === 1) {
accountAddressId = addresses[0].id;
} else {
accountAddressId = '';
}
useManualAddress = false;
}
// Toggle manual address mode
function toggleManualAddress() {
useManualAddress = !useManualAddress;
if (useManualAddress) {
selectedAccountId = '';
accountAddressId = '';
}
}
// Non-admin team members only
let nonAdminTeamMembers = $derived(teamProfiles.filter((p) => p.role !== 'ADMIN'));
// Get admin profile ID
let adminProfileId = $derived(teamProfiles.find((p) => p.role === 'ADMIN')?.id ?? null);
// Track if project is dispatched (admin profile is in team members)
let isDispatched = $derived(
adminProfileId ? selectedTeamMemberIds.includes(adminProfileId) : false
);
function toggleDispatched() {
if (!adminProfileId) return;
if (isDispatched) {
// When un-dispatching, remove admin and clear all team members
selectedTeamMemberIds = [];
} else {
selectedTeamMemberIds = [...selectedTeamMemberIds, adminProfileId];
}
}
const createStore = new CreateProjectStore();
const updateStore = new UpdateProjectStore();
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!customerId || !name || !date || !amount) {
error = 'Please provide customer, name, date, and amount';
return;
}
// Require either accountAddressId OR manual address
const hasAccountAddress = !!accountAddressId;
const hasManualAddress = !!(streetAddress && city && stateCode);
if (!hasAccountAddress && !hasManualAddress) {
error = 'Please select an account address or enter a manual address';
return;
}
const baseInput = {
customerId,
accountAddressId: accountAddressId || null,
name,
date,
amount: parseFloat(amount),
labor: labor ? parseFloat(labor) : 0,
status,
streetAddress: accountAddressId ? null : streetAddress || null,
city: accountAddressId ? null : city || null,
state: accountAddressId ? null : stateCode || null,
zipCode: accountAddressId ? null : zipCode || null,
notes: notes || null,
teamMemberIds: selectedTeamMemberIds.length > 0 ? selectedTeamMemberIds : null,
waveServiceId: waveServiceId || ''
};
if (isEdit && project) {
const res = await updateStore.mutate({
input: {
id: project.id,
...baseInput
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
} else {
const res = await createStore.mutate({
input: baseInput
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save project';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
function toggleTeamMember(id: string) {
if (selectedTeamMemberIds.includes(id)) {
selectedTeamMemberIds = selectedTeamMemberIds.filter((m) => m !== id);
} else {
selectedTeamMemberIds = [...selectedTeamMemberIds, id];
}
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<!-- Customer -->
<div>
<label for="customerId" class="mb-1.5 block text-sm font-medium text-theme">
Customer <span class="text-red-500">*</span>
</label>
<div class="relative w-full">
<select
id="customerId"
bind:value={customerId}
onchange={handleCustomerChange}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select a customer</option>
{#each customers as customer (customer.id)}
<option value={customer.id}>{customer.name}</option>
{/each}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
<!-- Address Selection Section -->
{#if customerId}
<div class="space-y-4 rounded-lg border border-theme bg-theme-card p-4">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-theme">Project Location</p>
{#if customerAccounts().length > 0}
<button
type="button"
onclick={toggleManualAddress}
class="text-xs text-primary-500 hover:text-primary-600"
>
{useManualAddress ? 'Use account address' : 'Enter address manually'}
</button>
{/if}
</div>
{#if !useManualAddress && customerAccounts().length > 0}
<!-- Account Selection -->
<div>
<label for="selectedAccountId" class="mb-1.5 block text-sm text-theme-secondary">
Account
</label>
<div class="relative w-full">
<select
id="selectedAccountId"
bind:value={selectedAccountId}
onchange={handleAccountChange}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select an account</option>
{#each customerAccounts() as account (account.id)}
<option value={account.id}>{account.name}</option>
{/each}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
<!-- Address Selection (shown when account is selected) -->
{#if selectedAccountId && accountAddresses().length > 0}
<div>
<label for="accountAddressId" class="mb-1.5 block text-sm text-theme-secondary">
Address
</label>
{#if accountAddresses().length === 1}
<!-- Single address - show as confirmation -->
{@const address = accountAddresses()[0]}
<div
class="rounded-lg border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="flex items-start gap-2">
<svg
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary-500"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<div class="text-sm">
<p class="font-medium text-theme">{address.streetAddress}</p>
<p class="text-theme-secondary">
{address.city}, {address.state}
{address.zipCode}
</p>
</div>
</div>
</div>
{:else}
<!-- Multiple addresses - show dropdown -->
<div class="relative w-full">
<select
id="accountAddressId"
bind:value={accountAddressId}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select an address</option>
{#each accountAddresses() as address (address.id)}
<option value={address.id}>
{address.streetAddress}, {address.city}, {address.state}
</option>
{/each}
</select>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
{/if}
</div>
<!-- Show selected address confirmation (for multi-address accounts) -->
{#if accountAddresses().length > 1 && selectedAddressDetails()}
{@const details = selectedAddressDetails()}
<div
class="rounded-lg border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="flex items-start gap-2">
<svg
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary-500"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<div class="text-sm">
<p class="font-medium text-theme">{details?.address.streetAddress}</p>
<p class="text-theme-secondary">
{details?.address.city}, {details?.address.state}
{details?.address.zipCode}
</p>
</div>
</div>
</div>
{/if}
{:else if selectedAccountId && accountAddresses().length === 0}
<p class="text-sm text-theme-muted italic">
This account has no addresses. Please enter address manually.
</p>
{/if}
{:else}
<!-- Manual Address Entry -->
<div class="space-y-3">
<div>
<label for="streetAddress" class="mb-1 block text-sm text-theme-secondary"
>Street</label
>
<input
id="streetAddress"
type="text"
bind:value={streetAddress}
disabled={submitting}
placeholder="123 Main St"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div class="grid grid-cols-6 gap-3">
<div class="col-span-3">
<label for="city" class="mb-1 block text-sm text-theme-secondary">City</label>
<input
id="city"
type="text"
bind:value={city}
disabled={submitting}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div class="col-span-1">
<label for="stateCode" class="mb-1 block text-sm text-theme-secondary">State</label>
<input
id="stateCode"
type="text"
bind:value={stateCode}
disabled={submitting}
maxlength="2"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div class="col-span-2">
<label for="zipCode" class="mb-1 block text-sm text-theme-secondary">Zip</label>
<input
id="zipCode"
type="text"
bind:value={zipCode}
disabled={submitting}
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
</div>
{/if}
</div>
{/if}
<!-- Project Name -->
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Project Name <span class="text-red-500">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="e.g., Deep Clean - March"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<!-- Date -->
<div>
<label for="date" class="mb-1.5 block text-sm font-medium text-theme">
Date <span class="text-red-500">*</span>
</label>
<input
id="date"
type="date"
bind:value={date}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<!-- Amount + Labor -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="amount" class="mb-1.5 block text-sm font-medium text-theme">
Amount <span class="text-red-500">*</span>
</label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
<input
id="amount"
type="number"
step="0.01"
min="0"
bind:value={amount}
disabled={submitting}
placeholder="0.00"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div>
<label for="labor" class="mb-1.5 block text-sm font-medium text-theme"> Labor </label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
<input
id="labor"
type="number"
step="0.01"
min="0"
bind:value={labor}
disabled={submitting}
placeholder="0.00"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
</div>
<!-- Status (only for edit) -->
{#if isEdit}
<div>
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
<div class="relative w-full">
<select
id="status"
bind:value={status}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="SCHEDULED">Scheduled</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">Completed</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
{/if}
<!-- Dispatched Toggle -->
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-card p-3">
<div>
<p class="text-sm font-medium text-theme">Dispatched</p>
<p class="text-xs text-theme-muted">Required before assigning team members</p>
</div>
<button
type="button"
onclick={toggleDispatched}
disabled={submitting}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 {isDispatched
? 'bg-emerald-500'
: 'bg-gray-200 dark:bg-gray-700'}"
role="switch"
aria-checked={isDispatched}
aria-label="Toggle dispatched status"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {isDispatched
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
<!-- Team Members -->
<div>
<p class="mb-1.5 text-sm font-medium text-theme">Team Members</p>
<div
class="space-y-2 rounded-lg border border-theme bg-theme-card p-3 {!isDispatched
? 'opacity-50'
: ''}"
>
{#if !isDispatched}
<p class="text-sm text-theme-muted italic">Enable "Dispatched" to assign team members</p>
{:else if nonAdminTeamMembers.length > 0}
{#each nonAdminTeamMembers as member (member.id)}
<label class="flex cursor-pointer items-center gap-3 rounded-lg interactive p-2">
<input
type="checkbox"
checked={selectedTeamMemberIds.includes(member.id)}
onchange={() => toggleTeamMember(member.id)}
disabled={submitting}
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500"
/>
<span class="text-sm text-theme">{member.fullName}</span>
{#if member.role === 'TEAM_LEADER'}
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
>
TL
</span>
{/if}
</label>
{/each}
{:else}
<p class="text-sm text-theme-muted">No team members available</p>
{/if}
</div>
</div>
<!-- Notes -->
<div>
<label for="notes" class="mb-1.5 block text-sm font-medium text-theme"> Notes </label>
<textarea
id="notes"
bind:value={notes}
disabled={submitting}
rows="3"
placeholder="Optional notes about the project"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<!-- Wave Product -->
{#if waveProducts.length > 0}
<div class="border-t border-theme pt-4">
<label for="waveProduct" class="mb-1.5 block text-sm font-medium text-theme">
Wave Product
</label>
<select
id="waveProduct"
bind:value={waveServiceId}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">No linked product</option>
{#each waveProducts as product (product.id)}
{@const decodedId = fromGlobalId(product.id)}
<option value={decodedId}>
{product.name} (${Number(product.unitPrice).toFixed(2)})
</option>
{/each}
</select>
{#if linkedProduct}
<p class="mt-1 text-xs text-theme-muted">
Currently linked to: {linkedProduct.name}
</p>
{/if}
</div>
{/if}
<!-- Submit Buttons -->
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Project'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,142 @@
<script lang="ts">
import { createProjectScope, updateProjectScope } from '$lib/utils/scopes';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { ProjectScope$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Scope = NonNullable<ProjectScope$result['projectScope']>;
interface Props {
projectId: string;
scope?: Scope;
onSuccess?: () => void;
onCancel?: () => void;
}
let { projectId, scope, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!scope);
let submitting = $state(false);
let error = $state('');
let name = $state(scope?.name ?? 'Project Scope');
let description = $state(scope?.description ?? '');
let isActive = $state(scope?.isActive ?? true);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!name.trim()) {
error = 'Please provide a scope name';
return;
}
if (isEdit && scope) {
await updateProjectScope({
id: scope.id,
name: name.trim(),
description: description.trim() || null,
isActive
});
} else {
await createProjectScope({
projectId,
name: name.trim(),
description: description.trim() || null,
isActive
});
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save scope';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
Scope Name <span class="text-red-500">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
disabled={submitting}
placeholder="e.g., Project Scope"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
Description
</label>
<textarea
id="description"
bind:value={description}
disabled={submitting}
rows="3"
placeholder="Optional description of this project scope"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<div>
<div class="flex items-center gap-2">
<input
id="isActive"
type="checkbox"
bind:checked={isActive}
disabled={submitting}
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<label for="isActive" class="text-sm text-theme">Active scope</label>
</div>
</div>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Scope'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,365 @@
<script lang="ts">
import type { ProjectScope$result } from '$houdini';
// Types derived from the ProjectScope query
export type ProjectScopeType = NonNullable<ProjectScope$result['projectScope']>;
export type ProjectCategoryType = ProjectScopeType['projectAreas'][number];
export type ProjectTaskType = ProjectCategoryType['projectTasks'][number];
interface Props {
scope: ProjectScopeType;
expandedCategories: Set<string>;
editable?: boolean;
onToggleCategory: (categoryId: string) => void;
onEditScope?: (scope: ProjectScopeType) => void;
onDeleteScope?: (scopeId: string) => void;
onAddCategory?: (scopeId: string, nextOrder: number) => void;
onEditCategory?: (scopeId: string, category: ProjectCategoryType) => void;
onDeleteCategory?: (categoryId: string) => void;
onAddTask?: (categoryId: string, nextOrder: number) => void;
onEditTask?: (categoryId: string, task: ProjectTaskType) => void;
onDeleteTask?: (taskId: string) => void;
}
let {
scope,
expandedCategories,
editable = true,
onToggleCategory,
onEditScope,
onDeleteScope,
onAddCategory,
onEditCategory,
onDeleteCategory,
onAddTask,
onEditTask,
onDeleteTask
}: Props = $props();
function getTotalTasks(): number {
return scope.projectAreas?.reduce((acc, cat) => acc + (cat.projectTasks?.length ?? 0), 0) ?? 0;
}
function sortedCategories(categories: ProjectCategoryType[]): ProjectCategoryType[] {
return [...categories].sort((a, b) => a.order - b.order);
}
function sortedTasks(tasks: ProjectTaskType[]): ProjectTaskType[] {
return [...tasks].sort((a, b) => a.order - b.order);
}
</script>
<div class="space-y-3">
<!-- Scope Header -->
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="font-medium text-theme">{scope.name}</h3>
{#if scope.isActive}
<span
class="rounded bg-accent-100 px-1.5 py-0.5 text-xs font-medium text-accent-700 dark:bg-accent-900/40 dark:text-accent-400"
>
Active
</span>
{/if}
</div>
{#if scope.description}
<p class="mt-1 text-sm text-theme-muted">{scope.description}</p>
{/if}
<p class="mt-1 text-xs text-theme-muted">
{scope.projectAreas?.length ?? 0}
{(scope.projectAreas?.length ?? 0) === 1 ? 'category' : 'categories'}, {getTotalTasks()}
{getTotalTasks() === 1 ? 'task' : 'tasks'}
</p>
</div>
{#if editable && onEditScope && onDeleteScope}
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={() => onEditScope(scope)}
class="rounded p-1.5 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Edit scope"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={() => onDeleteScope(scope.id)}
class="rounded p-1.5 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label="Delete scope"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
{/if}
</div>
<!-- Categories -->
<div class="border-t border-theme pt-3">
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-medium tracking-wide text-theme-muted uppercase">Categories</p>
{#if editable && onAddCategory}
<button
type="button"
onclick={() => onAddCategory(scope.id, scope.projectAreas?.length ?? 0)}
class="text-xs font-medium text-accent-500 hover:text-accent-600"
>
+ Add Category
</button>
{/if}
</div>
{#if scope.projectAreas && scope.projectAreas.length > 0}
<div class="space-y-2">
{#each sortedCategories(scope.projectAreas) as category (category.id)}
{@const taskCount = category.projectTasks?.length ?? 0}
{@const isCategoryExpanded = expandedCategories.has(category.id)}
<div
class="rounded-lg border border-l-2 border-theme border-l-accent-400 bg-gray-50 dark:bg-gray-800/50"
>
<!-- Category Header -->
<div class="flex items-start justify-between gap-1 p-2">
<button
type="button"
onclick={() => onToggleCategory(category.id)}
class="min-w-0 flex-1 text-left"
>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4 flex-shrink-0 text-theme-muted transition-transform {isCategoryExpanded
? 'rotate-90'
: ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span class="font-medium text-theme">{category.name}</span>
</span>
<span class="mt-0.5 block pl-6 text-xs text-theme-muted">
{taskCount}
{taskCount === 1 ? 'task' : 'tasks'}
</span>
</button>
{#if editable && onAddTask && onEditCategory && onDeleteCategory}
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={() => onAddTask(category.id, taskCount)}
class="rounded p-1 text-accent-500 hover:bg-accent-500/10"
aria-label="Add task"
title="Add task"
>
<svg
class="h-3.5 w-3.5"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<button
type="button"
onclick={() => onEditCategory(scope.id, category)}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Edit category"
>
<svg
class="h-3.5 w-3.5"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={() => onDeleteCategory(category.id)}
class="rounded p-1 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label="Delete category"
>
<svg
class="h-3.5 w-3.5"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
{/if}
</div>
<!-- Tasks (expanded content) -->
{#if isCategoryExpanded}
<div class="border-t border-theme px-2 pb-2">
{#if category.projectTasks && category.projectTasks.length > 0}
<div class="mt-2 space-y-2">
{#each sortedTasks(category.projectTasks) as task (task.id)}
<div class="rounded bg-gray-50 p-2 text-xs dark:bg-gray-800/50">
<!-- Task actions -->
{#if editable && onEditTask && onDeleteTask}
<div class="mb-2 flex items-center justify-end gap-0.5">
<button
type="button"
onclick={() => onEditTask(category.id, task)}
class="rounded p-0.5 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Edit task"
>
<svg
class="h-3 w-3"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={() => onDeleteTask(task.id)}
class="rounded p-0.5 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label="Delete task"
>
<svg
class="h-3 w-3"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
{/if}
<!-- Task fields stacked vertically -->
<div class="space-y-2">
<div>
<span
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
>
Scope Description
<button
type="button"
class="group relative"
aria-label="What is scope description?"
>
<svg
class="h-3 w-3 text-theme-muted hover:text-theme"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-[10px] text-white normal-case group-hover:block dark:bg-gray-700"
>
The customer-facing description of the task
</span>
</button>
</span>
<p class="text-theme">{task.description}</p>
</div>
<div>
<span
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
>
Checklist Description
<button
type="button"
class="group relative"
aria-label="What is checklist description?"
>
<svg
class="h-3 w-3 text-theme-muted hover:text-theme"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-[10px] text-white normal-case group-hover:block dark:bg-gray-700"
>
What team members see during a session
</span>
</button>
</span>
<p class="text-theme">{task.checklistDescription || '—'}</p>
</div>
</div>
</div>
{/each}
</div>
{:else}
<p class="mt-2 text-center text-xs text-theme-muted">No tasks yet</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-xs text-theme-muted">No categories defined for this scope.</p>
{/if}
</div>
</div>

View File

@ -0,0 +1,164 @@
<script lang="ts">
import { createProjectScopeTask, updateProjectScopeTask } from '$lib/utils/scopes';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { ProjectScope$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Task = NonNullable<
ProjectScope$result['projectScope']
>['projectAreas'][number]['projectTasks'][number];
interface Props {
categoryId: string;
task?: Task;
nextOrder?: number;
onSuccess?: () => void;
onCancel?: () => void;
}
let { categoryId, task, nextOrder = 0, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!task);
let submitting = $state(false);
let error = $state('');
let description = $state(task?.description ?? '');
let checklistDescription = $state(task?.checklistDescription ?? '');
let estimatedMinutes = $state<number | null>(task?.estimatedMinutes ?? null);
let order = $state(task?.order ?? nextOrder);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!description.trim()) {
error = 'Please provide a task description';
return;
}
if (isEdit && task) {
await updateProjectScopeTask({
id: task.id,
description: description.trim(),
checklistDescription: checklistDescription.trim() || null,
estimatedMinutes: estimatedMinutes || null,
order
});
} else {
await createProjectScopeTask({
categoryId,
description: description.trim(),
checklistDescription: checklistDescription.trim() || '',
estimatedMinutes: estimatedMinutes || null,
order
});
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save task';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
Task Description <span class="text-red-500">*</span>
</label>
<textarea
id="description"
bind:value={description}
disabled={submitting}
rows="2"
placeholder="e.g., Deep clean all kitchen appliances"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<div>
<label for="checklistDescription" class="mb-1.5 block text-sm font-medium text-theme">
Checklist Note
</label>
<input
id="checklistDescription"
type="text"
bind:value={checklistDescription}
disabled={submitting}
placeholder="Optional short note for checklist"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="mt-1 text-xs text-theme-muted">Shortened version shown on project checklists</p>
</div>
<div>
<label for="estimatedMinutes" class="mb-1.5 block text-sm font-medium text-theme">
Estimated Time (minutes)
</label>
<input
id="estimatedMinutes"
type="number"
min="0"
bind:value={estimatedMinutes}
disabled={submitting}
placeholder="e.g., 30"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="mt-1 text-xs text-theme-muted">Estimated time to complete this task</p>
</div>
<div>
<label for="order" class="mb-1.5 block text-sm font-medium text-theme"> Display Order </label>
<input
id="order"
type="number"
min="0"
bind:value={order}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Add Task'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,83 @@
<script lang="ts">
interface Template {
id: string;
name: string;
}
interface Props {
templates: Template[];
isOpen: boolean;
onToggle: () => void;
onCreateBlank: () => void;
onCreateFromTemplate: (templateId: string) => void;
}
let { templates, isOpen, onToggle, onCreateBlank, onCreateFromTemplate }: Props = $props();
</script>
<div class="relative">
{#if templates.length > 0}
<button
type="button"
onclick={onToggle}
class="inline-flex items-center gap-2 rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>Create Scope</span>
<svg class="h-3 w-3" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isOpen}
<!-- Backdrop to close dropdown -->
<button
type="button"
class="fixed inset-0 z-10"
onclick={onToggle}
aria-label="Close dropdown"
></button>
<!-- Dropdown menu -->
<div
class="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-theme bg-theme-card py-1 shadow-theme-lg"
>
<button
type="button"
onclick={() => {
onToggle();
onCreateBlank();
}}
class="block w-full interactive px-3 py-2 text-left text-sm text-theme"
>
Blank Scope
</button>
<div class="my-1 border-t border-theme"></div>
<p class="px-3 py-1 text-[10px] font-medium text-theme-muted uppercase">From Template</p>
{#each templates as template (template.id)}
<button
type="button"
onclick={() => {
onToggle();
onCreateFromTemplate(template.id);
}}
class="block w-full interactive px-3 py-2 text-left text-sm text-theme"
>
{template.name}
</button>
{/each}
</div>
{/if}
{:else}
<button
type="button"
onclick={onCreateBlank}
class="inline-flex items-center gap-2 rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>Create Scope</span>
</button>
{/if}
</div>

View File

@ -0,0 +1,297 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { AdminProjectsStore, UpdateReportStore } from '$houdini';
import { SvelteSet } from 'svelte/reactivity';
import type { GetReport$result } from '$houdini';
type Report = NonNullable<GetReport$result['report']>;
interface AccountInfo {
accountName: string;
addressName?: string | null;
}
interface CustomerInfo {
customerName: string;
}
interface Props {
open: boolean;
report: Report;
teamMemberId: string;
accountLookup: Map<string, AccountInfo>;
customerLookup: Map<string, CustomerInfo>;
onSuccess: () => void;
}
let {
open = $bindable(false),
report,
teamMemberId,
accountLookup,
customerLookup,
onSuccess
}: Props = $props();
let loading = $state(false);
let saving = $state(false);
let error = $state('');
let availableProjects = $state<
{
id: string;
name: string;
date: string;
accountAddressId: string | null;
customerId: string | null;
}[]
>([]);
let selectedProjectIds = new SvelteSet<string>();
const projectsStore = new AdminProjectsStore();
const updateStore = new UpdateReportStore();
// Get already included project IDs
let includedProjectIds = $derived(new Set(report.projects.map((p) => p.id)));
// Load available projects when modal opens
$effect(() => {
if (open) {
loadAvailableProjects();
selectedProjectIds.clear();
}
});
async function loadAvailableProjects() {
loading = true;
error = '';
// Extract year-month from report date (YYYY-MM)
const [year, month] = report.date.split('-');
const startOfMonth = `${year}-${month}-01`;
const endOfMonth = new Date(parseInt(year), parseInt(month), 0).toISOString().split('T')[0];
try {
const result = await projectsStore.fetch({
variables: {
filters: {
status: { exact: 'COMPLETED' },
date: { gte: startOfMonth, lte: endOfMonth }
}
}
});
// Filter to only projects assigned to this team member and not already included
availableProjects = (result.data?.projects ?? []).filter((project) => {
const isAssigned = project.teamMembers?.some((m) => m.pk === teamMemberId);
const notIncluded = !includedProjectIds.has(project.id);
return isAssigned && notIncluded;
});
} catch (err) {
console.error('Failed to load projects:', err);
error = 'Failed to load projects';
} finally {
loading = false;
}
}
function formatItemDate(dateStr: string): string {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
function getProjectDisplayName(project: {
date: string;
name: string;
accountAddressId: string | null;
customerId: string | null;
}): string {
const dateStr = formatItemDate(project.date);
let entityName = 'Unknown';
if (project.accountAddressId) {
const accountInfo = accountLookup.get(project.accountAddressId);
if (accountInfo) {
entityName = accountInfo.accountName;
}
} else if (project.customerId) {
const customerInfo = customerLookup.get(project.customerId);
if (customerInfo) {
entityName = customerInfo.customerName;
}
}
return `${dateStr}, ${entityName} - ${project.name}`;
}
function toggleProject(projectId: string) {
if (selectedProjectIds.has(projectId)) {
selectedProjectIds.delete(projectId);
} else {
selectedProjectIds.add(projectId);
}
}
function selectAll() {
for (const project of availableProjects) {
selectedProjectIds.add(project.id);
}
}
function selectNone() {
selectedProjectIds.clear();
}
async function handleSave() {
if (selectedProjectIds.size === 0) return;
saving = true;
error = '';
try {
// Combine existing project IDs with newly selected ones
const existingIds = report.projects.map((p) => p.id);
const newIds = [...selectedProjectIds];
const allIds = [...existingIds, ...newIds];
await updateStore.mutate({
input: {
id: report.id,
projectIds: allIds
}
});
open = false;
onSuccess();
} catch (err) {
console.error('Failed to add projects:', err);
error = 'Failed to add projects';
} finally {
saving = false;
}
}
function handleClose() {
open = false;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose();
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
transition:fade={{ duration: 150 }}
onmousedown={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
>
<div
class="max-h-[90vh] w-full max-w-lg overflow-hidden rounded-xl bg-theme-card shadow-xl"
transition:scale={{ duration: 150, start: 0.95 }}
>
<!-- Header -->
<div class="border-b border-theme px-6 py-4">
<h2 class="text-lg font-semibold text-theme">Add Projects</h2>
<p class="mt-1 text-sm text-theme-muted">
Select completed projects to add to this report.
</p>
</div>
<!-- Content -->
<div class="max-h-[60vh] overflow-y-auto p-6">
{#if loading}
<div class="flex items-center justify-center py-8">
<svg
class="h-8 w-8 animate-spin text-accent-500"
style="fill: none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
{:else if availableProjects.length === 0}
<p class="py-8 text-center text-sm text-theme-muted italic">
No additional projects available for this team member in this month.
</p>
{:else}
<!-- Select All / None -->
<div class="mb-4 flex items-center gap-4">
<button
onclick={selectAll}
class="text-sm font-medium text-accent-600 hover:underline dark:text-accent-400"
>
Select All
</button>
<button
onclick={selectNone}
class="text-sm font-medium text-theme-muted hover:underline"
>
Select None
</button>
<span class="ml-auto text-sm text-theme-muted">
{selectedProjectIds.size} selected
</span>
</div>
<div class="space-y-2">
{#each availableProjects as project (project.id)}
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme interactive p-3"
>
<input
type="checkbox"
checked={selectedProjectIds.has(project.id)}
onchange={() => toggleProject(project.id)}
class="border-theme-muted h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
/>
<span class="flex-1 text-sm text-theme">
{getProjectDisplayName(project)}
</span>
</label>
{/each}
</div>
{/if}
{#if error}
<div class="mt-4 rounded-lg bg-error-500/10 p-3">
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 border-t border-theme px-6 py-4">
<button onclick={handleClose} class="btn-ghost" disabled={saving}> Cancel </button>
<button
onclick={handleSave}
class="btn-primary"
disabled={saving || selectedProjectIds.size === 0}
>
{#if saving}
Adding...
{:else}
Add {selectedProjectIds.size} Project{selectedProjectIds.size !== 1 ? 's' : ''}
{/if}
</button>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,267 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { AdminServicesStore, UpdateReportStore } from '$houdini';
import { SvelteSet } from 'svelte/reactivity';
import type { GetReport$result } from '$houdini';
type Report = NonNullable<GetReport$result['report']>;
interface AccountInfo {
accountName: string;
addressName?: string | null;
}
interface Props {
open: boolean;
report: Report;
teamMemberId: string;
accountLookup: Map<string, AccountInfo>;
onSuccess: () => void;
}
let { open = $bindable(false), report, teamMemberId, accountLookup, onSuccess }: Props = $props();
let loading = $state(false);
let saving = $state(false);
let error = $state('');
let availableServices = $state<{ id: string; date: string; accountAddressId: string | null }[]>(
[]
);
let selectedServiceIds = new SvelteSet<string>();
const servicesStore = new AdminServicesStore();
const updateStore = new UpdateReportStore();
// Get already included service IDs
let includedServiceIds = $derived(new Set(report.services.map((s) => s.id)));
// Load available services when modal opens
$effect(() => {
if (open) {
loadAvailableServices();
selectedServiceIds.clear();
}
});
async function loadAvailableServices() {
loading = true;
error = '';
// Extract year-month from report date (YYYY-MM)
const [year, month] = report.date.split('-');
const startOfMonth = `${year}-${month}-01`;
const endOfMonth = new Date(parseInt(year), parseInt(month), 0).toISOString().split('T')[0];
try {
const result = await servicesStore.fetch({
variables: {
filters: {
status: { exact: 'COMPLETED' },
date: { gte: startOfMonth, lte: endOfMonth }
}
}
});
// Filter to only services assigned to this team member and not already included
availableServices = (result.data?.services ?? []).filter((service) => {
const isAssigned = service.teamMembers?.some((m) => m.pk === teamMemberId);
const notIncluded = !includedServiceIds.has(service.id);
return isAssigned && notIncluded;
});
} catch (err) {
console.error('Failed to load services:', err);
error = 'Failed to load services';
} finally {
loading = false;
}
}
function formatItemDate(dateStr: string): string {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
function getServiceDisplayName(service: {
date: string;
accountAddressId: string | null;
}): string {
const dateStr = formatItemDate(service.date);
const accountInfo = service.accountAddressId
? accountLookup.get(service.accountAddressId)
: null;
const accountName = accountInfo?.accountName ?? 'Unknown Account';
return `${dateStr}, ${accountName}`;
}
function toggleService(serviceId: string) {
if (selectedServiceIds.has(serviceId)) {
selectedServiceIds.delete(serviceId);
} else {
selectedServiceIds.add(serviceId);
}
}
function selectAll() {
for (const service of availableServices) {
selectedServiceIds.add(service.id);
}
}
function selectNone() {
selectedServiceIds.clear();
}
async function handleSave() {
if (selectedServiceIds.size === 0) return;
saving = true;
error = '';
try {
// Combine existing service IDs with newly selected ones
const existingIds = report.services.map((s) => s.id);
const newIds = [...selectedServiceIds];
const allIds = [...existingIds, ...newIds];
await updateStore.mutate({
input: {
id: report.id,
serviceIds: allIds
}
});
open = false;
onSuccess();
} catch (err) {
console.error('Failed to add services:', err);
error = 'Failed to add services';
} finally {
saving = false;
}
}
function handleClose() {
open = false;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose();
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
transition:fade={{ duration: 150 }}
onmousedown={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
>
<div
class="max-h-[90vh] w-full max-w-lg overflow-hidden rounded-xl bg-theme-card shadow-xl"
transition:scale={{ duration: 150, start: 0.95 }}
>
<!-- Header -->
<div class="border-b border-theme px-6 py-4">
<h2 class="text-lg font-semibold text-theme">Add Services</h2>
<p class="mt-1 text-sm text-theme-muted">
Select completed services to add to this report.
</p>
</div>
<!-- Content -->
<div class="max-h-[60vh] overflow-y-auto p-6">
{#if loading}
<div class="flex items-center justify-center py-8">
<svg
class="h-8 w-8 animate-spin text-secondary-500"
style="fill: none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
{:else if availableServices.length === 0}
<p class="py-8 text-center text-sm text-theme-muted italic">
No additional services available for this team member in this month.
</p>
{:else}
<!-- Select All / None -->
<div class="mb-4 flex items-center gap-4">
<button
onclick={selectAll}
class="text-sm font-medium text-secondary-600 hover:underline dark:text-secondary-400"
>
Select All
</button>
<button
onclick={selectNone}
class="text-sm font-medium text-theme-muted hover:underline"
>
Select None
</button>
<span class="ml-auto text-sm text-theme-muted">
{selectedServiceIds.size} selected
</span>
</div>
<div class="space-y-2">
{#each availableServices as service (service.id)}
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme interactive p-3"
>
<input
type="checkbox"
checked={selectedServiceIds.has(service.id)}
onchange={() => toggleService(service.id)}
class="border-theme-muted h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
/>
<span class="flex-1 text-sm text-theme">
{getServiceDisplayName(service)}
</span>
</label>
{/each}
</div>
{/if}
{#if error}
<div class="mt-4 rounded-lg bg-error-500/10 p-3">
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 border-t border-theme px-6 py-4">
<button onclick={handleClose} class="btn-ghost" disabled={saving}> Cancel </button>
<button
onclick={handleSave}
class="btn-primary"
disabled={saving || selectedServiceIds.size === 0}
>
{#if saving}
Adding...
{:else}
Add {selectedServiceIds.size} Service{selectedServiceIds.size !== 1 ? 's' : ''}
{/if}
</button>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,134 @@
<script lang="ts">
import { CreateReportStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import type { Team$result } from '$houdini';
type TeamProfile = Team$result['teamProfiles'][number];
interface Props {
teamProfiles: TeamProfile[];
onSuccess?: () => void;
onCancel?: () => void;
}
let { teamProfiles, onSuccess, onCancel }: Props = $props();
let submitting = $state(false);
let error = $state('');
let teamMemberId = $state('');
let date = $state('');
// Non-admin team members only
let nonAdminTeamMembers = $derived(teamProfiles.filter((p) => p.role !== 'ADMIN'));
const createStore = new CreateReportStore();
// Format date for display (Dec 15, 2024)
function formatDate(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!teamMemberId || !date) {
error = 'Please select a team member and date';
return;
}
const res = await createStore.mutate({
input: {
teamMemberId,
date
}
});
if (res.errors?.length) {
error = res.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create report';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
</script>
<form onsubmit={handleSubmit} class="flex h-full flex-col">
<div class="flex-1 space-y-6 overflow-y-auto p-6">
<!-- Team Member -->
<div>
<label for="teamMember" class="mb-1.5 block text-sm font-medium text-theme">Team Member</label
>
<select
id="teamMember"
bind:value={teamMemberId}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
required
>
<option value="">Select team member...</option>
{#each nonAdminTeamMembers as profile (profile.id)}
<option value={profile.id}>{profile.fullName}</option>
{/each}
</select>
</div>
<!-- Date -->
<div>
<label for="date" class="mb-1.5 block text-sm font-medium text-theme">Report Date</label>
<input
type="date"
id="date"
bind:value={date}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
{#if date}
<p class="mt-1 text-sm text-theme-secondary">{formatDate(date)}</p>
{/if}
</div>
<!-- Info -->
<div class="rounded-lg bg-accent2-500/10 p-4">
<p class="text-sm text-accent2-600 dark:text-accent2-400">
After creating the report, you can add completed services and projects from the report
detail page.
</p>
</div>
{#if error}
<div class="rounded-lg bg-error-500/10 p-4">
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 border-t border-theme bg-theme p-6">
<button type="button" onclick={handleCancel} class="btn-ghost" disabled={submitting}>
Cancel
</button>
<button type="submit" class="btn-primary" disabled={submitting || !teamMemberId || !date}>
{#if submitting}
Creating...
{:else}
Create Report
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,266 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import TaskTemplateCard from './TaskTemplateCard.svelte';
interface TaskTemplate {
id: string;
description: string;
checklistDescription: string | null;
frequency?: string | null;
estimatedMinutes: number | null;
order: number;
}
interface AreaOrCategory {
id: string;
name: string;
taskTemplates: TaskTemplate[];
}
interface Props {
area: AreaOrCategory;
variant: 'service' | 'project';
isExpanded: boolean;
editingAreaName: string | null;
editingTaskId: string | null;
showNewTaskInput: string | null;
newTaskDescription: string;
onToggle: () => void;
onUpdateName: (name: string) => void;
onDelete: () => void;
onStartEditName: () => void;
onCancelEditName: () => void;
onShowNewTask: () => void;
onHideNewTask: () => void;
onNewTaskDescriptionChange: (desc: string) => void;
onCreateTask: () => void;
onStartEditTask: (taskId: string) => void;
onCancelEditTask: () => void;
onUpdateTask: (
taskId: string,
updates: {
description: string;
checklistDescription: string;
frequency?: string;
estimatedMinutes: number | null;
}
) => void;
onDeleteTask: (taskId: string) => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
onMoveTaskUp: (taskId: string) => void;
onMoveTaskDown: (taskId: string) => void;
}
let {
area,
variant,
isExpanded,
editingAreaName,
editingTaskId,
showNewTaskInput,
newTaskDescription,
onToggle,
onUpdateName,
onDelete,
onStartEditName,
onCancelEditName,
onShowNewTask,
onHideNewTask,
onNewTaskDescriptionChange,
onCreateTask,
onStartEditTask,
onCancelEditTask,
onUpdateTask,
onDeleteTask,
onMoveUp,
onMoveDown,
onMoveTaskUp,
onMoveTaskDown
}: Props = $props();
let isEditingName = $derived(editingAreaName === area.id);
let isShowingNewTask = $derived(showNewTaskInput === area.id);
let sortedTasks = $derived([...area.taskTemplates].sort((a, b) => a.order - b.order));
let areaLabel = $derived(variant === 'service' ? 'area' : 'category');
</script>
<div class="mb-4 rounded-lg border border-theme bg-theme-card">
<div class="flex items-center justify-between border-b border-theme px-4 py-3">
<button onclick={onToggle} class="flex flex-1 items-center gap-2">
<svg
class="h-4 w-4 text-theme-muted transition-transform {isExpanded ? 'rotate-90' : ''}"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{#if isEditingName}
<input
type="text"
value={area.name}
class="rounded border border-theme bg-theme px-2 py-1 text-sm font-semibold text-theme focus:border-secondary-500 focus:outline-none"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Enter') {
onUpdateName(e.currentTarget.value);
} else if (e.key === 'Escape') {
onCancelEditName();
}
}}
onblur={(e) => onUpdateName(e.currentTarget.value)}
/>
{:else}
<span
class="font-semibold text-theme"
role="button"
tabindex="0"
ondblclick={(e) => {
e.stopPropagation();
onStartEditName();
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
onStartEditName();
}
}}
>
{area.name}
</span>
{/if}
<span class="text-xs text-theme-muted">
({area.taskTemplates.length} tasks)
</span>
</button>
<div class="flex items-center gap-1">
<button
onclick={onShowNewTask}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Add task"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</button>
{#if onMoveUp}
<button
onclick={onMoveUp}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Move {areaLabel} up"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
</button>
{/if}
{#if onMoveDown}
<button
onclick={onMoveDown}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Move {areaLabel} down"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/if}
<button
onclick={onDelete}
class="rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label="Delete {areaLabel}"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if isExpanded}
<div class="p-3" transition:slide={{ duration: 150 }}>
<!-- New Task Input -->
{#if isShowingNewTask}
<div
class="mb-3 rounded-lg border border-dashed border-theme bg-theme p-3"
transition:slide={{ duration: 150 }}
>
<input
type="text"
value={newTaskDescription}
oninput={(e) => onNewTaskDescriptionChange(e.currentTarget.value)}
placeholder="Task description..."
class="placeholder-theme-muted w-full rounded border border-theme bg-theme-card px-3 py-2 text-sm text-theme focus:border-secondary-500 focus:outline-none"
onkeydown={(e) => {
if (e.key === 'Enter') {
onCreateTask();
} else if (e.key === 'Escape') {
onHideNewTask();
onNewTaskDescriptionChange('');
}
}}
/>
<div class="mt-2 flex justify-end gap-2">
<button
onclick={() => {
onHideNewTask();
onNewTaskDescriptionChange('');
}}
class="rounded px-2 py-1 text-xs text-theme-muted hover:text-theme"
>
Cancel
</button>
<button
onclick={onCreateTask}
class="rounded bg-secondary-500 px-2 py-1 text-xs text-white hover:bg-secondary-600"
>
Add Task
</button>
</div>
</div>
{/if}
<!-- Tasks -->
{#each sortedTasks as task, index (task.id)}
<TaskTemplateCard
{task}
{variant}
isEditing={editingTaskId === task.id}
onStartEdit={() => onStartEditTask(task.id)}
onCancelEdit={onCancelEditTask}
onSave={(updates) => onUpdateTask(task.id, updates)}
onDelete={() => onDeleteTask(task.id)}
onMoveUp={index === 0 ? undefined : () => onMoveTaskUp(task.id)}
onMoveDown={index === sortedTasks.length - 1 ? undefined : () => onMoveTaskDown(task.id)}
/>
{/each}
{#if area.taskTemplates.length === 0 && !isShowingNewTask}
<div class="py-4 text-center text-sm text-theme-muted">
No tasks yet. Click + to add one.
</div>
{/if}
</div>
{/if}
</div>

View File

@ -0,0 +1,287 @@
<script lang="ts">
interface TaskTemplate {
id: string;
description: string;
checklistDescription: string | null;
frequency?: string | null;
estimatedMinutes: number | null;
}
interface Props {
task: TaskTemplate;
variant: 'service' | 'project';
isEditing: boolean;
onStartEdit: () => void;
onCancelEdit: () => void;
onSave: (updates: {
description: string;
checklistDescription: string;
frequency?: string;
estimatedMinutes: number | null;
}) => void;
onDelete: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
}
let { task, variant, isEditing, onStartEdit, onCancelEdit, onSave, onDelete, onMoveUp, onMoveDown }: Props = $props();
function formatFrequency(frequency: string | null | undefined): string {
const lower = frequency?.toLowerCase();
switch (lower) {
case 'daily':
return 'Daily';
case 'weekly':
return 'Weekly';
case 'monthly':
return 'Monthly';
case 'quarterly':
return 'Quarterly';
case 'triannual':
return '3x/Year';
case 'annual':
return 'Annual';
case 'as_needed':
return 'As Needed';
default:
return frequency ?? 'Weekly';
}
}
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const updates: Parameters<typeof onSave>[0] = {
description: String(formData.get('description') ?? ''),
checklistDescription: String(formData.get('checklistDescription') ?? ''),
estimatedMinutes: formData.get('estimatedMinutes')
? parseInt(String(formData.get('estimatedMinutes')))
: null
};
if (variant === 'service') {
updates.frequency = String(formData.get('frequency') ?? 'weekly');
}
onSave(updates);
}
</script>
<div class="mb-2 rounded-lg border border-theme bg-theme p-3 last:mb-0">
{#if isEditing}
<!-- Edit Mode -->
<form onsubmit={handleSubmit}>
<div class="space-y-3">
<div>
<label for="task-desc-{task.id}" class="mb-1 block text-xs font-medium text-theme-muted"
>Description</label
>
<input
id="task-desc-{task.id}"
type="text"
name="description"
value={task.description}
class="w-full rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
required
/>
</div>
<div>
<label
for="task-checklist-{task.id}"
class="mb-1 block text-xs font-medium text-theme-muted">Checklist Description</label
>
<input
id="task-checklist-{task.id}"
type="text"
name="checklistDescription"
value={task.checklistDescription ?? ''}
class="w-full rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
placeholder="Optional checklist text..."
/>
</div>
{#if variant === 'service'}
<div class="flex gap-3">
<div class="flex-1">
<label
for="task-freq-{task.id}"
class="mb-1 block text-xs font-medium text-theme-muted">Frequency</label
>
<select
id="task-freq-{task.id}"
name="frequency"
class="w-full rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
>
<option value="daily" selected={task.frequency?.toLowerCase() === 'daily'}
>Daily</option
>
<option
value="weekly"
selected={(task.frequency?.toLowerCase() ?? 'weekly') === 'weekly'}>Weekly</option
>
<option value="monthly" selected={task.frequency?.toLowerCase() === 'monthly'}
>Monthly</option
>
<option value="quarterly" selected={task.frequency?.toLowerCase() === 'quarterly'}
>Quarterly</option
>
<option value="triannual" selected={task.frequency?.toLowerCase() === 'triannual'}
>3x/Year</option
>
<option value="annual" selected={task.frequency?.toLowerCase() === 'annual'}
>Annual</option
>
<option value="as_needed" selected={task.frequency?.toLowerCase() === 'as_needed'}
>As Needed</option
>
</select>
</div>
<div class="w-24">
<label
for="task-mins-{task.id}"
class="mb-1 block text-xs font-medium text-theme-muted">Est. Min</label
>
<input
id="task-mins-{task.id}"
type="number"
name="estimatedMinutes"
value={task.estimatedMinutes ?? ''}
min="0"
class="w-full rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
placeholder="—"
/>
</div>
</div>
{:else}
<div>
<label for="task-mins-{task.id}" class="mb-1 block text-xs font-medium text-theme-muted"
>Est. Minutes</label
>
<input
id="task-mins-{task.id}"
type="number"
name="estimatedMinutes"
value={task.estimatedMinutes ?? ''}
min="0"
class="w-24 rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
placeholder="—"
/>
</div>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={onCancelEdit}
class="rounded px-3 py-1 text-xs text-theme-muted hover:text-theme"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-secondary-500 px-3 py-1 text-xs text-white hover:bg-secondary-600"
>
Save
</button>
</div>
</div>
</form>
{:else}
<!-- Display Mode -->
<div class="flex items-start justify-between gap-2">
<button
type="button"
class="flex flex-1 cursor-pointer items-start gap-2 text-left"
onclick={onStartEdit}
>
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-theme-muted transition-transform"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="flex-1">
<span class="text-sm font-medium text-theme">
{task.description}
</span>
{#if task.checklistDescription}
<span class="mt-1 block text-xs text-theme-muted">
{task.checklistDescription}
</span>
{/if}
<span class="mt-2 flex flex-wrap gap-2">
{#if variant === 'service' && task.frequency}
<span
class="rounded-full bg-primary-500/20 px-2 py-0.5 text-xs text-primary-600 dark:text-primary-400"
>
{formatFrequency(task.frequency)}
</span>
{/if}
{#if task.estimatedMinutes}
<span
class="rounded-full bg-surface-100 px-2 py-0.5 text-xs text-surface-600 dark:bg-surface-700 dark:text-surface-300"
>
{task.estimatedMinutes} min
</span>
{/if}
</span>
</span>
</button>
<div class="flex items-center gap-1">
{#if onMoveUp}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onMoveUp?.();
}}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Move task up"
>
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
</button>
{/if}
{#if onMoveDown}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onMoveDown?.();
}}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Move task down"
>
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/if}
<button
onclick={onDelete}
class="rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
aria-label="Delete task"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,278 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import AreaSection from './AreaSection.svelte';
import type { SvelteSet } from 'svelte/reactivity';
interface TaskTemplate {
id: string;
description: string;
checklistDescription: string | null;
frequency?: string | null;
estimatedMinutes: number | null;
order: number;
}
interface AreaOrCategory {
id: string;
name: string;
order: number;
taskTemplates: TaskTemplate[];
}
interface Template {
id: string;
name: string;
description: string | null;
isActive: boolean;
areaTemplates?: AreaOrCategory[];
categoryTemplates?: AreaOrCategory[];
}
interface Props {
template: Template;
variant: 'service' | 'project';
expandedAreas: SvelteSet<string>;
editingTemplateName: string | null;
editingAreaName: string | null;
editingTaskId: string | null;
showNewAreaInput: string | null;
showNewTaskInput: string | null;
newAreaName: string;
newTaskDescription: string;
onToggleArea: (areaId: string) => void;
onStartEditTemplateName: () => void;
onCancelEditTemplateName: () => void;
onUpdateTemplateName: (name: string) => void;
onUpdateTemplateDescription: (description: string) => void;
onDeleteTemplate: () => void;
onStartEditAreaName: (areaId: string) => void;
onCancelEditAreaName: () => void;
onUpdateAreaName: (areaId: string, name: string) => void;
onDeleteArea: (areaId: string) => void;
onShowNewArea: () => void;
onHideNewArea: () => void;
onNewAreaNameChange: (name: string) => void;
onCreateArea: () => void;
onShowNewTask: (areaId: string) => void;
onHideNewTask: () => void;
onNewTaskDescriptionChange: (desc: string) => void;
onCreateTask: (areaId: string) => void;
onStartEditTask: (taskId: string) => void;
onCancelEditTask: () => void;
onUpdateTask: (
taskId: string,
updates: {
description: string;
checklistDescription: string;
frequency?: string;
estimatedMinutes: number | null;
}
) => void;
onDeleteTask: (taskId: string) => void;
onMoveAreaUp: (areaId: string) => void;
onMoveAreaDown: (areaId: string) => void;
onMoveTaskUp: (taskId: string, areaId: string) => void;
onMoveTaskDown: (taskId: string, areaId: string) => void;
}
let {
template,
variant,
expandedAreas,
editingTemplateName,
editingAreaName,
editingTaskId,
showNewAreaInput,
showNewTaskInput,
newAreaName,
newTaskDescription,
onToggleArea,
onStartEditTemplateName,
onCancelEditTemplateName,
onUpdateTemplateName,
onUpdateTemplateDescription,
onDeleteTemplate,
onStartEditAreaName,
onCancelEditAreaName,
onUpdateAreaName,
onDeleteArea,
onShowNewArea,
onHideNewArea,
onNewAreaNameChange,
onCreateArea,
onShowNewTask,
onHideNewTask,
onNewTaskDescriptionChange,
onCreateTask,
onStartEditTask,
onCancelEditTask,
onUpdateTask,
onDeleteTask,
onMoveAreaUp,
onMoveAreaDown,
onMoveTaskUp,
onMoveTaskDown
}: Props = $props();
let areas = $derived(
(template.areaTemplates ?? template.categoryTemplates ?? [])
.slice()
.sort((a, b) => a.order - b.order)
);
let isEditingName = $derived(editingTemplateName === template.id);
let isShowingNewArea = $derived(showNewAreaInput === template.id);
let areaLabel = $derived(variant === 'service' ? 'Area' : 'Category');
</script>
<!-- Template Header -->
<div class="border-b border-theme bg-theme-card px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{#if isEditingName}
<input
type="text"
value={template.name}
class="rounded-lg border border-theme bg-theme px-3 py-1 text-lg font-bold text-theme focus:border-secondary-500 focus:outline-none"
onkeydown={(e) => {
if (e.key === 'Enter') {
onUpdateTemplateName(e.currentTarget.value);
} else if (e.key === 'Escape') {
onCancelEditTemplateName();
}
}}
onblur={(e) => onUpdateTemplateName(e.currentTarget.value)}
/>
{:else}
<h2
class="cursor-pointer text-xl font-bold text-theme"
ondblclick={onStartEditTemplateName}
>
{template.name}
</h2>
{/if}
<span
class="rounded-full px-2 py-0.5 text-xs {template.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
>
{template.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<button
onclick={onDeleteTemplate}
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
aria-label="Delete template"
>
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<!-- Description -->
<div class="mt-3">
<textarea
placeholder="Add a description..."
rows="2"
class="placeholder-theme-muted w-full resize-none rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme-secondary focus:border-secondary-500 focus:text-theme focus:outline-none"
onblur={(e) => {
const newDesc = e.currentTarget.value;
if (newDesc !== (template.description ?? '')) {
onUpdateTemplateDescription(newDesc);
}
}}>{template.description ?? ''}</textarea
>
</div>
</div>
<div class="flex-1 overflow-y-auto p-6">
<!-- Areas/Categories -->
{#each areas as area, index (area.id)}
<AreaSection
{area}
{variant}
isExpanded={expandedAreas.has(area.id)}
{editingAreaName}
{editingTaskId}
{showNewTaskInput}
{newTaskDescription}
onToggle={() => onToggleArea(area.id)}
onUpdateName={(name) => onUpdateAreaName(area.id, name)}
onDelete={() => onDeleteArea(area.id)}
onStartEditName={() => onStartEditAreaName(area.id)}
onCancelEditName={onCancelEditAreaName}
onShowNewTask={() => onShowNewTask(area.id)}
{onHideNewTask}
{onNewTaskDescriptionChange}
onCreateTask={() => onCreateTask(area.id)}
{onStartEditTask}
{onCancelEditTask}
{onUpdateTask}
{onDeleteTask}
onMoveUp={index === 0 ? undefined : () => onMoveAreaUp(area.id)}
onMoveDown={index === areas.length - 1 ? undefined : () => onMoveAreaDown(area.id)}
onMoveTaskUp={(taskId) => onMoveTaskUp(taskId, area.id)}
onMoveTaskDown={(taskId) => onMoveTaskDown(taskId, area.id)}
/>
{/each}
<!-- Add Area/Category Button / Input -->
{#if isShowingNewArea}
<div
class="rounded-lg border border-dashed border-theme bg-theme-card p-4"
transition:slide={{ duration: 150 }}
>
<input
type="text"
value={newAreaName}
oninput={(e) => onNewAreaNameChange(e.currentTarget.value)}
placeholder="{areaLabel} name..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme focus:border-secondary-500 focus:outline-none"
onkeydown={(e) => {
if (e.key === 'Enter') {
onCreateArea();
} else if (e.key === 'Escape') {
onHideNewArea();
onNewAreaNameChange('');
}
}}
/>
<div class="mt-2 flex justify-end gap-2">
<button
onclick={() => {
onHideNewArea();
onNewAreaNameChange('');
}}
class="rounded px-3 py-1 text-sm text-theme-muted hover:text-theme"
>
Cancel
</button>
<button
onclick={onCreateArea}
class="rounded bg-secondary-500 px-3 py-1 text-sm text-white hover:bg-secondary-600"
>
Add {areaLabel}
</button>
</div>
</div>
{:else}
<button
onclick={onShowNewArea}
class="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-theme p-4 text-sm text-theme-muted transition-colors hover:border-secondary-500 hover:bg-theme-card hover:text-secondary-500"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Add {areaLabel}
</button>
{/if}
</div>

View File

@ -0,0 +1,129 @@
<script lang="ts">
import { slide } from 'svelte/transition';
interface AreaOrCategory {
id: string;
name: string;
order: number;
taskTemplates: { id: string }[];
}
interface Template {
id: string;
name: string;
isActive: boolean;
areaTemplates?: AreaOrCategory[];
categoryTemplates?: AreaOrCategory[];
}
interface Props {
templates: Template[];
selectedId: string | null;
variant: 'service' | 'project';
showNewInput: boolean;
newName: string;
onSelect: (id: string) => void;
onCreate: () => void;
onShowNewInput: (show: boolean) => void;
onNewNameChange: (name: string) => void;
}
let {
templates,
selectedId,
variant,
showNewInput,
newName,
onSelect,
onCreate,
onShowNewInput,
onNewNameChange
}: Props = $props();
let borderColorClass = $derived(
variant === 'service' ? 'border-l-secondary-500' : 'border-l-accent-500'
);
let bgColorClass = $derived(variant === 'service' ? 'bg-secondary-500/10' : 'bg-accent-500/10');
let areaLabel = $derived(variant === 'service' ? 'areas' : 'categories');
function getAreas(template: Template): AreaOrCategory[] {
return template.areaTemplates ?? template.categoryTemplates ?? [];
}
function getTaskCount(template: Template): number {
return getAreas(template).reduce((sum, a) => sum + a.taskTemplates.length, 0);
}
</script>
<!-- New Template Input -->
{#if showNewInput}
<div class="border-b border-theme p-3" transition:slide={{ duration: 150 }}>
<input
type="text"
value={newName}
oninput={(e) => onNewNameChange(e.currentTarget.value)}
placeholder="Template name..."
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme focus:border-secondary-500 focus:ring-1 focus:ring-secondary-500 focus:outline-none"
onkeydown={(e) => {
if (e.key === 'Enter') {
onCreate();
} else if (e.key === 'Escape') {
onShowNewInput(false);
onNewNameChange('');
}
}}
/>
<div class="mt-2 flex justify-end gap-2">
<button
onclick={() => {
onShowNewInput(false);
onNewNameChange('');
}}
class="rounded px-3 py-1 text-sm text-theme-muted hover:text-theme"
>
Cancel
</button>
<button
onclick={onCreate}
class="rounded bg-secondary-500 px-3 py-1 text-sm text-white hover:bg-secondary-600"
>
Create
</button>
</div>
</div>
{/if}
<!-- Template List -->
<div class="flex-1 overflow-y-auto p-2">
{#each templates as template (template.id)}
<button
onclick={() => onSelect(template.id)}
class="mb-1 w-full rounded-lg border-l-4 p-3 text-left transition-colors {selectedId ===
template.id
? `${borderColorClass} ${bgColorClass}`
: 'border-l-transparent hover:bg-black/5 dark:hover:bg-white/10'}"
>
<span class="flex items-center justify-between">
<span class="font-medium text-theme">{template.name}</span>
<span
class="rounded-full px-2 py-0.5 text-xs {template.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
>
{template.isActive ? 'Active' : 'Inactive'}
</span>
</span>
<span class="mt-1 block text-xs text-theme-muted">
{getAreas(template).length}
{areaLabel},
{getTaskCount(template)} tasks
</span>
</button>
{/each}
{#if templates.length === 0}
<div class="py-8 text-center text-theme-muted">
<p>No {variant} templates yet</p>
<p class="text-sm">Click + to create one</p>
</div>
{/if}
</div>

View File

@ -0,0 +1,523 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { GenerateServicesByMonthStore } from '$houdini';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
interface Schedule {
id: string;
name: string | null;
startDate: string;
endDate: string | null;
mondayService: boolean;
tuesdayService: boolean;
wednesdayService: boolean;
thursdayService: boolean;
fridayService: boolean;
saturdayService: boolean;
sundayService: boolean;
weekendService: boolean;
}
interface Address {
id: string;
name: string | null;
streetAddress: string;
city: string;
state: string;
zipCode: string;
isActive: boolean;
schedules: Schedule[];
}
interface Account {
id: string;
name: string;
addresses: Address[];
}
interface Props {
open: boolean;
accounts: Account[];
preselectedAddressId?: string;
preselectedScheduleId?: string;
initialMonth?: number;
initialYear?: number;
onClose: () => void;
onSuccess: (totalCount: number) => void;
}
let {
open = $bindable(false),
accounts,
preselectedAddressId,
preselectedScheduleId,
initialMonth,
initialYear,
onClose,
onSuccess
}: Props = $props();
// State
let now = new Date();
// Default to next month if no initial values provided
let defaultMonth = now.getMonth() + 2 > 12 ? 1 : now.getMonth() + 2;
let defaultYear = now.getMonth() + 2 > 12 ? now.getFullYear() + 1 : now.getFullYear();
let selectedMonth = $state(initialMonth ?? defaultMonth);
let selectedYear = $state(initialYear ?? defaultYear);
// Update selected month/year when initial values change
$effect(() => {
if (initialMonth !== undefined) {
selectedMonth = initialMonth;
}
if (initialYear !== undefined) {
selectedYear = initialYear;
}
});
let selectedItems = new SvelteSet<string>(); // "addressId:scheduleId"
let generating = $state(false);
let results = $state<
{ key: string; accountName: string; success: boolean; count?: number; error?: string }[]
>([]);
let showResults = $state(false);
const generateStore = new GenerateServicesByMonthStore();
// Filter accounts to only show those with active schedules
// A schedule is active if it has no endDate OR if the endDate is in the future
let accountsWithActiveSchedules = $derived(
accounts
.map((account) => ({
...account,
addresses: account.addresses
.filter((addr) => addr.isActive)
.map((addr) => ({
...addr,
activeSchedules: addr.schedules.filter((s) => {
if (!s.endDate) return true;
// Compare endDate with today
const endDate = new Date(s.endDate + 'T00:00:00');
const today = new SvelteDate();
today.setHours(0, 0, 0, 0);
return endDate >= today;
})
}))
.filter((addr) => addr.activeSchedules.length > 0)
}))
.filter((account) => account.addresses.length > 0)
);
// Initialize preselection
$effect(() => {
if (open && preselectedAddressId && preselectedScheduleId) {
selectedItems.add(`${preselectedAddressId}:${preselectedScheduleId}`);
}
});
// Reset state when modal opens
$effect(() => {
if (open) {
results = [];
showResults = false;
if (!preselectedAddressId) {
selectedItems.clear();
}
}
});
function toggleSelection(addressId: string, scheduleId: string) {
const key = `${addressId}:${scheduleId}`;
if (selectedItems.has(key)) {
selectedItems.delete(key);
} else {
selectedItems.add(key);
}
}
function selectAll() {
for (const account of accountsWithActiveSchedules) {
for (const address of account.addresses) {
for (const schedule of address.activeSchedules) {
selectedItems.add(`${address.id}:${schedule.id}`);
}
}
}
}
function selectNone() {
selectedItems.clear();
}
function getScheduleDays(schedule: Schedule): string[] {
const days: string[] = [];
if (schedule.mondayService) days.push('Mon');
if (schedule.tuesdayService) days.push('Tue');
if (schedule.wednesdayService) days.push('Wed');
if (schedule.thursdayService) days.push('Thu');
if (schedule.fridayService) days.push('Fri');
if (schedule.saturdayService) days.push('Sat');
if (schedule.sundayService) days.push('Sun');
if (schedule.weekendService && !days.includes('Fri')) days.push('Weekend');
return days;
}
// Calculate estimated service count for a schedule
// Mirrors backend logic: Mon-Thu use day flags, Fri uses weekend_service OR friday flag,
// Sat-Sun use day flags only if weekend_service is false
function estimateServiceCount(schedule: Schedule): number {
const year = selectedYear;
const month = selectedMonth;
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
// Map JS getDay() (Sun=0..Sat=6) to day flags
// Index: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
const dayFlags = [
schedule.sundayService,
schedule.mondayService,
schedule.tuesdayService,
schedule.wednesdayService,
schedule.thursdayService,
schedule.fridayService,
schedule.saturdayService
];
let count = 0;
for (let d = new SvelteDate(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) {
const jsDay = d.getDay(); // Sun=0, Mon=1, ..., Sat=6
let scheduleToday = false;
if (jsDay >= 1 && jsDay <= 4) {
// Mon-Thu: use the day flag
scheduleToday = dayFlags[jsDay];
} else if (jsDay === 5) {
// Friday: weekend_service takes precedence, otherwise use friday flag
scheduleToday = schedule.weekendService || dayFlags[5];
} else {
// Sat (6) or Sun (0): only use day flag if weekend_service is OFF
if (!schedule.weekendService) {
scheduleToday = dayFlags[jsDay];
}
}
if (scheduleToday) {
count++;
}
}
return count;
}
async function handleGenerate() {
if (selectedItems.size === 0) return;
generating = true;
results = [];
for (const key of selectedItems) {
const [addressId, scheduleId] = key.split(':');
// Find account name for display
let accountName = 'Unknown';
for (const account of accounts) {
if (account.addresses.some((a) => a.id === addressId)) {
accountName = account.name;
break;
}
}
try {
const res = await generateStore.mutate({
input: {
accountAddressId: addressId,
scheduleId: scheduleId,
year: selectedYear,
month: selectedMonth
}
});
const count = res.data?.generateServicesByMonth?.length ?? 0;
results = [...results, { key, accountName, success: true, count }];
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to generate';
results = [...results, { key, accountName, success: false, error: errorMessage }];
}
}
generating = false;
showResults = true;
// Calculate total and notify
const totalCount = results.reduce((sum, r) => sum + (r.count ?? 0), 0);
if (totalCount > 0) {
onSuccess(totalCount);
}
}
function handleClose() {
if (generating) return;
open = false;
onClose();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open && !generating) {
handleClose();
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && !generating) {
handleClose();
}
}
// Month names
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
// Year options (current year and next 2)
let yearOptions = $derived([now.getFullYear(), now.getFullYear() + 1, now.getFullYear() + 2]);
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
transition:fade={{ duration: 150 }}
onclick={handleBackdropClick}
role="presentation"
>
<div
class="flex max-h-[90vh] w-full max-w-2xl flex-col rounded-xl border border-theme bg-theme shadow-theme-lg"
transition:scale={{ duration: 150, start: 0.95 }}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
<h2 id="modal-title" class="text-lg font-semibold text-theme">Generate Services</h2>
<button
onclick={handleClose}
disabled={generating}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme disabled:opacity-50 dark:hover:bg-white/10"
aria-label="Close"
>
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-6">
{#if showResults}
<!-- Results View -->
<div class="space-y-4">
<h3 class="font-medium text-theme">Generation Results</h3>
<div class="space-y-2">
{#each results as result (result.key)}
<div class="flex items-center justify-between rounded-lg border border-theme p-3">
<span class="text-theme">{result.accountName}</span>
{#if result.success}
<span
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
{result.count} service{result.count !== 1 ? 's' : ''} created
</span>
{:else}
<span
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{result.error}
</span>
{/if}
</div>
{/each}
</div>
{#if results.length > 0}
{@const successCount = results.filter((r) => r.success).length}
{@const totalServices = results.reduce((sum, r) => sum + (r.count ?? 0), 0)}
<div class="rounded-lg bg-theme-card p-4 text-center">
<p class="text-lg font-semibold text-theme">
{totalServices} service{totalServices !== 1 ? 's' : ''} generated
</p>
<p class="text-sm text-theme-muted">
{successCount} of {results.length} address{results.length !== 1 ? 'es' : ''} succeeded
</p>
</div>
{/if}
</div>
{:else}
<!-- Selection View -->
<div class="space-y-6">
<!-- Month/Year Selection -->
<div class="flex gap-4">
<div class="flex-1">
<label for="month-select" class="mb-1 block text-sm font-medium text-theme"
>Month</label
>
<select
id="month-select"
bind:value={selectedMonth}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme"
>
{#each months as month, i (month)}
<option value={i + 1}>{month}</option>
{/each}
</select>
</div>
<div class="w-32">
<label for="year-select" class="mb-1 block text-sm font-medium text-theme"
>Year</label
>
<select
id="year-select"
bind:value={selectedYear}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme"
>
{#each yearOptions as year (year)}
<option value={year}>{year}</option>
{/each}
</select>
</div>
</div>
<!-- Selection Controls -->
<div class="flex items-center justify-between">
<p class="text-sm text-theme-muted">
{selectedItems.size} address{selectedItems.size !== 1 ? 'es' : ''} selected
</p>
<div class="flex gap-2">
<button
onclick={selectAll}
class="text-sm text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
>
Select All
</button>
<span class="text-theme-muted">|</span>
<button
onclick={selectNone}
class="text-sm text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
>
Select None
</button>
</div>
</div>
<!-- Address List -->
<div class="space-y-3">
{#if accountsWithActiveSchedules.length === 0}
<div class="rounded-lg border border-theme bg-theme-card p-8 text-center">
<p class="text-theme-muted">No accounts with active schedules found</p>
</div>
{:else}
{#each accountsWithActiveSchedules as account (account.id)}
<div class="rounded-lg border border-theme">
<div class="border-b border-theme bg-theme-card px-4 py-2">
<h4 class="font-medium text-theme">{account.name}</h4>
</div>
<div class="divide-theme divide-y">
{#each account.addresses as address (address.id)}
{#each address.activeSchedules as schedule (schedule.id)}
{@const key = `${address.id}:${schedule.id}`}
{@const isSelected = selectedItems.has(key)}
{@const days = getScheduleDays(schedule)}
{@const estimatedCount = estimateServiceCount(schedule)}
<label
class="flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors hover:bg-black/5 dark:hover:bg-white/5"
>
<input
type="checkbox"
checked={isSelected}
onchange={() => toggleSelection(address.id, schedule.id)}
class="mt-1 h-4 w-4 rounded border-theme text-primary-500"
/>
<span class="min-w-0 flex-1">
<span class="text-sm font-medium text-theme">
{address.name || address.streetAddress}
</span>
<span class="text-xs text-theme-muted">
{address.streetAddress}, {address.city}, {address.state}
</span>
<span class="mt-1 flex flex-wrap items-center gap-1">
{#each days as day (day)}
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
>
{day}
</span>
{/each}
<span class="ml-2 text-xs text-theme-muted">
~{estimatedCount} service{estimatedCount !== 1 ? 's' : ''}
</span>
</span>
</span>
</label>
{/each}
{/each}
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
{#if showResults}
<button
onclick={handleClose}
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600"
>
Done
</button>
{:else}
<button
onclick={handleClose}
disabled={generating}
class="rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
<button
onclick={handleGenerate}
disabled={generating || selectedItems.size === 0}
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if generating}
<span class="flex items-center gap-2">
<IconSpinner class="h-4 w-4" />
Generating...
</span>
{:else}
Generate Services
{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,559 @@
<script lang="ts">
import { CreateServiceStore, UpdateServiceStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas.svelte';
import { fromGlobalId } from '$lib/utils/relay';
import type { GetService$result, Customers$result, Accounts$result, Team$result } from '$houdini';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
type Service = NonNullable<GetService$result['service']>;
type Customer = Customers$result['customers'][number];
type Account = Accounts$result['accounts'][number];
type TeamProfile = Team$result['teamProfiles'][number];
interface Props {
service?: Service;
customers: Customer[];
accounts: Account[];
teamProfiles: TeamProfile[];
onSuccess?: () => void;
onCancel?: () => void;
}
let { service, customers, accounts, teamProfiles, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!service);
let submitting = $state(false);
let error = $state('');
// Helper to find customer Relay Global ID from UUID
function findCustomerGlobalId(accountAddressUuid: string | null | undefined): string {
if (!accountAddressUuid) return '';
// Find account that contains this address, then get its customer
for (const account of accounts) {
const hasAddress = account.addresses?.some((a) => fromGlobalId(a.id) === accountAddressUuid);
if (hasAddress && account.customerId) {
const customer = customers.find((c) => fromGlobalId(c.id) === account.customerId);
return customer?.id ?? '';
}
}
return '';
}
// Helper to find address Relay Global ID from UUID
function findAddressGlobalId(addressUuid: string | null | undefined): string {
if (!addressUuid) return '';
for (const account of accounts) {
const address = account.addresses?.find((a) => fromGlobalId(a.id) === addressUuid);
if (address) return address.id;
}
return '';
}
// Helper to find account that contains an address UUID
function findAccountForAddressUuid(addressUuid: string | null | undefined): string {
if (!addressUuid) return '';
for (const account of accounts) {
const hasAddress = account.addresses?.some((a) => fromGlobalId(a.id) === addressUuid);
if (hasAddress) return account.id;
}
return '';
}
// Helper to convert team member UUIDs (pk) to Relay Global IDs
function findTeamMemberGlobalIds(memberPks: { pk: string }[] | null | undefined): string[] {
if (!memberPks) return [];
return memberPks
.map((m) => {
const profile = teamProfiles.find((p) => fromGlobalId(p.id) === m.pk);
return profile?.id;
})
.filter((id): id is string => !!id);
}
// Form fields
let customerId = $state(findCustomerGlobalId(service?.accountAddressId));
let selectedAccountId = $state(findAccountForAddressUuid(service?.accountAddressId));
let accountAddressId = $state(findAddressGlobalId(service?.accountAddressId));
let date = $state(service?.date ?? '');
let notes = $state(service?.notes ?? '');
let selectedTeamMemberIds = $state<string[]>(findTeamMemberGlobalIds(service?.teamMembers));
// Filter accounts for selected customer
let customerAccounts = $derived(() => {
if (!customerId) return [];
const customerUuid = fromGlobalId(customerId);
return accounts.filter((a) => a.customerId === customerUuid);
});
// Get addresses for selected account
let accountAddresses = $derived(() => {
if (!selectedAccountId) return [];
const account = customerAccounts().find((a) => a.id === selectedAccountId);
return account?.addresses ?? [];
});
// Get currently selected address details for display
let selectedAddressDetails = $derived(() => {
if (!accountAddressId) return null;
for (const account of customerAccounts()) {
const address = account.addresses?.find((a) => a.id === accountAddressId);
if (address) {
return { account, address };
}
}
return null;
});
// When customer changes, reset account and address
function handleCustomerChange() {
selectedAccountId = '';
accountAddressId = '';
}
// When account changes, auto-select if only one address, otherwise reset
function handleAccountChange() {
const addresses = accountAddresses();
if (addresses.length === 1) {
accountAddressId = addresses[0].id;
} else {
accountAddressId = '';
}
}
// Non-admin team members only (for display)
let nonAdminTeamMembers = $derived(teamProfiles.filter((p) => p.role !== 'ADMIN'));
// Get admin profile ID to auto-include on create
let adminProfileId = $derived(teamProfiles.find((p) => p.role === 'ADMIN')?.id ?? null);
// Track if service is dispatched (admin profile is in team members)
let isDispatched = $derived(
adminProfileId ? selectedTeamMemberIds.includes(adminProfileId) : false
);
function toggleDispatched() {
if (!adminProfileId) return;
if (isDispatched) {
// When un-dispatching, remove admin and clear all team members
selectedTeamMemberIds = [];
} else {
selectedTeamMemberIds = [...selectedTeamMemberIds, adminProfileId];
}
}
const createStore = new CreateServiceStore();
const updateStore = new UpdateServiceStore();
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
error = '';
try {
if (!date) {
error = 'Please provide a date';
return;
}
if (!isEdit && !accountAddressId) {
error = 'Please select an account and address';
return;
}
if (isEdit && service) {
// Update existing service - only date, notes, and team members
const res = await updateStore.mutate({
input: {
id: service.id,
date,
notes: notes.trim() || null,
teamMemberIds: selectedTeamMemberIds
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
} else {
// Create new service
// Get account ID from the selected account
const selectedAccount = customerAccounts().find((a) => a.id === selectedAccountId);
// Build team member IDs, always including admin profile
const teamMemberIds = [...selectedTeamMemberIds];
if (adminProfileId && !teamMemberIds.includes(adminProfileId)) {
teamMemberIds.push(adminProfileId);
}
const res = await createStore.mutate({
input: {
accountId: selectedAccount?.id ?? null,
accountAddressId,
date,
status: 'SCHEDULED',
notes: notes.trim() || null,
teamMemberIds: teamMemberIds.length > 0 ? teamMemberIds : null
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
}
offCanvas.closeRight();
onSuccess?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save service';
} finally {
submitting = false;
}
}
function handleCancel() {
offCanvas.closeRight();
onCancel?.();
}
function toggleTeamMember(id: string) {
if (selectedTeamMemberIds.includes(id)) {
selectedTeamMemberIds = selectedTeamMemberIds.filter((m) => m !== id);
} else {
selectedTeamMemberIds = [...selectedTeamMemberIds, id];
}
}
</script>
<div class="min-w-0 space-y-6">
{#if error}
<div class="alert-error">
{error}
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<!-- Customer (only for create) -->
{#if !isEdit}
<div>
<label for="customerId" class="mb-1.5 block text-sm font-medium text-theme">
Customer <span class="text-red-500">*</span>
</label>
<div class="relative w-full">
<select
id="customerId"
bind:value={customerId}
onchange={handleCustomerChange}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select a customer</option>
{#each customers as customer (customer.id)}
<option value={customer.id}>{customer.name}</option>
{/each}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
<!-- Account/Address Selection (only for create) -->
{#if customerId}
<div class="space-y-4 rounded-lg border border-theme bg-theme-card p-4">
<p class="text-sm font-medium text-theme">Service Location</p>
{#if customerAccounts().length > 0}
<!-- Account Selection -->
<div>
<label for="selectedAccountId" class="mb-1.5 block text-sm text-theme-secondary">
Account <span class="text-red-500">*</span>
</label>
<div class="relative w-full">
<select
id="selectedAccountId"
bind:value={selectedAccountId}
onchange={handleAccountChange}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select an account</option>
{#each customerAccounts() as account (account.id)}
<option value={account.id}>{account.name}</option>
{/each}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
<!-- Address Selection (shown when account is selected) -->
{#if selectedAccountId && accountAddresses().length > 0}
<div>
<label for="accountAddressId" class="mb-1.5 block text-sm text-theme-secondary">
Address <span class="text-red-500">*</span>
</label>
{#if accountAddresses().length === 1}
<!-- Single address - show as confirmation -->
{@const address = accountAddresses()[0]}
<div
class="rounded-lg border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="flex items-start gap-2">
<svg
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary-500"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<div class="text-sm">
<p class="font-medium text-theme">{address.streetAddress}</p>
<p class="text-theme-secondary">
{address.city}, {address.state}
{address.zipCode}
</p>
</div>
</div>
</div>
{:else}
<!-- Multiple addresses - show dropdown -->
<div class="relative w-full">
<select
id="accountAddressId"
bind:value={accountAddressId}
disabled={submitting}
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select an address</option>
{#each accountAddresses() as address (address.id)}
<option value={address.id}>
{address.streetAddress}, {address.city}, {address.state}
</option>
{/each}
</select>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg
class="h-4 w-4 text-theme-muted"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
{/if}
</div>
<!-- Show selected address confirmation (for multi-address accounts) -->
{#if accountAddresses().length > 1 && selectedAddressDetails()}
{@const details = selectedAddressDetails()}
<div
class="rounded-lg border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="flex items-start gap-2">
<svg
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary-500"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<div class="text-sm">
<p class="font-medium text-theme">{details?.address.streetAddress}</p>
<p class="text-theme-secondary">
{details?.address.city}, {details?.address.state}
{details?.address.zipCode}
</p>
</div>
</div>
</div>
{/if}
{:else if selectedAccountId && accountAddresses().length === 0}
<p class="text-sm text-warning">
This account has no addresses. Please select a different account or add an address
to this account first.
</p>
{/if}
{:else}
<p class="text-sm text-warning">
This customer has no accounts. Please create an account first.
</p>
{/if}
</div>
{/if}
{/if}
<!-- Date -->
<div>
<label for="date" class="mb-1.5 block text-sm font-medium text-theme">
Date <span class="text-red-500">*</span>
</label>
<input
id="date"
type="date"
bind:value={date}
disabled={submitting}
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<!-- Dispatched Toggle -->
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-card p-3">
<div>
<p class="text-sm font-medium text-theme">Dispatched</p>
<p class="text-xs text-theme-muted">Required before assigning team members</p>
</div>
<button
type="button"
onclick={toggleDispatched}
disabled={submitting}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 {isDispatched
? 'bg-emerald-500'
: 'bg-gray-200 dark:bg-gray-700'}"
role="switch"
aria-checked={isDispatched}
aria-label="Toggle dispatched status"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {isDispatched
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
<!-- Team Members -->
<div>
<p class="mb-1.5 text-sm font-medium text-theme">Team Members</p>
<div
class="space-y-2 rounded-lg border border-theme bg-theme-card p-3 {!isDispatched
? 'opacity-50'
: ''}"
>
{#if !isDispatched}
<p class="text-sm text-theme-muted italic">Enable "Dispatched" to assign team members</p>
{:else if nonAdminTeamMembers.length > 0}
{#each nonAdminTeamMembers as member (member.id)}
<label class="flex cursor-pointer items-center gap-3 rounded-lg interactive p-2">
<input
type="checkbox"
checked={selectedTeamMemberIds.includes(member.id)}
onchange={() => toggleTeamMember(member.id)}
disabled={submitting}
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500"
/>
<span class="text-sm text-theme">{member.fullName}</span>
{#if member.role === 'TEAM_LEADER'}
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
>
TL
</span>
{/if}
</label>
{/each}
{:else}
<p class="text-sm text-theme-muted">No team members available</p>
{/if}
</div>
</div>
<!-- Notes -->
<div>
<label for="notes" class="mb-1.5 block text-sm font-medium text-theme">Notes</label>
<textarea
id="notes"
bind:value={notes}
disabled={submitting}
rows="3"
placeholder="Optional notes about this service"
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
</div>
<!-- Submit Buttons -->
<div class="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={submitting}
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
<IconSpinner class="h-4 w-4" />
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
</span>
{:else}
{isEdit ? 'Save Changes' : 'Create Service'}
{/if}
</button>
<button
type="button"
onclick={handleCancel}
disabled={submitting}
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
>
Cancel
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,328 @@
<script lang="ts">
import AssignColumnHeader from './AssignColumnHeader.svelte';
import AssignServiceGroup from './AssignServiceGroup.svelte';
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
import type { SvelteSet } from 'svelte/reactivity';
type EnrichedService = {
id: string;
date: string;
status: string;
notes: string | null;
teamMembers: { pk: string }[];
accountAddressId: string;
accountName: string;
addressName: string | null;
address: string | null;
};
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
interface Props {
column: ColumnType;
services: EnrichedService[];
groupedServices: Map<string, EnrichedService[]>;
expandedGroups: SvelteSet<string>;
updatingServices: Set<string>;
openTeamMemberDropdown: string | null;
editingDateServiceId?: string | null;
// Selection props
selectedServices: SvelteSet<string>;
isBulkOperating?: boolean;
// For Ready to Assign bulk operations
nonAdminTeamMembers?: { id: string; fullName: string }[];
bulkSelectedTeamMember?: string | null;
showBulkTeamMemberDropdown?: boolean;
// Callbacks
onToggleGroup: (groupKey: string) => void;
getTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
getNonDispatchTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
getAvailableTeamMembers: (service: EnrichedService) => { pk: string; name: string }[];
getStagedTeamMemberDetails: (serviceId: string) => { pk: string; name: string }[];
hasStagedMembers: (serviceId: string) => boolean;
onAddDispatch: (service: EnrichedService) => void;
onRemoveDispatch: (service: EnrichedService) => void;
onSubmitStaged: (service: EnrichedService) => void;
onRemoveNonDispatch: (service: EnrichedService) => void;
onStageTeamMember: (serviceId: string, memberPk: string) => void;
onUnstageTeamMember: (serviceId: string, memberPk: string) => void;
onToggleDropdown: (serviceId: string | null) => void;
// Selection callbacks
onToggleSelection: (serviceId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
onBulkAction: () => void;
// For Ready to Assign
onBulkTeamMemberSelect?: (memberPk: string) => void;
onToggleBulkTeamMemberDropdown?: () => void;
// Unassigned column specific
onUpdateDate?: (service: EnrichedService, newDate: string) => void;
onDeleteService?: (service: EnrichedService) => void;
onStartEditDate?: (serviceId: string) => void;
onCancelEditDate?: () => void;
}
let {
column,
services,
groupedServices,
expandedGroups,
updatingServices,
openTeamMemberDropdown,
editingDateServiceId = null,
selectedServices,
isBulkOperating = false,
nonAdminTeamMembers = [],
bulkSelectedTeamMember = null,
showBulkTeamMemberDropdown = false,
onToggleGroup,
getTeamMemberNames,
getNonDispatchTeamMemberNames,
getAvailableTeamMembers,
getStagedTeamMemberDetails,
hasStagedMembers,
onAddDispatch,
onRemoveDispatch,
onSubmitStaged,
onRemoveNonDispatch,
onStageTeamMember,
onUnstageTeamMember,
onToggleDropdown,
onToggleSelection,
onSelectAll,
onClearSelection,
onBulkAction,
onBulkTeamMemberSelect,
onToggleBulkTeamMemberDropdown,
onUpdateDate,
onDeleteService,
onStartEditDate,
onCancelEditDate
}: Props = $props();
let hasSelection = $derived(selectedServices.size > 0);
let allSelected = $derived(services.length > 0 && selectedServices.size === services.length);
function getEmptyIcon(): string {
switch (column) {
case 'unassigned':
case 'assigned':
return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
case 'readyToAssign':
return 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z';
}
}
function getEmptyMessage(): string {
switch (column) {
case 'unassigned':
return 'No unassigned services';
case 'readyToAssign':
return 'No services ready to assign';
case 'assigned':
return 'No assigned services';
}
}
function getBulkActionLabel(): string {
switch (column) {
case 'unassigned':
return 'Add Dispatch';
case 'readyToAssign':
return 'Assign';
case 'assigned':
return 'Remove Team';
}
}
function getBulkActionDisabled(): boolean {
if (isBulkOperating) return true;
if (selectedServices.size === 0) return true;
return column === 'readyToAssign' && !bulkSelectedTeamMember;
}
function getSelectedTeamMemberName(): string {
if (!bulkSelectedTeamMember) return 'Select team member...';
const member = nonAdminTeamMembers.find((m) => {
// Extract pk from global id
const parts = atob(m.id).split(':');
return parts[1] === bulkSelectedTeamMember;
});
return member?.fullName ?? 'Unknown';
}
</script>
<div class="flex w-1/3 flex-col overflow-hidden rounded-xl bg-theme-card shadow-md">
<AssignColumnHeader {column} count={services.length} />
<!-- Bulk Action Bar -->
{#if services.length > 0}
<div class="border-b border-theme bg-theme-card px-3 py-2">
<div class="flex items-center justify-between gap-2">
<!-- Select All / Clear -->
<div class="flex items-center gap-2">
<button
onclick={() => (allSelected ? onClearSelection() : onSelectAll())}
class="flex items-center gap-1.5 rounded interactive px-2 py-1 text-xs font-medium {hasSelection
? 'text-primary-600 dark:text-primary-400'
: 'text-theme-muted'}"
>
<svg
class="h-4 w-4"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
{#if allSelected}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else if hasSelection}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else}
<circle cx="12" cy="12" r="9" />
{/if}
</svg>
{#if hasSelection}
<span>{selectedServices.size} selected</span>
{:else}
<span>Select all</span>
{/if}
</button>
{#if hasSelection}
<button onclick={onClearSelection} class="text-xs text-theme-muted hover:text-theme">
Clear
</button>
{/if}
</div>
<!-- Bulk Action -->
{#if hasSelection}
<div class="flex items-center gap-2">
{#if column === 'readyToAssign'}
<!-- Team member dropdown for bulk assignment -->
<div class="relative">
<button
onclick={(e) => {
e.stopPropagation();
onToggleBulkTeamMemberDropdown?.();
}}
class="flex items-center gap-1 rounded border border-theme interactive bg-theme px-2 py-1 text-xs font-medium text-theme"
>
<span class="max-w-[100px] truncate">{getSelectedTeamMemberName()}</span>
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if showBulkTeamMemberDropdown}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
class="absolute top-full right-0 z-30 mt-1 max-h-48 w-48 overflow-y-auto rounded-lg border border-theme bg-theme-card py-1 shadow-lg"
>
{#each nonAdminTeamMembers as member (member.id)}
{@const memberPk = atob(member.id).split(':')[1]}
<button
onclick={() => onBulkTeamMemberSelect?.(memberPk)}
class="block w-full px-3 py-2 text-left text-sm text-theme hover:bg-black/5 dark:hover:bg-white/10 {bulkSelectedTeamMember ===
memberPk
? 'bg-primary-50 dark:bg-primary-900/20'
: ''}"
>
{member.fullName}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<button
onclick={onBulkAction}
disabled={getBulkActionDisabled()}
class="flex items-center gap-1 rounded bg-primary-600 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isBulkOperating}
<IconSpinner class="h-3 w-3" />
{:else}
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
{/if}
{getBulkActionLabel()}
</button>
</div>
{/if}
</div>
</div>
{/if}
<div class="flex-1 space-y-2 overflow-y-auto bg-theme p-4">
{#each [...groupedServices.entries()] as [groupKey, groupServices] (groupKey)}
<AssignServiceGroup
{groupKey}
services={groupServices}
{column}
isExpanded={expandedGroups.has(groupKey)}
{updatingServices}
{openTeamMemberDropdown}
{editingDateServiceId}
{selectedServices}
onToggle={() => onToggleGroup(groupKey)}
{getTeamMemberNames}
{getNonDispatchTeamMemberNames}
{getAvailableTeamMembers}
{getStagedTeamMemberDetails}
{hasStagedMembers}
{onAddDispatch}
{onRemoveDispatch}
{onSubmitStaged}
{onRemoveNonDispatch}
{onStageTeamMember}
{onUnstageTeamMember}
{onToggleDropdown}
{onToggleSelection}
{onUpdateDate}
{onDeleteService}
{onStartEditDate}
{onCancelEditDate}
/>
{/each}
{#if services.length === 0}
<div class="py-12 text-center text-theme-muted">
<svg
class="mx-auto mb-3 h-12 w-12"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d={getEmptyIcon()}
/>
</svg>
{getEmptyMessage()}
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,56 @@
<script lang="ts">
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
interface Props {
column: ColumnType;
count: number;
}
let { column, count }: Props = $props();
function getConfig() {
switch (column) {
case 'unassigned':
return {
title: 'Unassigned',
description: 'Click the arrow to mark as ready for assignment.',
bgClass: 'bg-red-50 dark:bg-red-900/20',
dotClass: 'bg-red-500',
badgeClass: 'bg-red-500'
};
case 'readyToAssign':
return {
title: 'Ready to Assign',
description: 'Add team members, then click the arrow to assign.',
bgClass: 'bg-yellow-50 dark:bg-yellow-900/20',
dotClass: 'bg-yellow-500',
badgeClass: 'bg-yellow-500'
};
case 'assigned':
return {
title: 'Assigned',
description: 'These services are good to go.',
bgClass: 'bg-green-50 dark:bg-green-900/20',
dotClass: 'bg-green-500',
badgeClass: 'bg-green-500'
};
}
}
let config = $derived(getConfig());
</script>
<div class="border-b border-theme {config.bgClass} px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full {config.dotClass}"></div>
<h2 class="font-semibold text-theme">{config.title}</h2>
</div>
<span class="rounded-full {config.badgeClass} px-2.5 py-0.5 text-sm font-semibold text-white">
{count}
</span>
</div>
<p class="mt-1 text-xs text-theme-muted">
{config.description}
</p>
</div>

View File

@ -0,0 +1,380 @@
<script lang="ts">
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
interface ServiceDisplay {
date: string;
accountName: string;
addressName: string | null;
address: string | null;
}
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
interface Props {
service: ServiceDisplay;
column: ColumnType;
isUpdating: boolean;
// For unassigned column
teamMemberNames?: string[];
isEditingDate?: boolean;
// For readyToAssign column
availableMembers?: { pk: string; name: string }[];
stagedMembers?: { pk: string; name: string }[];
hasStaged?: boolean;
isDropdownOpen?: boolean;
// For assigned column
nonDispatchTeamMemberNames?: string[];
// Selection
isSelected?: boolean;
showCheckbox?: boolean;
// Callbacks
onAddDispatch?: () => void;
onRemoveDispatch?: () => void;
onSubmitStaged?: () => void;
onRemoveNonDispatch?: () => void;
onStageTeamMember?: (memberPk: string) => void;
onUnstageTeamMember?: (memberPk: string) => void;
onToggleDropdown?: () => void;
onToggleSelection?: () => void;
// Unassigned column specific callbacks
onUpdateDate?: (newDate: string) => void;
onDelete?: () => void;
onStartEditDate?: () => void;
onCancelEditDate?: () => void;
}
let {
service,
column,
isUpdating,
teamMemberNames = [],
isEditingDate = false,
availableMembers = [],
stagedMembers = [],
hasStaged = false,
isDropdownOpen = false,
nonDispatchTeamMemberNames = [],
isSelected = false,
showCheckbox = false,
onAddDispatch,
onRemoveDispatch,
onSubmitStaged,
onRemoveNonDispatch,
onStageTeamMember,
onUnstageTeamMember,
onToggleDropdown,
onToggleSelection,
onUpdateDate,
onDelete,
onStartEditDate,
onCancelEditDate
}: Props = $props();
// Local state for date editing - initialized fresh when editing starts
let editDateValue = $state('');
// Reset edit value when editing starts
$effect(() => {
if (isEditingDate) {
editDateValue = service.date;
}
});
function formatServiceDate(dateString: string): string {
const date = new Date(dateString + 'T00:00:00');
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
function handleDateSubmit() {
if (editDateValue && editDateValue !== service.date) {
onUpdateDate?.(editDateValue);
} else {
onCancelEditDate?.();
}
}
function handleDateKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleDateSubmit();
} else if (e.key === 'Escape') {
onCancelEditDate?.();
}
}
function handleCheckboxClick(e: MouseEvent) {
e.stopPropagation();
onToggleSelection?.();
}
</script>
<div
class="group relative rounded-lg border bg-theme p-3 transition-all hover:shadow-md {isSelected
? 'border-primary-500 ring-1 ring-primary-500'
: 'border-theme hover:border-theme'}"
>
{#if isUpdating}
<div class="bg-theme/80 absolute inset-0 z-10 flex items-center justify-center rounded-lg">
<IconSpinner class="h-6 w-6 text-secondary-500" />
</div>
{/if}
<!-- Checkbox for bulk selection -->
{#if showCheckbox}
<button
onclick={handleCheckboxClick}
class="absolute top-2 right-2 z-10 flex h-5 w-5 items-center justify-center rounded border transition-colors {isSelected
? 'border-primary-500 bg-primary-500 text-white'
: 'border-gray-300 bg-white hover:border-primary-400 dark:border-gray-600 dark:bg-gray-800 dark:hover:border-primary-500'}"
aria-label={isSelected ? 'Deselect service' : 'Select service'}
>
{#if isSelected}
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
{/if}
<!-- Date display/edit for unassigned column -->
{#if column === 'unassigned' && isEditingDate}
<div class="mb-2 flex items-center gap-2">
<input
type="date"
bind:value={editDateValue}
onkeydown={handleDateKeydown}
class="w-full rounded border border-theme bg-theme px-2 py-1 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
<button
onclick={handleDateSubmit}
class="rounded bg-green-500 p-1 text-white hover:bg-green-600"
aria-label="Save date"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</button>
<button
onclick={() => {
editDateValue = service.date;
onCancelEditDate?.();
}}
class="rounded bg-gray-500 p-1 text-white hover:bg-gray-600"
aria-label="Cancel"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{:else if column === 'unassigned'}
<button
onclick={onStartEditDate}
class="mb-1 flex items-center gap-1 text-sm font-bold text-theme hover:text-primary-600 dark:hover:text-primary-400"
title="Click to edit date"
>
{formatServiceDate(service.date)}
<svg
class="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-100"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
{:else}
<div class="mb-1 text-sm font-bold text-theme">{formatServiceDate(service.date)}</div>
{/if}
<div
class="mb-1 text-sm font-semibold text-secondary-600 dark:text-secondary-400 {showCheckbox
? 'pr-6'
: ''}"
>
{service.accountName}
</div>
<div class="mb-2 text-xs text-theme-muted">{service.addressName ?? service.address}</div>
{#if column === 'unassigned'}
<!-- Unassigned column footer -->
<div class="flex items-center justify-between border-t border-theme pt-2">
<button
onclick={onDelete}
disabled={isUpdating}
class="rounded-full bg-red-100 p-1.5 text-red-600 transition-colors hover:bg-red-200 disabled:opacity-50 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
aria-label="Delete service"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<div class="flex-1 px-2 text-xs text-theme-muted">
{#if teamMemberNames.length === 0}
<span class="italic">No team assigned</span>
{:else}
{teamMemberNames.join(', ')}
{/if}
</div>
<button
onclick={onAddDispatch}
disabled={isUpdating}
class="rounded-full bg-green-100 p-1.5 text-green-600 transition-colors hover:bg-green-200 disabled:opacity-50 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
aria-label="Add dispatch"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{:else if column === 'readyToAssign'}
<!-- Ready to Assign: Staged team members + Add button -->
<div class="mb-3 flex flex-wrap items-center gap-1.5">
{#each stagedMembers as member (member.pk)}
<span
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
>
{member.name}
<button
onclick={() => onUnstageTeamMember?.(member.pk)}
class="ml-0.5 rounded-full p-0.5 hover:bg-blue-200 dark:hover:bg-blue-800"
aria-label="Remove {member.name}"
>
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
{/each}
<!-- Add team member button -->
<div class="relative">
<button
onclick={(e) => {
e.stopPropagation();
onToggleDropdown?.();
}}
class="inline-flex items-center gap-0.5 rounded-full border border-dashed border-blue-300 px-2 py-0.5 text-xs font-medium text-blue-500 transition-colors hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 dark:border-blue-600 dark:text-blue-400 dark:hover:border-blue-500 dark:hover:bg-blue-900/20"
aria-label="Add team member"
>
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Add
</button>
{#if isDropdownOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
class="absolute top-full left-0 z-20 mt-1 w-48 rounded-lg border border-theme bg-theme-card py-1 shadow-lg"
>
{#if availableMembers.length === 0}
<div class="px-3 py-2 text-sm text-theme-muted italic">No available members</div>
{:else}
{#each availableMembers as member (member.pk)}
<button
onclick={() => onStageTeamMember?.(member.pk)}
class="block w-full interactive px-3 py-2 text-left text-sm text-theme"
>
{member.name}
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
<div class="flex items-center justify-between border-t border-theme pt-2">
<button
onclick={onRemoveDispatch}
disabled={isUpdating}
class="rounded-full bg-red-100 p-1.5 text-red-600 transition-colors hover:bg-red-200 disabled:opacity-50 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
aria-label="Remove dispatch"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<button
onclick={onSubmitStaged}
disabled={!hasStaged || isUpdating}
class="rounded-full p-1.5 transition-colors disabled:opacity-30 {hasStaged
? 'bg-green-100 text-green-600 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
: 'bg-theme-secondary text-theme-muted'}"
aria-label="Assign team members"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{:else}
<!-- Assigned column footer -->
<div class="flex items-center justify-between border-t border-theme pt-2">
<button
onclick={onRemoveNonDispatch}
disabled={isUpdating}
class="rounded-full bg-red-100 p-1.5 text-red-600 transition-colors hover:bg-red-200 disabled:opacity-50 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
aria-label="Remove team members"
>
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div class="text-xs text-theme-muted">
<span class="font-medium">Team:</span>
{nonDispatchTeamMemberNames.join(', ')}
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,167 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import AssignServiceCard from './AssignServiceCard.svelte';
import type { SvelteSet } from 'svelte/reactivity';
type EnrichedService = {
id: string;
date: string;
status: string;
notes: string | null;
teamMembers: { pk: string }[];
accountAddressId: string;
accountName: string;
addressName: string | null;
address: string | null;
};
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
interface Props {
groupKey: string;
services: EnrichedService[];
column: ColumnType;
isExpanded: boolean;
updatingServices: Set<string>;
openTeamMemberDropdown: string | null;
editingDateServiceId?: string | null;
selectedServices?: SvelteSet<string>;
// Callbacks
onToggle: () => void;
getTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
getNonDispatchTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
getAvailableTeamMembers: (service: EnrichedService) => { pk: string; name: string }[];
getStagedTeamMemberDetails: (serviceId: string) => { pk: string; name: string }[];
hasStagedMembers: (serviceId: string) => boolean;
onAddDispatch: (service: EnrichedService) => void;
onRemoveDispatch: (service: EnrichedService) => void;
onSubmitStaged: (service: EnrichedService) => void;
onRemoveNonDispatch: (service: EnrichedService) => void;
onStageTeamMember: (serviceId: string, memberPk: string) => void;
onUnstageTeamMember: (serviceId: string, memberPk: string) => void;
onToggleDropdown: (serviceId: string | null) => void;
onToggleSelection?: (serviceId: string) => void;
// Unassigned column specific
onUpdateDate?: (service: EnrichedService, newDate: string) => void;
onDeleteService?: (service: EnrichedService) => void;
onStartEditDate?: (serviceId: string) => void;
onCancelEditDate?: () => void;
}
let {
groupKey,
services,
column,
isExpanded,
updatingServices,
openTeamMemberDropdown,
editingDateServiceId = null,
selectedServices,
onToggle,
getTeamMemberNames,
getNonDispatchTeamMemberNames,
getAvailableTeamMembers,
getStagedTeamMemberDetails,
hasStagedMembers,
onAddDispatch,
onRemoveDispatch,
onSubmitStaged,
onRemoveNonDispatch,
onStageTeamMember,
onUnstageTeamMember,
onToggleDropdown,
onToggleSelection,
onUpdateDate,
onDeleteService,
onStartEditDate,
onCancelEditDate
}: Props = $props();
function getBadgeClass(): string {
switch (column) {
case 'unassigned':
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400';
case 'readyToAssign':
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400';
case 'assigned':
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400';
}
}
// Count selected services in this group
let selectedCount = $derived(
selectedServices ? services.filter((s) => selectedServices.has(s.id)).length : 0
);
</script>
<div class="rounded-lg border border-theme bg-theme-card">
<button
onclick={onToggle}
class="flex w-full items-center justify-between interactive px-3 py-2 text-left"
>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4 text-theme-muted transition-transform {isExpanded ? 'rotate-90' : ''}"
style="fill: none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="text-sm font-medium text-theme">{groupKey}</span>
</span>
<span class="flex items-center gap-1.5">
{#if selectedCount > 0}
<span
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
>
{selectedCount}
</span>
{/if}
<span class="rounded-full px-2 py-0.5 text-xs font-semibold {getBadgeClass()}">
{services.length}
</span>
</span>
</button>
{#if isExpanded}
<div class="space-y-2 border-t border-theme p-2" transition:slide={{ duration: 150 }}>
{#each services as service (service.id)}
{@const availableMembers =
column === 'readyToAssign' ? getAvailableTeamMembers(service) : []}
{@const stagedMembers =
column === 'readyToAssign' ? getStagedTeamMemberDetails(service.id) : []}
{@const hasStaged = column === 'readyToAssign' ? hasStagedMembers(service.id) : false}
{@const isSelected = selectedServices?.has(service.id) ?? false}
<AssignServiceCard
{service}
{column}
isUpdating={updatingServices.has(service.id)}
teamMemberNames={column === 'unassigned' ? getTeamMemberNames(service.teamMembers) : []}
isEditingDate={column === 'unassigned' && editingDateServiceId === service.id}
{availableMembers}
{stagedMembers}
{hasStaged}
isDropdownOpen={openTeamMemberDropdown === service.id}
nonDispatchTeamMemberNames={column === 'assigned'
? getNonDispatchTeamMemberNames(service.teamMembers)
: []}
{isSelected}
showCheckbox={!!selectedServices}
onAddDispatch={() => onAddDispatch(service)}
onRemoveDispatch={() => onRemoveDispatch(service)}
onSubmitStaged={() => onSubmitStaged(service)}
onRemoveNonDispatch={() => onRemoveNonDispatch(service)}
onStageTeamMember={(pk) => onStageTeamMember(service.id, pk)}
onUnstageTeamMember={(pk) => onUnstageTeamMember(service.id, pk)}
onToggleDropdown={() =>
onToggleDropdown(openTeamMemberDropdown === service.id ? null : service.id)}
onToggleSelection={() => onToggleSelection?.(service.id)}
onUpdateDate={(newDate) => onUpdateDate?.(service, newDate)}
onDelete={() => onDeleteService?.(service)}
onStartEditDate={() => onStartEditDate?.(service.id)}
{onCancelEditDate}
/>
{/each}
</div>
{/if}
</div>

View File

@ -0,0 +1,113 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { chat } from '$lib/stores/chat.svelte';
import MessageList from './MessageList.svelte';
import MessageInput from './MessageInput.svelte';
import ConversationList from './ConversationList.svelte';
let showConversations = $state(false);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && chat.isOpen) {
if (showConversations) {
showConversations = false;
} else {
chat.close();
}
}
}
function toggleConversations() {
showConversations = !showConversations;
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if chat.isOpen}
<!-- Backdrop for mobile -->
<button
type="button"
class="fixed inset-0 z-40 bg-black/30 sm:hidden"
onclick={() => chat.close()}
aria-label="Close chat"
></button>
<!-- Chat panel -->
<div
class="fixed right-0 bottom-0 z-50 flex h-[calc(100dvh-3.5rem)] w-full flex-col bg-white shadow-xl sm:right-4 sm:bottom-20 sm:h-[600px] sm:max-h-[calc(100dvh-8rem)] sm:w-96 sm:rounded-2xl dark:bg-gray-800"
transition:fly={{ y: 20, duration: 200 }}
role="dialog"
aria-modal="true"
aria-label="Chat"
>
<!-- Header -->
<div
class="flex shrink-0 items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700"
>
<div class="flex items-center gap-3">
<button
type="button"
onclick={toggleConversations}
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
aria-label="Show conversations"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7" />
</svg>
</button>
<div>
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{chat.activeConversation?.title || 'New Chat'}
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">
{#if chat.connectionStatus === 'connected'}
AI Assistant
{:else if chat.connectionStatus === 'connecting'}
Connecting...
{:else}
Disconnected
{/if}
</p>
</div>
</div>
<button
type="button"
onclick={() => chat.close()}
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
aria-label="Close chat"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Main content area -->
<div class="relative flex min-h-0 flex-1 flex-col">
<!-- Messages -->
<MessageList />
<!-- Input -->
<MessageInput />
<!-- Conversation list overlay -->
{#if showConversations}
<div
class="absolute inset-0 bg-white dark:bg-gray-800"
transition:fly={{ x: -100, duration: 200 }}
>
<ConversationList onClose={() => (showConversations = false)} />
</div>
{/if}
</div>
</div>
{/if}

View File

@ -0,0 +1,41 @@
<script lang="ts">
import { chat } from '$lib/stores/chat.svelte';
const statusColors = {
disconnected: 'bg-gray-400',
connecting: 'bg-yellow-400 animate-pulse',
connected: 'bg-green-400',
error: 'bg-red-400'
};
let statusColor = $derived(statusColors[chat.connectionStatus]);
</script>
<button
type="button"
onclick={() => chat.toggle()}
class="fixed right-4 bottom-24 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-primary-600 text-white shadow-lg transition-transform hover:scale-105 hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:outline-none md:bottom-6"
aria-label={chat.isOpen ? 'Close chat' : 'Open chat'}
>
{#if chat.isOpen}
<!-- X icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{:else}
<!-- Chat bubble icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{/if}
<!-- Connection status indicator -->
<span
class="absolute top-0 right-0 h-3 w-3 rounded-full border-2 border-white {statusColor}"
aria-hidden="true"
></span>
</button>

View File

@ -0,0 +1,117 @@
<script lang="ts">
import { chat } from '$lib/stores/chat.svelte';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
function selectConversation(id: string) {
chat.selectConversation(id);
onClose();
}
function startNew() {
chat.startNewConversation();
onClose();
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: 'short' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
}
</script>
<div class="flex h-full flex-col">
<div class="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-white">Conversations</h3>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
aria-label="Close"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto">
<!-- New conversation button -->
<button
type="button"
onclick={startNew}
class="flex w-full items-center gap-3 border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-700/50"
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-white">New Conversation</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Start fresh</p>
</div>
</button>
<!-- Conversation list -->
{#each chat.conversations as conversation (conversation.id)}
<button
type="button"
onclick={() => selectConversation(conversation.id)}
class="flex w-full items-center gap-3 border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-700/50 {conversation.id ===
chat.activeConversationId
? 'bg-primary-50 dark:bg-primary-900/20'
: ''}"
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white">
{conversation.title || 'New Conversation'}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{formatDate(conversation.updated_at || conversation.created_at)}
</p>
</div>
</button>
{/each}
{#if chat.conversations.length === 0}
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
No conversations yet
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,39 @@
<script lang="ts">
import type { ChatMessage } from '$lib/stores/chat.svelte';
import ToolCallCard from './ToolCallCard.svelte';
interface Props {
message: ChatMessage;
}
let { message }: Props = $props();
let isUser = $derived(message.role === 'user');
</script>
<div class="flex {isUser ? 'justify-end' : 'justify-start'}">
<div
class="max-w-[85%] rounded-2xl px-4 py-2 {isUser
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'}"
>
<!-- Tool calls (for assistant messages) -->
{#if message.tool_calls && message.tool_calls.length > 0}
<div class="mb-2 space-y-2">
{#each message.tool_calls as tool, i}
<ToolCallCard
{tool}
result={message.tool_results?.find((r) => r.tool_use_id === tool.id)?.result}
/>
{/each}
</div>
{/if}
<!-- Message content -->
{#if message.content}
<div class="prose prose-sm max-w-none {isUser ? 'prose-invert' : 'dark:prose-invert'}">
{@html message.content.replace(/\n/g, '<br>')}
</div>
{/if}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More