public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 11:25:38 -05:00
commit 9d679cd029
403 changed files with 46024 additions and 0 deletions

35
.dockerignore Normal file
View File

@ -0,0 +1,35 @@
# Dependencies (use image's npm ci instead)
node_modules
# Build outputs
build
.svelte-kit
dist
coverage
# Environment files (avoid baking secrets into images)
.env
.env.*
!.env.example
# VCS
.git
.git/*
.gitignore
# Editors/OS
.idea
.vscode
*.swp
*.swo
.DS_Store
Thumbs.db
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Caches
.cache

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
# 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
# Houdini Schema Introspection (development only)
# These headers are used to fetch the GraphQL schema
USER_ID=your-dev-user-id
USER_PROFILE_TYPE=TeamProfileType
OATHKEEPER_SECRET=your-oathkeeper-secret
DJANGO_PROFILE_ID=your-dev-profile-id

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
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
.idea

9
.graphqlrc.yaml Normal file
View File

@ -0,0 +1,9 @@
projects:
default:
schema:
- ./schema.graphql
- ./.houdini/graphql/schema.graphql
documents:
- '**/*.gql'
- '**/*.svelte'
- ./.houdini/graphql/documents.gql

1
.npmrc Normal file
View File

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

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

15
.prettierrc Normal file
View File

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

56
Dockerfile Normal file
View File

@ -0,0 +1,56 @@
# 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
ENV PUBLIC_CALENDAR_API_URL=$PUBLIC_CALENDAR_API_URL
ENV PUBLIC_CALENDAR_API_KEY=$PUBLIC_CALENDAR_API_KEY
# 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 (ensure package.json has "start": "node build")
CMD ["npm", "start"]

154
README.md Normal file
View File

@ -0,0 +1,154 @@
# Nexus 5 Frontend 1 - Admin Dashboard
Admin-focused dashboard for the Nexus 5 platform, providing comprehensive management controls for service businesses.
## Overview
This is the first iteration of the Nexus 5 frontend, built as an admin-focused dashboard with comprehensive controls for managing customers, accounts, services, projects, and more.
## Tech Stack
- **SvelteKit** - Meta-framework for building web applications
- **Houdini** - Type-safe GraphQL client with automatic code generation
- **Tailwind CSS** - Utility-first CSS framework
- **Flowbite Svelte** - UI component library
- **TypeScript** - Type-safe JavaScript
## Evolution
This represents the first SvelteKit frontend for Nexus 5, building on lessons learned from previous iterations:
| Feature | nexus-4 (Rust+SvelteKit) | nexus-5-frontend-1 |
|---------|--------------------------|---------------------|
| **Framework** | SvelteKit + Houdini | SvelteKit + Houdini |
| **GraphQL** | Basic queries | Full Relay pagination |
| **UI Components** | Custom | Flowbite Svelte |
| **Authentication** | JWT | Ory Kratos integration |
| **Real-time** | None | WebSocket subscriptions |
## Features
- **Customer Management** - Create and manage customer profiles
- **Account Management** - Handle accounts with addresses, contacts, schedules
- **Service Scheduling** - Schedule and assign services
- **Project Management** - Track projects and project scopes
- **Scope Templates** - Create reusable scope templates with AI-assisted JSON import
- **Calendar Integration** - Schedule events and view calendar
- **Reports** - Generate and manage service reports
- **Profile Management** - Manage team and customer profiles
- **Notification Rules** - Configure notification triggers and channels
- **Real-time Updates** - Live notifications and messages
## Getting Started
### Prerequisites
- Node.js 18+
- Access to Nexus 5 GraphQL API
### Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Type checking
npm run check
```
### Environment Variables
Create a `.env` file with:
```
PUBLIC_GRAPHQL_URL=http://localhost:8000/graphql/
PUBLIC_KRATOS_URL=http://localhost:4433
```
## Project Structure
```
src/
├── lib/
│ ├── components/ # Svelte components
│ │ ├── accounts/ # Account management
│ │ ├── customers/ # Customer management
│ │ ├── forms/ # Form components
│ │ ├── modals/ # Modal dialogs
│ │ ├── nav/ # Navigation
│ │ ├── notifications/ # Notification UI
│ │ ├── scope-builder/ # Scope template builder
│ │ ├── services/ # Service management
│ │ └── sessions/ # Work session UI
│ ├── graphql/ # GraphQL operations
│ │ ├── mutations/ # GraphQL mutations
│ │ └── queries/ # GraphQL queries
│ ├── stores/ # Svelte stores
│ └── utils/ # Utility functions
├── routes/ # SvelteKit routes
│ ├── accounts/ # Account pages
│ ├── calendar/ # Calendar pages
│ ├── customers/ # Customer pages
│ ├── notifications/ # Notification pages
│ ├── profiles/ # Profile pages
│ ├── projects/ # Project pages
│ ├── reports/ # Report pages
│ ├── scopes/ # Scope pages
│ └── services/ # Service pages
└── schema.graphql # GraphQL schema
```
## Key Patterns
### GraphQL with Houdini
Uses Houdini for type-safe GraphQL operations with automatic code generation:
```typescript
import { GetAccountsStore } from '$houdini';
const accounts = new GetAccountsStore();
await accounts.fetch({ policy: 'NetworkOnly' });
```
### Relay-style Pagination
Implements cursor-based pagination following the Relay specification:
```graphql
query GetAccounts($first: Int, $after: String) {
accounts(first: $first, after: $after) {
edges {
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
```
### Off-Canvas Pattern
Uses off-canvas panels for editing and creating entities without leaving the current page.
## Related Repositories
- **nexus-5** - Django GraphQL API backend
- **nexus-5-auth** - Ory Kratos/Oathkeeper authentication
- **nexus-5-frontend-2** - Streamlined team app
- **nexus-5-frontend-3** - Full-featured portal
## License
MIT License - See LICENSE file for details.

19
docker-compose.yml Normal file
View File

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

42
eslint.config.js Normal file
View File

@ -0,0 +1,42 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
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 goto() promise resolution requirement - we intentionally use fire-and-forget navigation
'svelte/no-navigation-without-resolve': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

24
houdini.config.js Normal file
View File

@ -0,0 +1,24 @@
/// <references types="houdini-svelte">
/** @type {import('houdini').ConfigFile} */
const config = {
watchSchema: {
url: 'http://192.168.100.174: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',
plugins: {
'houdini-svelte': {
client: './src/lib/graphql/client.ts',
forceRunesMode: true
}
}
};
export default config;

6928
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "nexus-5-frontend-1",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node 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 ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-node": "^5.3.1",
"@sveltejs/kit": "^2.34.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/typography": "^0.5.14",
"autoprefixer": "^10.4.20",
"concurrently": "^9.2.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"flowbite-svelte": "^1.12.6",
"flowbite-svelte-icons": "^2.3.0",
"globals": "^16.0.0",
"houdini": "^2.0.0-next.6",
"houdini-svelte": "^3.0.0-next.9",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.12",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.1.0"
},
"dependencies": {
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"vite-plugin-mkcert": "^1.17.9"
}
}

5
postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {}
}
};

4333
schema.graphql Normal file

File diff suppressed because it is too large Load Diff

49
src/app.css Normal file
View File

@ -0,0 +1,49 @@
@import 'tailwindcss';
@plugin 'flowbite/plugin';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-50: #eff6ff; /* blue-50 */
--color-primary-100: #dbeafe; /* blue-100 */
--color-primary-200: #bfdbfe; /* blue-200 */
--color-primary-300: #93c5fd; /* blue-300 */
--color-primary-400: #60a5fa; /* blue-400 */
--color-primary-500: #3b82f6; /* blue-500 */
--color-primary-600: #2563eb; /* blue-600 */
--color-primary-700: #1d4ed8; /* blue-700 */
--color-primary-800: #1e40af; /* blue-800 */
--color-primary-900: #1e3a8a; /* blue-900 */
--color-secondary-50: #f0fdf4; /* green-50 */
--color-secondary-100: #dcfce7; /* green-100 */
--color-secondary-200: #bbf7d0; /* green-200 */
--color-secondary-300: #86efac; /* green-300 */
--color-secondary-400: #4ade80; /* green-400 */
--color-secondary-500: #22c55e; /* green-500 */
--color-secondary-600: #16a34a; /* green-600 */
--color-secondary-700: #15803d; /* green-700 */
--color-secondary-800: #166534; /* green-800 */
--color-secondary-900: #14532d; /* green-900 */
}
@source "../node_modules/flowbite-svelte/dist";
@source "../node_modules/flowbite-svelte-icons/dist";
/* Base visual refinements */
:root {
color-scheme: light dark;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Improve default focus outline for accessibility */
:where(a, button, input, select, textarea) {
outline-offset: 2px;
}
/* Subtle card-like background for sections on light mode */
/* Keep Tailwind utility-first approach; only minor resets here */

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

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nexus 5.0 Online Platform</title>
%sveltekit.head%
</head>
<body class="bg-white dark:bg-gray-800" data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

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

164
src/lib/auth.ts Normal file
View File

@ -0,0 +1,164 @@
import { writable, derived, type Readable } from 'svelte/store';
const isBrowser = typeof window !== 'undefined';
// Kratos/Oathkeeper configuration - always use production
const KRATOS_BASE_URL = isBrowser ? 'https://auth.example.com' : '';
// Get the app's origin for return_to URLs
const APP_ORIGIN = isBrowser ? window.location.origin : '';
export type SessionIdentity = {
id: string;
traits: {
email?: string;
name?: {
first?: string;
last?: string;
};
phone?: string;
profile_type?: string;
};
metadata_public?: {
django_profile_id?: string;
customer_id?: string;
};
};
export type Session = {
id: string;
active: boolean;
identity: SessionIdentity;
expires_at?: string;
authenticated_at?: string;
} | null;
function createAuthStore() {
const store = writable<Session>(null);
let checkInProgress = false;
// Check session with Kratos whoami endpoint
async function checkSession(): Promise<Session> {
if (!isBrowser) return null;
if (checkInProgress) return null;
checkInProgress = true;
try {
const response = await fetch(`${KRATOS_BASE_URL}/sessions/whoami`, {
credentials: 'include', // Send cookies
headers: {
Accept: 'application/json'
}
});
if (response.ok) {
const session = await response.json();
store.set(session);
return session;
} else {
store.set(null);
return null;
}
} catch (error) {
console.warn('Failed to check session:', error);
store.set(null);
return null;
} finally {
checkInProgress = false;
}
}
// Initialize session check on browser
if (isBrowser) {
checkSession();
}
return {
subscribe: store.subscribe,
checkSession,
logout: async (returnTo?: string) => {
if (!isBrowser) return;
try {
// Use provided returnTo or default to app origin
const returnUrl = returnTo || APP_ORIGIN;
// Call Kratos logout endpoint with return_to parameter
const logoutEndpoint = `${KRATOS_BASE_URL}/self-service/logout/browser?return_to=${encodeURIComponent(returnUrl)}`;
const response = await fetch(logoutEndpoint, {
credentials: 'include'
});
if (response.ok) {
const logoutData = await response.json();
// Redirect to logout URL to complete the flow (this will include the return_to)
if (logoutData.logout_url) {
window.location.href = logoutData.logout_url;
}
}
} catch (error) {
console.error('Logout failed:', error);
} finally {
store.set(null);
}
}
};
}
export const auth = createAuthStore();
// Reactive helpers for UI
export const isAuthenticated: Readable<boolean> = derived(auth, ($session) =>
Boolean($session?.active)
);
export const userEmail: Readable<string | null> = derived(
auth,
($session) => $session?.identity?.traits?.email ?? null
);
export const userFullName: Readable<string | null> = derived(auth, ($session) => {
const firstName = $session?.identity?.traits?.name?.first;
const lastName = $session?.identity?.traits?.name?.last;
if (firstName && lastName) return `${firstName} ${lastName}`;
if (firstName) return firstName;
if (lastName) return lastName;
return null;
});
// Helper functions for checking authentication
export function checkSession(): Promise<Session> {
return auth.checkSession();
}
export function logout(returnTo?: string): Promise<void> {
return auth.logout(returnTo);
}
// Login and registration redirects to Kratos
export function redirectToLogin(returnTo?: string): void {
if (!isBrowser) return;
// Default to the app origin + path, or use provided returnTo
const returnUrl = returnTo
? returnTo.startsWith('http')
? returnTo
: `${APP_ORIGIN}${returnTo}`
: window.location.href;
window.location.href = `${KRATOS_BASE_URL}/self-service/login/browser?return_to=${encodeURIComponent(returnUrl)}`;
}
export function redirectToRegistration(returnTo?: string): void {
if (!isBrowser) return;
// Default to the app origin + path, or use provided returnTo
const returnUrl = returnTo
? returnTo.startsWith('http')
? returnTo
: `${APP_ORIGIN}${returnTo}`
: window.location.href;
window.location.href = `${KRATOS_BASE_URL}/self-service/registration/browser?return_to=${encodeURIComponent(returnUrl)}`;
}

View File

@ -0,0 +1,50 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getImageSrc, revokeImageSrc, openImageInNewTab } from '$lib/media';
import { Card } from 'flowbite-svelte';
let {
path,
alt,
clazz = '',
fullPath
}: { path: string; alt: string; clazz?: string; fullPath?: string } = $props();
let src = $state<string | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
if (!path) return;
try {
src = await getImageSrc(path);
} catch (e) {
console.error(`Failed to load image: ${path}`, e);
error = e instanceof Error ? e.message : 'Failed to load image';
}
});
onDestroy(() => {
if (src) {
revokeImageSrc(src);
}
});
function handleClick() {
const toOpen = fullPath || path;
if (toOpen) {
openImageInNewTab(toOpen);
}
}
</script>
{#if error}
<div class="flex h-full w-full items-center justify-center bg-gray-200 text-red-500">
<p>{error}</p>
</div>
{:else if src}
<Card onclick={handleClick}><img {src} {alt} class="{clazz} cursor-pointer" /></Card>
{:else}
<div class="flex h-full w-full animate-pulse items-center justify-center bg-gray-200">
<!-- Placeholder -->
</div>
{/if}

View File

@ -0,0 +1,119 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getVideoSrc, revokeVideoSrc, getImageSrc, revokeImageSrc } from '$lib/media';
import { Spinner } from 'flowbite-svelte';
let {
path,
thumbnailPath,
alt = 'Video',
clazz = '',
showControls = true,
autoplay = false,
muted = false,
loop = false
}: {
path: string;
thumbnailPath?: string;
alt?: string;
clazz?: string;
showControls?: boolean;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
} = $props();
let videoSrc = $state<string | null>(null);
let thumbnailSrc = $state<string | null>(null);
let error = $state<string | null>(null);
let loading = $state(true);
onMount(async () => {
if (!path) {
error = 'No video path provided';
loading = false;
return;
}
try {
// Load thumbnail first if available
if (thumbnailPath) {
try {
thumbnailSrc = await getImageSrc(thumbnailPath);
} catch (thumbError) {
console.warn(`Failed to load video thumbnail: ${thumbnailPath}`, thumbError);
// Continue loading video even if thumbnail fails
}
}
// Load video
videoSrc = await getVideoSrc(path);
loading = false;
} catch (e) {
console.error(`Failed to load video: ${path}`, e);
error = e instanceof Error ? e.message : 'Failed to load video';
loading = false;
}
});
onDestroy(() => {
if (videoSrc) {
revokeVideoSrc(videoSrc);
}
if (thumbnailSrc) {
revokeImageSrc(thumbnailSrc);
}
});
</script>
{#if error}
<div
class="flex items-center justify-center rounded-lg border-2 border-dashed border-red-300 bg-red-50 p-8 dark:border-red-700 dark:bg-red-900/20"
>
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-red-400"
stroke="currentColor"
viewBox="0 0 24 24"
style="fill: none;"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p class="mt-2 text-sm text-red-600 dark:text-red-400">
{error}
</p>
</div>
</div>
{:else if loading}
<div
class="flex items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800 {clazz ||
'aspect-video'}"
>
{#if thumbnailSrc}
<img src={thumbnailSrc} {alt} class="h-full w-full rounded-lg object-cover opacity-50" />
{/if}
<div class="absolute flex flex-col items-center gap-2">
<Spinner size="8" />
<p class="text-sm text-gray-600 dark:text-gray-400">Loading video...</p>
</div>
</div>
{:else if videoSrc}
<video
src={videoSrc}
title={alt}
class={clazz}
controls={showControls}
{autoplay}
{muted}
{loop}
poster={thumbnailSrc || undefined}
preload="metadata"
>
Your browser does not support the video tag.
</video>
{/if}

View File

@ -0,0 +1,70 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { clickOutside } from '$lib/utils/offCanvas';
import { offCanvasStore, offCanvas } from '$lib/stores/offCanvas';
const state = $derived($offCanvasStore.left);
const isOpen = $derived(state.isOpen);
const content = $derived(state.content);
function close() {
offCanvas.closeLeft();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="bg-opacity-50 fixed inset-0 z-40 bg-black transition-opacity"
onclick={close}
role="presentation"
></div>
<!-- Off-canvas panel -->
<div
class="fixed top-0 left-0 z-50 flex h-full flex-col {content?.width ||
'w-full md:w-1/2'} bg-white shadow-lg dark:bg-gray-900 {content?.class || ''}"
transition:fly={{ x: -320, duration: 300 }}
use:clickOutside={close}
role="dialog"
aria-modal="true"
aria-labelledby={content?.title ? 'offcanvas-title' : undefined}
>
{#if content?.title}
<div
class="flex flex-shrink-0 items-center justify-between border-b p-4 dark:border-gray-700"
>
<h3 id="offcanvas-title" class="text-lg font-semibold text-gray-900 dark:text-white">
{content.title}
</h3>
<button
onclick={close}
class="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-white"
aria-label="Close"
>
<svg class="h-5 w-5 fill-current" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
<div class="flex-1 overflow-y-auto p-4">
{#if content?.content}
{@render content.content()}
{/if}
</div>
</div>
{/if}

View File

@ -0,0 +1,70 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { clickOutside } from '$lib/utils/offCanvas';
import { offCanvasStore, offCanvas } from '$lib/stores/offCanvas';
const state = $derived($offCanvasStore.right);
const isOpen = $derived(state.isOpen);
const content = $derived(state.content);
function close() {
offCanvas.closeRight();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="bg-opacity-50 fixed inset-0 z-40 bg-black transition-opacity"
onclick={close}
role="presentation"
></div>
<!-- Off-canvas panel -->
<div
class="fixed top-0 right-0 z-50 flex h-full flex-col {content?.width ||
'w-full md:w-1/2'} bg-white shadow-lg dark:bg-gray-900 {content?.class || ''}"
transition:fly={{ x: 320, duration: 300 }}
use:clickOutside={close}
role="dialog"
aria-modal="true"
aria-labelledby={content?.title ? 'offcanvas-title' : undefined}
>
{#if content?.title}
<div
class="flex flex-shrink-0 items-center justify-between border-b p-4 dark:border-gray-700"
>
<h3 id="offcanvas-title" class="text-lg font-semibold text-gray-900 dark:text-white">
{content.title}
</h3>
<button
onclick={close}
class="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-white"
aria-label="Close"
>
<svg class="h-5 w-5 fill-current" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
<div class="flex-1 overflow-y-auto p-4">
{#if content?.content}
{@render content.content()}
{/if}
</div>
</div>
{/if}

View File

@ -0,0 +1,98 @@
<script lang="ts">
import { Button, Spinner } from 'flowbite-svelte';
import { GetLaborsStore } from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
import { formatDate } from '$lib/utils/date';
import DeleteModal from '$lib/components/modals/common/DeleteModal.svelte';
import { DeleteLaborStore } from '$houdini';
let { addressId, onEditLabor } = $props<{
addressId: string; // global id
onEditLabor?: (laborId: string) => void;
}>();
let labors = $derived(new GetLaborsStore());
let deleteLaborStore = $state(new DeleteLaborStore());
let loading = $state(false);
let error = $state('');
const fetchLabors = () =>
labors.fetch({
variables: { filters: { accountAddressId: fromGlobalId(addressId) } },
policy: 'NetworkOnly'
});
$effect(() => {
if (!addressId) return;
loading = true;
error = '';
fetchLabors()
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load labor rates'))
.finally(() => (loading = false));
});
// Sort: current (no end) first, then by start desc
let sortedLabors = $derived(() => {
const list = [...($labors.data?.labors ?? [])];
return list.sort((a, b) => {
const aHasEnd = !!a.endDate;
const bHasEnd = !!b.endDate;
if (!aHasEnd && bHasEnd) return -1;
if (aHasEnd && !bHasEnd) return 1;
return new Date(b.startDate).getTime() - new Date(a.startDate).getTime();
});
});
</script>
<div class="mt-4">
<div class="mb-2 flex items-center justify-between">
<h4 class="font-semibold text-gray-900 dark:text-gray-100">Labor</h4>
</div>
{#if error}
<div class="mb-2 text-sm text-red-600">{error}</div>
{/if}
{#if loading || $labors.fetching}
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<Spinner size="4" /> Loading labor rates...
</div>
{:else if sortedLabors().length}
<div class="space-y-3">
{#each sortedLabors() as l (l.id)}
<div
class="rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900"
>
<div class="mb-2 flex items-start justify-between">
<div class="min-w-0">
<div class="font-medium text-gray-900 dark:text-gray-100">{l.amount}</div>
</div>
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
<Button color="secondary" size="xs" onclick={() => onEditLabor?.(l.id)}>Edit</Button>
<DeleteModal
id={fromGlobalId(l.id)}
store={deleteLaborStore}
itemName="this labor rate"
entityType="labor"
triggerLabel="Delete"
triggerSize="xs"
showSuccessMessage={true}
actionLabel="Submit"
ondeleted={() => fetchLabors()}
/>
</div>
</div>
<div class="text-sm text-gray-700 dark:text-gray-300">
{formatDate(l.startDate)} - {formatDate(l.endDate)}
</div>
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-600 dark:text-gray-300">No labor rates.</div>
{/if}
</div>

View File

@ -0,0 +1,72 @@
<script lang="ts">
import { Spinner } from 'flowbite-svelte';
import { GetLaborsStore } from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
let { addressId, onEditLabor } = $props<{
addressId: string; // global id
onEditLabor?: (laborId: string) => void;
}>();
let labors = $derived(new GetLaborsStore());
let loading = $state(false);
let error = $state('');
$effect(() => {
if (!addressId) return;
loading = true;
error = '';
labors
.fetch({
variables: { filters: { accountAddressId: fromGlobalId(addressId) } },
policy: 'NetworkOnly'
})
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load labor rates'))
.finally(() => (loading = false));
});
// Get current labor rates (active based on today's date)
let currentLabors = $derived(() => {
const list = [...($labors.data?.labors ?? [])];
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
return list.filter((l) => {
const isAfterStart = l.startDate <= today;
const isBeforeEnd = !l.endDate || l.endDate >= today;
return isAfterStart && isBeforeEnd;
});
});
</script>
{#if loading || $labors.fetching}
<div class="flex items-center gap-1">
<Spinner size="4" />
</div>
{:else if error}
<div class="text-xs text-red-600">Error</div>
{:else if currentLabors().length}
<div class="space-y-1">
{#each currentLabors() as l (l.id)}
<div
role="button"
tabindex="0"
class="hover:border-primary-300 cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
onclick={() => onEditLabor?.(l.id)}
onkeydown={(e) => e.key === 'Enter' && onEditLabor?.(l.id)}
title="Click to edit labor rate"
>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{l.amount}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Active rate</div>
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">None</div>
{/if}

View File

@ -0,0 +1,122 @@
<script lang="ts">
import { Badge, Button, Spinner } from 'flowbite-svelte';
import { GetSchedulesStore } from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
import { formatDate } from '$lib/utils/date';
import DeleteModal from '$lib/components/modals/common/DeleteModal.svelte';
import { DeleteScheduleStore } from '$houdini';
let { addressId, onEditSchedule } = $props<{
addressId: string; // global id
onEditSchedule?: (scheduleId: string) => void;
}>();
let schedules = $derived(new GetSchedulesStore());
let deleteScheduleStore = $state(new DeleteScheduleStore());
let loading = $state(false);
let error = $state('');
const fetchSchedules = () =>
schedules.fetch({
variables: { filters: { accountAddressId: fromGlobalId(addressId) } },
policy: 'NetworkOnly'
});
$effect(() => {
if (!addressId) return;
loading = true;
error = '';
fetchSchedules()
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load schedules'))
.finally(() => (loading = false));
});
// Sort: current (no end) first, then by start desc
let sortedSchedules = $derived(() => {
const list = [...($schedules.data?.schedules ?? [])];
return list.sort((a, b) => {
const aHasEnd = !!a.endDate;
const bHasEnd = !!b.endDate;
if (!aHasEnd && bHasEnd) return -1;
if (aHasEnd && !bHasEnd) return 1;
return new Date(b.startDate).getTime() - new Date(a.startDate).getTime();
});
});
</script>
<div class="mt-4">
<div class="mb-2 flex items-center justify-between">
<h4 class="font-semibold text-gray-900 dark:text-gray-100">Schedules</h4>
<!-- Optional: another add button could go here, but we already show one above -->
</div>
{#if error}
<div class="mb-2 text-sm text-red-600">{error}</div>
{/if}
{#if loading || $schedules.fetching}
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<Spinner size="4" /> Loading schedules...
</div>
{:else if sortedSchedules().length}
<div class="space-y-3">
{#each sortedSchedules() as s (s.id)}
<div
class="rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900"
>
<div class="mb-2 flex items-start justify-between">
<div class="min-w-0">
<div class="font-medium text-gray-900 dark:text-gray-100">
{s.name || 'Service Schedule'}
</div>
<div class="mt-1 flex flex-wrap items-center gap-1">
<Badge size="small" color={s.mondayService ? 'blue' : 'gray'}>Mon</Badge>
<Badge size="small" color={s.tuesdayService ? 'blue' : 'gray'}>Tue</Badge>
<Badge size="small" color={s.wednesdayService ? 'blue' : 'gray'}>Wed</Badge>
<Badge size="small" color={s.thursdayService ? 'blue' : 'gray'}>Thu</Badge>
<Badge size="small" color={s.fridayService ? 'blue' : 'gray'}>Fri</Badge>
<Badge size="small" color={s.saturdayService ? 'blue' : 'gray'}>Sat</Badge>
<Badge size="small" color={s.sundayService ? 'blue' : 'gray'}>Sun</Badge>
{#if s.weekendService}
<Badge size="small" color="purple">Weekend</Badge>
{/if}
</div>
</div>
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
<Button color="secondary" size="xs" onclick={() => onEditSchedule?.(s.id)}
>Edit</Button
>
<DeleteModal
id={fromGlobalId(s.id)}
store={deleteScheduleStore}
itemName="this schedule"
entityType="schedule"
triggerLabel="Delete"
triggerSize="xs"
showSuccessMessage={true}
actionLabel="Submit"
ondeleted={() => fetchSchedules()}
/>
</div>
</div>
<div class="text-sm text-gray-700 dark:text-gray-300">
{formatDate(s.startDate)} - {formatDate(s.endDate)}
</div>
{#if s.scheduleException}
<div
class="mt-2 border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
{s.scheduleException}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-600 dark:text-gray-300">No schedules.</div>
{/if}
</div>

View File

@ -0,0 +1,83 @@
<script lang="ts">
import { Badge, Spinner } from 'flowbite-svelte';
import { GetSchedulesStore } from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
let { addressId, onEditSchedule } = $props<{
addressId: string; // global id
onEditSchedule?: (scheduleId: string) => void;
}>();
let schedules = $derived(new GetSchedulesStore());
let loading = $state(false);
let error = $state('');
$effect(() => {
if (!addressId) return;
loading = true;
error = '';
schedules
.fetch({
variables: { filters: { accountAddressId: fromGlobalId(addressId) } },
policy: 'NetworkOnly'
})
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load schedules'))
.finally(() => (loading = false));
});
// Get current schedules (active based on today's date)
let currentSchedules = $derived(() => {
const list = [...($schedules.data?.schedules ?? [])];
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
return list.filter((s) => {
const isAfterStart = s.startDate <= today;
const isBeforeEnd = !s.endDate || s.endDate >= today;
return isAfterStart && isBeforeEnd;
});
});
</script>
{#if loading || $schedules.fetching}
<div class="flex items-center gap-1">
<Spinner size="4" />
</div>
{:else if error}
<div class="text-xs text-red-600">Error</div>
{:else if currentSchedules().length}
<div class="space-y-1">
{#each currentSchedules() as s (s.id)}
<div
role="button"
tabindex="0"
class="hover:border-primary-300 cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
onclick={() => onEditSchedule?.(s.id)}
onkeydown={(e) => e.key === 'Enter' && onEditSchedule?.(s.id)}
title="Click to edit schedule"
>
<div class="mb-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{s.name || 'Schedule'}
</div>
<div class="flex flex-wrap gap-1">
{#if s.mondayService}<Badge size="small" color="blue" class="text-xs">Mo</Badge>{/if}
{#if s.tuesdayService}<Badge size="small" color="blue" class="text-xs">Tu</Badge>{/if}
{#if s.wednesdayService}<Badge size="small" color="blue" class="text-xs">We</Badge>{/if}
{#if s.thursdayService}<Badge size="small" color="blue" class="text-xs">Th</Badge>{/if}
{#if s.fridayService}<Badge size="small" color="blue" class="text-xs">Fr</Badge>{/if}
{#if s.saturdayService}<Badge size="small" color="blue" class="text-xs">Sa</Badge>{/if}
{#if s.sundayService}<Badge size="small" color="blue" class="text-xs">Su</Badge>{/if}
{#if s.weekendService}
<Badge size="small" color="purple" class="text-xs">Weekend</Badge>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">None</div>
{/if}

View File

@ -0,0 +1,11 @@
<script lang="ts">
import AccountForm from '$lib/components/forms/accounts/AccountForm.svelte';
let { customerId, onSuccess, onCancel } = $props<{
customerId?: string;
onSuccess?: () => void;
onCancel?: () => void;
}>();
</script>
<AccountForm {customerId} {onSuccess} {onCancel} />

View File

@ -0,0 +1,151 @@
<script lang="ts">
import { CreateAccountAddressStore } from '$houdini';
import { Alert, Button, Input, Label } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Account {
id: string;
}
interface Props {
account: Account;
onSuccess?: () => void;
onCancel?: () => void;
}
let { account, onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let streetAddress = $state('');
let city = $state('');
let stateValue = $state('');
let zipCode = $state('');
let name = $state('');
let notes = $state('');
let create = $state(new CreateAccountAddressStore());
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!streetAddress || !city || !stateValue || !zipCode) {
error = 'Please fill in all required fields';
return;
}
const res = await create.mutate({
input: {
accountId: account.id,
name,
streetAddress,
city,
state: stateValue,
zipCode,
notes,
isActive: true,
isPrimary: false
}
});
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 create address';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="name" class="mb-2">Address Name</Label>
<Input
id="name"
type="text"
bind:value={name}
disabled={creating}
placeholder="Enter address name (optional)"
/>
</div>
<div>
<Label for="streetAddress" class="mb-2">Street Address</Label>
<Input
id="streetAddress"
type="text"
bind:value={streetAddress}
disabled={creating}
placeholder="Enter street address"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="city" class="mb-2">City</Label>
<Input
id="city"
type="text"
bind:value={city}
disabled={creating}
placeholder="Enter city"
/>
</div>
<div>
<Label for="state" class="mb-2">State</Label>
<Input
id="state"
type="text"
bind:value={stateValue}
disabled={creating}
placeholder="Enter state"
/>
</div>
</div>
<div>
<Label for="zipCode" class="mb-2">ZIP Code</Label>
<Input
id="zipCode"
type="text"
bind:value={zipCode}
disabled={creating}
placeholder="Enter ZIP code"
/>
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Input
id="notes"
type="text"
bind:value={notes}
disabled={creating}
placeholder="Enter notes (optional)"
/>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Address'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,142 @@
<script lang="ts">
import { CreateAccountContactStore } from '$houdini';
import { Alert, Button, Input, Label, Textarea } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Account {
id: string;
}
interface Props {
account: Account;
onSuccess?: () => void;
onCancel?: () => void;
}
let { account, onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let notes = $state('');
let create = $state(new CreateAccountContactStore());
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!firstName) {
error = 'Please provide at least a first name';
return;
}
if (!email && !phone) {
error = 'Please provide either an email or phone number';
return;
}
const res = await create.mutate({
input: {
accountId: account.id,
firstName,
lastName,
email,
phone,
notes,
isActive: true,
isPrimary: false
}
});
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 create contact';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="firstName" class="mb-2">First Name</Label>
<Input
id="firstName"
type="text"
bind:value={firstName}
disabled={creating}
placeholder="Enter first name"
/>
</div>
<div>
<Label for="lastName" class="mb-2">Last Name</Label>
<Input
id="lastName"
type="text"
bind:value={lastName}
disabled={creating}
placeholder="Enter last name"
/>
</div>
</div>
<div>
<Label for="email" class="mb-2">Email</Label>
<Input
id="email"
type="email"
bind:value={email}
disabled={creating}
placeholder="Enter email address"
/>
</div>
<div>
<Label for="phone" class="mb-2">Phone</Label>
<Input
id="phone"
type="tel"
bind:value={phone}
disabled={creating}
placeholder="Enter phone number"
/>
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Textarea
id="notes"
bind:value={notes}
disabled={creating}
placeholder="Enter any notes (optional)"
rows={3}
/>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Contact'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,100 @@
<script lang="ts">
import { CreateLaborStore } from '$houdini';
import { Alert, Button, Input, Label } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
addressId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { addressId, onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let amount = $state('');
let startDate = $state('');
let endDate = $state('');
let create = $state(new CreateLaborStore());
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
const amt = parseFloat(String(amount));
if (Number.isNaN(amt)) {
error = 'Please enter a valid amount';
return;
}
if (!startDate) {
error = 'Please provide a start date';
return;
}
const res = await create.mutate({
input: {
accountAddressId: addressId,
amount: amt,
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 create labor';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="amount" class="mb-2">Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
bind:value={amount}
disabled={creating}
placeholder="Enter amount"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} disabled={creating} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input id="endDate" type="date" bind:value={endDate} disabled={creating} />
</div>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Labor'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,104 @@
<script lang="ts">
import { CreateRevenueStore } from '$houdini';
import { Alert, Button, Input, Label } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Account {
id: string;
}
interface Props {
account: Account;
onSuccess?: () => void;
onCancel?: () => void;
}
let { account, onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let amount = $state('');
let startDate = $state('');
let endDate = $state('');
let create = $state(new CreateRevenueStore());
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
const amt = parseFloat(String(amount));
if (Number.isNaN(amt)) {
error = 'Please enter a valid amount';
return;
}
if (!startDate) {
error = 'Please provide a start date';
return;
}
const res = await create.mutate({
input: {
accountId: account.id,
amount: amt,
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 create revenue';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="amount" class="mb-2">Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
bind:value={amount}
disabled={creating}
placeholder="Enter amount"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} disabled={creating} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input id="endDate" type="date" bind:value={endDate} disabled={creating} />
</div>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Revenue'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,157 @@
<script lang="ts">
import { CreateScheduleStore } from '$houdini';
import { Alert, Button, Input, Label, Checkbox, Textarea } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
addressId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { addressId, onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let name = $state('Service Schedule');
let startDate = $state('');
let endDate = $state('');
let scheduleException = $state('');
let sundayService = $state(false);
let mondayService = $state(false);
let tuesdayService = $state(false);
let wednesdayService = $state(false);
let thursdayService = $state(false);
let fridayService = $state(false);
let saturdayService = $state(false);
let weekendService = $state(false);
let create = $state(new CreateScheduleStore());
$effect(() => {
// Enforce rule: If weekendService is enabled, clear Fri/Sat/Sun individual flags
if (weekendService) {
sundayService = false;
saturdayService = false;
fridayService = false;
}
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!startDate) {
error = 'Please provide a start date';
return;
}
const res = await create.mutate({
input: {
accountAddressId: addressId,
name: name || 'Service Schedule',
startDate,
endDate: endDate || null,
scheduleException: scheduleException || null,
sundayService,
mondayService,
tuesdayService,
wednesdayService,
thursdayService,
fridayService,
saturdayService,
weekendService
}
});
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 create schedule';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="name" class="mb-2">Schedule Name</Label>
<Input
id="name"
type="text"
bind:value={name}
disabled={creating}
placeholder="Enter schedule name"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} disabled={creating} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input id="endDate" type="date" bind:value={endDate} disabled={creating} />
</div>
</div>
<div>
<Label class="mb-4 block text-sm font-medium text-gray-700 dark:text-gray-300"
>Service Days</Label
>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<Checkbox bind:checked={mondayService} disabled={creating}>Monday</Checkbox>
<Checkbox bind:checked={tuesdayService} disabled={creating}>Tuesday</Checkbox>
<Checkbox bind:checked={wednesdayService} disabled={creating}>Wednesday</Checkbox>
<Checkbox bind:checked={thursdayService} disabled={creating}>Thursday</Checkbox>
<Checkbox bind:checked={fridayService} disabled={creating || weekendService}
>Friday</Checkbox
>
<Checkbox bind:checked={saturdayService} disabled={creating || weekendService}
>Saturday</Checkbox
>
<Checkbox bind:checked={sundayService} disabled={creating || weekendService}
>Sunday</Checkbox
>
<Checkbox bind:checked={weekendService} disabled={creating}>Weekend Service</Checkbox>
</div>
</div>
</div>
<div>
<Label for="scheduleException" class="mb-2">Schedule Exceptions</Label>
<Textarea
id="scheduleException"
bind:value={scheduleException}
disabled={creating}
placeholder="Enter any schedule exceptions (optional)"
rows={3}
/>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Schedule'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import { GetAccountStore } from '$houdini';
import { Alert, Spinner } from 'flowbite-svelte';
import AccountEditForm from '$lib/components/forms/accounts/AccountEditForm.svelte';
let { accountId, onSuccess, onCancel } = $props<{
accountId: string;
onSuccess?: () => void;
onCancel?: () => void;
}>();
let accountStore = $derived(new GetAccountStore());
let loading = $state(false);
let error = $state('');
$effect(() => {
if (!accountId) return;
loading = true;
error = '';
accountStore
.fetch({ variables: { id: accountId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load account'))
.finally(() => (loading = false));
});
</script>
{#if error}
<Alert color="red" class="mb-4">{error}</Alert>
{/if}
{#if loading || $accountStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading account data...
</div>
{:else if $accountStore.data?.account}
<AccountEditForm account={$accountStore.data.account} {onSuccess} {onCancel} />
{:else}
<div class="text-gray-600 dark:text-gray-300">Account not found.</div>
{/if}

View File

@ -0,0 +1,136 @@
<script lang="ts">
import { GetAccountAddressStore, UpdateAccountAddressStore } from '$houdini';
import { Alert, Button, Input, Label, Checkbox, Spinner } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
addressId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { addressId, onSuccess, onCancel }: Props = $props();
let accountAddressStore = $state(new GetAccountAddressStore());
let update = $state(new UpdateAccountAddressStore());
let loading = $state(false);
let error = $state('');
let streetAddress = $state('');
let city = $state('');
let stateValue = $state('');
let zipCode = $state('');
let isActive = $state(true);
let isPrimary = $state(false);
$effect(() => {
if (!addressId) return;
loading = true;
error = '';
accountAddressStore
.fetch({ variables: { id: addressId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.accountAddress) {
const address = res.data.accountAddress;
streetAddress = address.streetAddress ?? '';
city = address.city ?? '';
stateValue = address.state ?? '';
zipCode = address.zipCode ?? '';
isActive = address.isActive ?? true;
isPrimary = address.isPrimary ?? false;
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load address'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!streetAddress || !city || !stateValue || !zipCode) {
error = 'Please fill in all required fields';
return;
}
try {
const res = await update.mutate({
input: {
id: addressId,
streetAddress,
city,
state: stateValue,
zipCode,
isActive,
isPrimary
}
});
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 update address';
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if loading || $accountAddressStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading address data...
</div>
{:else if $accountAddressStore.data?.accountAddress}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="streetAddress" class="mb-2">Street Address</Label>
<Input
id="streetAddress"
type="text"
bind:value={streetAddress}
placeholder="Enter street address"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="city" class="mb-2">City</Label>
<Input id="city" type="text" bind:value={city} placeholder="Enter city" />
</div>
<div>
<Label for="state" class="mb-2">State</Label>
<Input id="state" type="text" bind:value={stateValue} placeholder="Enter state" />
</div>
</div>
<div>
<Label for="zipCode" class="mb-2">ZIP Code</Label>
<Input id="zipCode" type="text" bind:value={zipCode} placeholder="Enter ZIP code" />
</div>
<div class="flex gap-4">
<Checkbox bind:checked={isActive}>Active</Checkbox>
<Checkbox bind:checked={isPrimary}>Primary</Checkbox>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary">Save</Button>
<Button type="button" color="light" onclick={handleCancel}>Cancel</Button>
</div>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Address not found.</div>
{/if}
</div>

View File

@ -0,0 +1,139 @@
<script lang="ts">
import { GetAccountContactStore, UpdateAccountContactStore } from '$houdini';
import { Alert, Button, Input, Label, Textarea, Checkbox, Spinner } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
contactId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { contactId, onSuccess, onCancel }: Props = $props();
let accountContactStore = $state(new GetAccountContactStore());
let update = $state(new UpdateAccountContactStore());
let loading = $state(false);
let error = $state('');
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let notes = $state('');
let isActive = $state(true);
let isPrimary = $state(false);
$effect(() => {
if (!contactId) return;
loading = true;
error = '';
accountContactStore
.fetch({ variables: { id: contactId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.accountContact) {
const contact = res.data.accountContact;
firstName = contact.firstName ?? '';
lastName = contact.lastName ?? '';
email = contact.email ?? '';
phone = contact.phone ?? '';
notes = contact.notes ?? '';
isActive = contact.isActive ?? true;
isPrimary = contact.isPrimary ?? false;
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load contact'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!firstName || !email) {
error = 'Please provide at least first name and email';
return;
}
try {
const res = await update.mutate({
input: {
id: contactId,
firstName,
lastName,
email,
phone,
notes,
isActive,
isPrimary
}
});
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 update contact';
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if loading || $accountContactStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading contact data...
</div>
{:else if $accountContactStore.data?.accountContact}
<form class="space-y-6" onsubmit={handleSubmit}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="firstName" class="mb-2">First Name</Label>
<Input id="firstName" type="text" bind:value={firstName} placeholder="Enter first name" />
</div>
<div>
<Label for="lastName" class="mb-2">Last Name</Label>
<Input id="lastName" type="text" bind:value={lastName} placeholder="Enter last name" />
</div>
</div>
<div>
<Label for="email" class="mb-2">Email</Label>
<Input id="email" type="email" bind:value={email} placeholder="Enter email address" />
</div>
<div>
<Label for="phone" class="mb-2">Phone</Label>
<Input id="phone" type="tel" bind:value={phone} placeholder="Enter phone number" />
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Textarea id="notes" bind:value={notes} placeholder="Enter any notes (optional)" rows={3} />
</div>
<div class="flex gap-4">
<Checkbox bind:checked={isActive}>Active</Checkbox>
<Checkbox bind:checked={isPrimary}>Primary</Checkbox>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary">Save</Button>
<Button type="button" color="light" onclick={handleCancel}>Cancel</Button>
</div>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Contact not found.</div>
{/if}
</div>

View File

@ -0,0 +1,141 @@
<script lang="ts">
import { GetLaborStore, UpdateLaborStore } from '$houdini';
import { Alert, Button, Input, Label, Checkbox, Spinner } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
laborId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { laborId, onSuccess, onCancel }: Props = $props();
let laborStore = $state(new GetLaborStore());
let update = $state(new UpdateLaborStore());
let loading = $state(false);
let error = $state('');
let amount = $state(0);
let startDate = $state('');
let endDate = $state('');
let isActive = $state(true);
$effect(() => {
if (!laborId) return;
loading = true;
error = '';
laborStore
.fetch({ variables: { id: laborId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.labor) {
const labor = res.data.labor;
amount = parseFloat(labor.amount) ?? 0;
startDate = labor.startDate ?? '';
endDate = labor.endDate ?? '';
isActive = !labor.endDate; // Active if no end date
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load labor'))
.finally(() => (loading = false));
});
// Clear endDate when isActive is checked
$effect(() => {
if (isActive) {
endDate = '';
}
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!amount || !startDate) {
error = 'Please provide amount and start date';
return;
}
try {
const res = await update.mutate({
input: {
id: laborId,
amount,
startDate,
endDate: isActive ? null : 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 update labor';
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if loading || $laborStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading labor data...
</div>
{:else if $laborStore.data?.labor}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="amount" class="mb-2">Labor Rate Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
bind:value={amount}
placeholder="Enter labor rate amount"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input
id="endDate"
type="date"
bind:value={endDate}
disabled={isActive}
placeholder="Leave blank for ongoing"
/>
</div>
</div>
<div>
<Checkbox bind:checked={isActive}>Active (ongoing labor rate)</Checkbox>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Check this box if this labor rate is currently active. Only one active labor rate is
allowed per address.
</p>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary">Save</Button>
<Button type="button" color="light" onclick={handleCancel}>Cancel</Button>
</div>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Labor record not found.</div>
{/if}
</div>

View File

@ -0,0 +1,134 @@
<script lang="ts">
import { GetRevenueStore, UpdateRevenueStore } from '$houdini';
import { Alert, Button, Input, Label, Checkbox, Spinner } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
revenueId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { revenueId, onSuccess, onCancel }: Props = $props();
let revenueStore = $state(new GetRevenueStore());
let update = $state(new UpdateRevenueStore());
let loading = $state(false);
let error = $state('');
let amount = $state(0);
let startDate = $state('');
let endDate = $state('');
let isActive = $state(true);
$effect(() => {
if (!revenueId) return;
loading = true;
error = '';
revenueStore
.fetch({ variables: { id: revenueId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.revenue) {
const revenue = res.data.revenue;
amount = revenue.amount ?? 0;
startDate = revenue.startDate ?? '';
endDate = revenue.endDate ?? '';
isActive = !revenue.endDate; // Active if no end date
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load revenue'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!amount || !startDate) {
error = 'Please provide amount and start date';
return;
}
try {
const res = await update.mutate({
input: {
id: revenueId,
amount,
startDate,
endDate: isActive ? null : 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 update revenue';
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if loading || $revenueStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading revenue data...
</div>
{:else if $revenueStore.data?.revenue}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="amount" class="mb-2">Revenue Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
bind:value={amount}
placeholder="Enter revenue amount"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input
id="endDate"
type="date"
bind:value={endDate}
disabled={isActive}
placeholder="Leave blank for ongoing"
/>
</div>
</div>
<div>
<Checkbox bind:checked={isActive}>Active (ongoing revenue)</Checkbox>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Check this box if this revenue is currently active. Only one active revenue is allowed per
account.
</p>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary">Save</Button>
<Button type="button" color="light" onclick={handleCancel}>Cancel</Button>
</div>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Revenue record not found.</div>
{/if}
</div>

View File

@ -0,0 +1,182 @@
<script lang="ts">
import { GetScheduleStore, UpdateScheduleStore } from '$houdini';
import { Alert, Button, Input, Label, Checkbox, Spinner } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
scheduleId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { scheduleId, onSuccess, onCancel }: Props = $props();
let scheduleStore = $state(new GetScheduleStore());
let update = $state(new UpdateScheduleStore());
let loading = $state(false);
let error = $state('');
let name = $state('');
let startDate = $state('');
let endDate = $state('');
let isActive = $state(true);
let scheduleException = $state('');
// Day checkboxes
let sundayService = $state(false);
let mondayService = $state(false);
let tuesdayService = $state(false);
let wednesdayService = $state(false);
let thursdayService = $state(false);
let fridayService = $state(false);
let saturdayService = $state(false);
let weekendService = $state(false);
$effect(() => {
if (!scheduleId) return;
loading = true;
error = '';
scheduleStore
.fetch({ variables: { id: scheduleId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.schedule) {
const schedule = res.data.schedule;
name = schedule.name ?? '';
startDate = schedule.startDate ?? '';
endDate = schedule.endDate ?? '';
isActive = !schedule.endDate; // Active if no end date
scheduleException = schedule.scheduleException ?? '';
sundayService = schedule.sundayService ?? false;
mondayService = schedule.mondayService ?? false;
tuesdayService = schedule.tuesdayService ?? false;
wednesdayService = schedule.wednesdayService ?? false;
thursdayService = schedule.thursdayService ?? false;
fridayService = schedule.fridayService ?? false;
saturdayService = schedule.saturdayService ?? false;
weekendService = schedule.weekendService ?? false;
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load schedule'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!name || !startDate) {
error = 'Please provide schedule name and start date';
return;
}
try {
const res = await update.mutate({
input: {
id: scheduleId,
name,
startDate,
endDate: isActive ? null : endDate || null,
scheduleException,
sundayService,
mondayService,
tuesdayService,
wednesdayService,
thursdayService,
fridayService,
saturdayService,
weekendService
}
});
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 update schedule';
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if loading || $scheduleStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading schedule data...
</div>
{:else if $scheduleStore.data?.schedule}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="name" class="mb-2">Schedule Name</Label>
<Input id="name" type="text" bind:value={name} placeholder="Enter schedule name" />
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input
id="endDate"
type="date"
bind:value={endDate}
disabled={isActive}
placeholder="Leave blank for ongoing"
/>
</div>
</div>
<div>
<Label class="mb-3">Service Days</Label>
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<Checkbox bind:checked={sundayService}>Sunday</Checkbox>
<Checkbox bind:checked={mondayService}>Monday</Checkbox>
<Checkbox bind:checked={tuesdayService}>Tuesday</Checkbox>
<Checkbox bind:checked={wednesdayService}>Wednesday</Checkbox>
<Checkbox bind:checked={thursdayService}>Thursday</Checkbox>
<Checkbox bind:checked={fridayService}>Friday</Checkbox>
<Checkbox bind:checked={saturdayService}>Saturday</Checkbox>
<Checkbox bind:checked={weekendService}>Weekends</Checkbox>
</div>
</div>
<div>
<Label for="scheduleException" class="mb-2">Schedule Exception</Label>
<Input
id="scheduleException"
type="text"
bind:value={scheduleException}
placeholder="Optional: special notes or exceptions"
/>
</div>
<div>
<Checkbox bind:checked={isActive}>Active (ongoing schedule)</Checkbox>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Check this box if this schedule is currently active. Only one active schedule is allowed
per address.
</p>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary">Save</Button>
<Button type="button" color="light" onclick={handleCancel}>Cancel</Button>
</div>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Schedule not found.</div>
{/if}
</div>

View File

@ -0,0 +1,10 @@
<script lang="ts">
import CustomerForm from '$lib/components/forms/customers/CustomerForm.svelte';
let { onSuccess, onCancel } = $props<{
onSuccess?: () => void;
onCancel?: () => void;
}>();
</script>
<CustomerForm {onSuccess} {onCancel} />

View File

@ -0,0 +1,150 @@
<script lang="ts">
import { CreateCustomerAddressStore } from '$houdini';
import { Alert, Button, Input, Label, Select } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Customer {
id: string;
}
interface Props {
customer: Customer;
onSuccess?: () => void;
onCancel?: () => void;
}
let { customer, onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let streetAddress = $state('');
let city = $state('');
let stateValue = $state('');
let zipCode = $state('');
let addressType = $state('');
let addressTypes = $derived([
{ value: 'BILLING', name: 'Billing' },
{ value: 'OFFICE', name: 'Office' },
{ value: 'SHIPPING', name: 'Shipping' },
{ value: 'OTHER', name: 'Other' }
]);
let create = $state(new CreateCustomerAddressStore());
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!streetAddress || !city || !stateValue || !zipCode || !addressType) {
error = 'Please fill in all required fields';
return;
}
const res = await create.mutate({
input: {
customerId: customer.id,
streetAddress,
city,
state: stateValue,
zipCode,
addressType,
isActive: true,
isPrimary: false
}
});
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 create address';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="streetAddress" class="mb-2">Street Address</Label>
<Input
id="streetAddress"
type="text"
bind:value={streetAddress}
disabled={creating}
placeholder="Enter street address"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="city" class="mb-2">City</Label>
<Input
id="city"
type="text"
bind:value={city}
disabled={creating}
placeholder="Enter city"
/>
</div>
<div>
<Label for="state" class="mb-2">State</Label>
<Input
id="state"
type="text"
bind:value={stateValue}
disabled={creating}
placeholder="Enter state"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="zipCode" class="mb-2">ZIP Code</Label>
<Input
id="zipCode"
type="text"
bind:value={zipCode}
disabled={creating}
placeholder="Enter ZIP code"
/>
</div>
<div>
<Label for="addressType" class="mb-2">Address Type</Label>
<Select
id="addressType"
bind:value={addressType}
disabled={creating}
placeholder="Select address type"
>
<option value="" disabled>Select address type</option>
{#each addressTypes as type (type.value)}
<option value={type.value}>{type.name}</option>
{/each}
</Select>
</div>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Address'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,142 @@
<script lang="ts">
import { CreateCustomerContactStore } from '$houdini';
import { Alert, Button, Input, Label, Textarea } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Customer {
id: string;
}
interface Props {
customer: Customer;
onSuccess?: () => void;
onCancel?: () => void;
}
let { customer, onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let notes = $state('');
let create = $state(new CreateCustomerContactStore());
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!firstName) {
error = 'Please provide at least a first name';
return;
}
if (!email && !phone) {
error = 'Please provide either an email or phone number';
return;
}
const res = await create.mutate({
input: {
customerId: customer.id,
firstName,
lastName,
email,
phone,
notes,
isActive: true,
isPrimary: false
}
});
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 create contact';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="firstName" class="mb-2">First Name</Label>
<Input
id="firstName"
type="text"
bind:value={firstName}
disabled={creating}
placeholder="Enter first name"
/>
</div>
<div>
<Label for="lastName" class="mb-2">Last Name</Label>
<Input
id="lastName"
type="text"
bind:value={lastName}
disabled={creating}
placeholder="Enter last name"
/>
</div>
</div>
<div>
<Label for="email" class="mb-2">Email</Label>
<Input
id="email"
type="email"
bind:value={email}
disabled={creating}
placeholder="Enter email address"
/>
</div>
<div>
<Label for="phone" class="mb-2">Phone</Label>
<Input
id="phone"
type="tel"
bind:value={phone}
disabled={creating}
placeholder="Enter phone number"
/>
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Textarea
id="notes"
bind:value={notes}
disabled={creating}
placeholder="Enter any notes (optional)"
rows={3}
/>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Contact'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import { GetCustomerStore } from '$houdini';
import { Alert, Spinner } from 'flowbite-svelte';
import CustomerEditForm from '$lib/components/forms/customers/CustomerEditForm.svelte';
let { customerId, onSuccess, onCancel } = $props<{
customerId: string;
onSuccess?: () => void;
onCancel?: () => void;
}>();
let customerStore = $derived(new GetCustomerStore());
let loading = $state(false);
let error = $state('');
$effect(() => {
if (!customerId) return;
loading = true;
error = '';
customerStore
.fetch({ variables: { id: customerId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load customer'))
.finally(() => (loading = false));
});
</script>
{#if error}
<Alert color="red" class="mb-4">{error}</Alert>
{/if}
{#if loading || $customerStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading customer data...
</div>
{:else if $customerStore.data?.customer}
<CustomerEditForm customer={$customerStore.data.customer} {onSuccess} {onCancel} />
{:else}
<div class="text-gray-600 dark:text-gray-300">Customer not found.</div>
{/if}

View File

@ -0,0 +1,157 @@
<script lang="ts">
import { GetCustomerAddressStore, UpdateCustomerAddressStore } from '$houdini';
import { Alert, Button, Input, Label, Select, Checkbox, Spinner } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
addressId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { addressId, onSuccess, onCancel }: Props = $props();
let customerAddressStore = $state(new GetCustomerAddressStore());
let update = $state(new UpdateCustomerAddressStore());
let loading = $state(false);
let error = $state('');
let streetAddress = $state('');
let city = $state('');
let stateValue = $state('');
let zipCode = $state('');
let addressType = $state('');
let isActive = $state(true);
let isPrimary = $state(false);
let addressTypes = [
{ value: 'BILLING', name: 'Billing' },
{ value: 'OFFICE', name: 'Office' },
{ value: 'SHIPPING', name: 'Shipping' },
{ value: 'OTHER', name: 'Other' }
];
$effect(() => {
if (!addressId) return;
loading = true;
error = '';
customerAddressStore
.fetch({ variables: { id: addressId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.customerAddress) {
const address = res.data.customerAddress;
streetAddress = address.streetAddress ?? '';
city = address.city ?? '';
stateValue = address.state ?? '';
zipCode = address.zipCode ?? '';
addressType = address.addressType ?? '';
isActive = address.isActive ?? true;
isPrimary = address.isPrimary ?? false;
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load address'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!streetAddress || !city || !stateValue || !zipCode || !addressType) {
error = 'Please fill in all required fields';
return;
}
try {
const res = await update.mutate({
input: {
id: addressId,
streetAddress,
city,
state: stateValue,
zipCode,
addressType,
isActive,
isPrimary
}
});
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 update address';
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if loading || $customerAddressStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading address data...
</div>
{:else if $customerAddressStore.data?.customerAddress}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="streetAddress" class="mb-2">Street Address</Label>
<Input
id="streetAddress"
type="text"
bind:value={streetAddress}
placeholder="Enter street address"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="city" class="mb-2">City</Label>
<Input id="city" type="text" bind:value={city} placeholder="Enter city" />
</div>
<div>
<Label for="state" class="mb-2">State</Label>
<Input id="state" type="text" bind:value={stateValue} placeholder="Enter state" />
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="zipCode" class="mb-2">ZIP Code</Label>
<Input id="zipCode" type="text" bind:value={zipCode} placeholder="Enter ZIP code" />
</div>
<div>
<Label for="addressType" class="mb-2">Address Type</Label>
<Select id="addressType" bind:value={addressType}>
<option value="" disabled>Select address type</option>
{#each addressTypes as type (type.value)}
<option value={type.value}>{type.name}</option>
{/each}
</Select>
</div>
</div>
<div class="flex gap-4">
<Checkbox bind:checked={isActive}>Active</Checkbox>
<Checkbox bind:checked={isPrimary}>Primary</Checkbox>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary">Save</Button>
<Button type="button" color="light" onclick={handleCancel}>Cancel</Button>
</div>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Address not found.</div>
{/if}
</div>

View File

@ -0,0 +1,139 @@
<script lang="ts">
import { isAuthenticated } from '$lib/auth';
import { GetCustomerContactStore, UpdateCustomerContactStore } from '$houdini';
import { Alert, Button, Input, Label, Textarea, Checkbox, Spinner } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
contactId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { contactId, onSuccess, onCancel }: Props = $props();
let customerContactStore = $state(new GetCustomerContactStore());
let update = $state(new UpdateCustomerContactStore());
let loading = $state(false);
let error = $state('');
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let notes = $state('');
let isActive = $state(true);
let isPrimary = $state(false);
$effect(() => {
if (!$isAuthenticated || !contactId) return;
loading = true;
error = '';
customerContactStore
.fetch({ variables: { id: contactId }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.customerContact) {
const contact = res.data.customerContact;
firstName = contact.firstName ?? '';
lastName = contact.lastName ?? '';
email = contact.email ?? '';
phone = contact.phone ?? '';
notes = contact.notes ?? '';
isActive = contact.isActive ?? true;
isPrimary = contact.isPrimary ?? false;
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load contact'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!firstName || !email) {
error = 'Please provide at least first name and email';
return;
}
try {
const res = await update.mutate({
input: {
id: contactId,
firstName,
lastName,
email,
phone,
notes,
isActive,
isPrimary
}
});
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 update contact';
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if loading || $customerContactStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading contact data...
</div>
{:else if $customerContactStore.data?.customerContact}
<form class="space-y-6" onsubmit={handleSubmit}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="firstName" class="mb-2">First Name</Label>
<Input id="firstName" type="text" bind:value={firstName} placeholder="Enter first name" />
</div>
<div>
<Label for="lastName" class="mb-2">Last Name</Label>
<Input id="lastName" type="text" bind:value={lastName} placeholder="Enter last name" />
</div>
</div>
<div>
<Label for="email" class="mb-2">Email</Label>
<Input id="email" type="email" bind:value={email} placeholder="Enter email address" />
</div>
<div>
<Label for="phone" class="mb-2">Phone</Label>
<Input id="phone" type="tel" bind:value={phone} placeholder="Enter phone number" />
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Textarea id="notes" bind:value={notes} placeholder="Enter any notes (optional)" rows={3} />
</div>
<div class="flex gap-4">
<Checkbox bind:checked={isActive}>Active</Checkbox>
<Checkbox bind:checked={isPrimary}>Primary</Checkbox>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary">Save</Button>
<Button type="button" color="light" onclick={handleCancel}>Cancel</Button>
</div>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Contact not found.</div>
{/if}
</div>

View File

@ -0,0 +1,104 @@
<script lang="ts">
import { GetAccountsStore } from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
import { offCanvas } from '$lib/utils/offCanvas';
import { Alert, Spinner, Badge } from 'flowbite-svelte';
import { goto } from '$app/navigation';
let { customerId } = $props<{
customerId: string;
}>();
let accountsStore = $derived(new GetAccountsStore());
let loading = $state(false);
let error = $state('');
// Decode customerId to get the UUID for filtering
let customerUUID = $derived(fromGlobalId(customerId));
$effect(() => {
if (!customerUUID) return;
loading = true;
error = '';
accountsStore
.fetch({
variables: { filters: { customerId: customerUUID } },
policy: 'NetworkOnly'
})
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load accounts'))
.finally(() => (loading = false));
});
function statusColor(status: string): 'green' | 'red' | 'yellow' | 'purple' | 'gray' | 'blue' {
switch (status) {
case 'ACTIVE':
return 'green';
case 'PAUSED':
return 'yellow';
case 'ENDED':
return 'gray';
default:
return 'blue';
}
}
function handleAccountClick(accountId: string) {
offCanvas.closeRight();
goto(`/accounts/${accountId}`);
}
</script>
{#if error}
<Alert color="red" class="mb-4">{error}</Alert>
{/if}
{#if loading || $accountsStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading accounts...
</div>
{:else if $accountsStore.data?.accounts && $accountsStore.data.accounts.length > 0}
<div class="space-y-3">
{#each $accountsStore.data.accounts as account (account.id)}
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:border-blue-500 hover:bg-blue-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-400 dark:hover:bg-blue-900/20"
onclick={() => handleAccountClick(account.id)}
>
<span class="flex items-start justify-between">
<span class="min-w-0 flex-1">
<span class="mb-2 block text-lg font-semibold text-gray-900 dark:text-gray-100">
{account.name}
</span>
{#if account.primaryAddress}
<span class="block text-sm text-gray-600 dark:text-gray-400">
{account.primaryAddress.streetAddress}
<br />
{account.primaryAddress.city}, {account.primaryAddress.state}
{account.primaryAddress.zipCode}
</span>
{/if}
</span>
<Badge color={statusColor(String(account.status))} class="ml-4 shrink-0 px-3 py-1">
{String(account.status)}
</Badge>
</span>
</button>
{/each}
<div class="pt-2 text-center text-sm text-gray-500 dark:text-gray-400">
{$accountsStore.data.accounts.length} account{$accountsStore.data.accounts.length === 1
? ''
: 's'} found
</div>
</div>
{:else}
<div class="py-8 text-center text-gray-600 dark:text-gray-400">
No accounts found for this customer.
</div>
{/if}

View File

@ -0,0 +1,356 @@
<script lang="ts">
import {
getEntityConfig,
formatFieldValue,
getFieldHeaders,
getFieldLabels,
getTitleKey,
getStatusColor
} from '$lib/config/entityConfig';
import type { EntityType } from '$lib/types/entities';
import type { EntityConfig } from '$lib/config/entityConfig';
import HybridView from '$lib/components/shared/HybridView.svelte';
import { Button, Spinner, Alert, Input } from 'flowbite-svelte';
import {
isInactiveEntity,
transformEntityForDisplay,
filterEntities,
sortEntities
} from '$lib/utils/entityUtils';
let {
entityType,
items = [],
loading = false,
error = '',
showInactive = false,
searchable = true,
sortable = true,
onCreateClick,
createButtonText = 'Add Item'
} = $props<{
entityType: string;
items: EntityType[];
loading?: boolean;
error?: string;
showInactive?: boolean;
searchable?: boolean;
sortable?: boolean;
onCreateClick?: () => void;
createButtonText?: string;
}>();
// Get configuration for this entity type
const config: EntityConfig = getEntityConfig(entityType);
// Local state for search and sorting
let searchTerm = $state('');
let sortDirection: 'asc' | 'desc' = $state('asc');
// Filter items based on showInactive flag
const activeItems = $derived(() => {
if (showInactive) return items;
return items.filter(
(item: EntityType) => !isInactiveEntity(item as unknown as Record<string, unknown>)
);
});
// Apply search filtering
const searchedItems = $derived(() => {
if (!searchable || !searchTerm.trim()) return activeItems();
const filtered = filterEntities(
activeItems() as unknown as Record<string, unknown>[],
searchTerm,
entityType
);
return filtered as unknown as EntityType[];
});
// Apply sorting
const sortedItems = $derived(() => {
if (!sortable) return searchedItems();
const sorted = sortEntities(
searchedItems() as unknown as Record<string, unknown>[],
entityType,
sortDirection
);
return sorted as unknown as EntityType[];
});
// Transform items for display with formatted values and status
const displayItems = $derived(() => {
return sortedItems().map((item: EntityType) => {
const transformed = transformEntityForDisplay(
item as unknown as Record<string, unknown>,
entityType
);
// Format each primary field according to its type
const displayItem: Record<string, unknown> = { ...transformed };
config.primaryFields.forEach((field) => {
if (field.key in displayItem) {
displayItem[field.key] = formatFieldValue(displayItem[field.key], field.type);
}
});
// Add computed status for display
const itemRecord = item as unknown as Record<string, unknown>;
displayItem._computedStatus = getEntityStatus(itemRecord);
displayItem._statusColor = getStatusColor(String(displayItem._computedStatus));
return displayItem;
});
});
// Prepare data for HybridView using utility functions
const headers = $derived(() => getFieldHeaders(entityType));
const labels = $derived(() => getFieldLabels(entityType));
const titleKey = $derived(() => getTitleKey(entityType));
// Toggle sort direction
function toggleSort() {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
}
// Clear search
function clearSearch() {
searchTerm = '';
}
// Get computed status for an entity (considering dates, etc.)
function getEntityStatus(item: Record<string, unknown>): string {
// Check if entity has explicit status field
if (config.statusField && item[config.statusField]) {
return String(item[config.statusField]).toUpperCase();
}
// For entities with start/end dates, compute status
if ('startDate' in item && 'endDate' in item) {
const now = new Date();
const startDate = item.startDate ? new Date(String(item.startDate)) : null;
const endDate = item.endDate ? new Date(String(item.endDate)) : null;
if (endDate && endDate < now) {
return 'EXPIRED';
}
if (startDate && startDate > now) {
return 'PENDING';
}
return 'ACTIVE';
}
// Check isActive field
if ('isActive' in item) {
return item.isActive ? 'ACTIVE' : 'INACTIVE';
}
// Check isExpired field (for invitations)
if ('isExpired' in item) {
return item.isExpired ? 'EXPIRED' : 'ACTIVE';
}
return 'ACTIVE'; // Default status
}
// Get count text for display
const countText = $derived(() => {
const total = items.length;
const visible = displayItems().length;
const entityName = config.entityName;
if (total === visible) {
return `${total} ${entityName}`;
} else {
return `${visible} of ${total} ${entityName}`;
}
});
</script>
<section class="p-4 md:p-6">
<div class="mx-auto max-w-7xl">
<!-- Header Section -->
<div class="mb-6">
<div class="flex items-start justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 capitalize dark:text-gray-100">
{config.entityName}
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Manage your {config.entityName}.
{#if !showInactive}
Inactive entries are hidden by default.
{/if}
</p>
</div>
{#if onCreateClick}
<Button
color="primary"
class="px-6 py-3 text-sm font-medium shadow-sm"
onclick={onCreateClick}
>
{createButtonText}
</Button>
{/if}
</div>
</div>
<!-- Controls Section -->
<div class="mb-6 space-y-4">
<!-- Search and Sort Controls -->
{#if searchable || sortable}
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{#if searchable}
<div class="max-w-md flex-1">
<div class="relative">
<Input
bind:value={searchTerm}
placeholder="Search {config.entityName}..."
class="pr-10"
/>
{#if searchTerm}
<button
onclick={clearSearch}
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
aria-label="Clear search"
>
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
stroke="currentColor"
style="fill: none;"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
{/if}
<div class="flex items-center gap-4">
{#if sortable}
<Button color="light" size="sm" onclick={toggleSort} class="flex items-center gap-2">
<span>Sort</span>
<svg
class="h-4 w-4 transition-transform {sortDirection === 'desc'
? 'rotate-180'
: ''}"
viewBox="0 0 24 24"
stroke="currentColor"
style="fill: none;"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 11l5-5m0 0l5 5m-5-5v12"
/>
</svg>
</Button>
{/if}
</div>
</div>
{/if}
<!-- Filters and Count -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
bind:checked={showInactive}
class="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 dark:border-gray-600"
/>
<span>Show inactive {config.entityName}</span>
</label>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{countText()}
</div>
</div>
</div>
<!-- Error Display -->
{#if error}
<Alert color="red" class="mb-4">
<span class="font-medium">Error:</span>
{error}
</Alert>
{/if}
<!-- Content Display -->
{#if loading}
<div class="flex items-center justify-center gap-3 py-12 text-gray-600 dark:text-gray-400">
<Spinner size="6" />
<span class="text-lg">Loading {config.entityName}...</span>
</div>
{:else if items.length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-gray-400 dark:text-gray-500">
<svg
class="mx-auto h-16 w-16"
viewBox="0 0 24 24"
stroke="currentColor"
style="fill: none;"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
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>
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-gray-100">
No {config.entityName} found
</h3>
<p class="mb-6 text-gray-500 dark:text-gray-400">
Get started by adding your first {config.entityName.slice(0, -1)}.
</p>
{#if onCreateClick}
<Button color="primary" onclick={onCreateClick}>{createButtonText}</Button>
{/if}
</div>
{:else if displayItems().length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-gray-400 dark:text-gray-500">
<svg
class="mx-auto h-16 w-16"
viewBox="0 0 24 24"
stroke="currentColor"
style="fill: none;"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-gray-100">
No matching {config.entityName} found
</h3>
<p class="mb-6 text-gray-500 dark:text-gray-400">
Try adjusting your search or filters to find what you're looking for.
</p>
{#if searchTerm}
<Button color="light" onclick={clearSearch}>Clear search</Button>
{/if}
</div>
{:else}
<!-- Use HybridView for responsive display -->
<HybridView
items={displayItems()}
headers={headers()}
labels={labels()}
titleKey={titleKey()}
/>
{/if}
</div>
</section>

View File

@ -0,0 +1,128 @@
<script lang="ts">
import { UpdateAccountStore } from '$houdini';
import { Alert, Button, Input, Label, Select } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
account: {
id: string;
name: string;
startDate: string | null;
endDate: string | null;
status: string;
};
onSuccess?: () => void;
onCancel?: () => void;
}
let { account, onSuccess, onCancel }: Props = $props();
let update = $state(new UpdateAccountStore());
let error = $state('');
let submitting = $state(false);
function toDateInput(dateStr: string | null): string {
if (!dateStr) return '';
try {
return new Date(dateStr).toISOString().split('T')[0];
} catch {
return '';
}
}
let name = $state(account.name ?? '');
let startDate = $state(toDateInput(account.startDate));
let endDate = $state(toDateInput(account.endDate));
let status = $state(account.status ?? 'ACTIVE');
let statusOptions = [
{ value: 'ACTIVE', name: 'Active' },
{ value: 'PENDING', name: 'Pending' },
{ value: 'INACTIVE', name: 'Inactive' },
{ value: 'ENDED', name: 'Ended' }
];
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!name || !startDate) {
error = 'Please provide name and start date';
return;
}
submitting = true;
error = '';
try {
const res = await update.mutate({
input: {
id: account.id,
name,
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 update account';
} finally {
submitting = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="name" class="mb-2">Account Name</Label>
<Input
id="name"
type="text"
bind:value={name}
placeholder="Enter account name"
disabled={submitting}
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} disabled={submitting} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input id="endDate" type="date" bind:value={endDate} disabled={submitting} />
</div>
</div>
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" bind:value={status} disabled={submitting}>
{#each statusOptions as option (option.value)}
<option value={option.value}>{option.name}</option>
{/each}
</Select>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={submitting}>
{submitting ? 'Saving…' : 'Save'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={submitting}
>Cancel</Button
>
</div>
</form>
</div>

View File

@ -0,0 +1,131 @@
<script lang="ts">
import { CreateAccountStore, GetCustomersStore } from '$houdini';
import { Alert, Button, Input, Label, Select } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
customerId?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { customerId, onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let selectedCustomerId = $state(customerId || '');
let name = $state('');
let startDate = $state('');
let endDate = $state('');
let status = $state('ACTIVE');
let customers = new GetCustomersStore();
let statusOptions = [
{ value: 'ACTIVE', name: 'Active' },
{ value: 'PENDING', name: 'Pending' },
{ value: 'INACTIVE', name: 'Inactive' },
{ value: 'ENDED', name: 'Ended' }
];
let create = new CreateAccountStore();
$effect(() => {
customers.fetch({ variables: {} });
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!selectedCustomerId || !name || !startDate) {
error = 'Please select customer and provide name and start date';
return;
}
const res = await create.mutate({
input: {
customerId: selectedCustomerId,
name,
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 create account';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
{#if !customerId}
<div>
<Label for="customer" class="mb-2">Customer</Label>
<Select id="customer" bind:value={selectedCustomerId} disabled={creating}>
<option value="">Select a customer</option>
{#each $customers.data?.customers ?? [] as customer (customer.id)}
<option value={customer.id}>{customer.name}</option>
{/each}
</Select>
</div>
{/if}
<div>
<Label for="name" class="mb-2">Account Name</Label>
<Input
id="name"
type="text"
bind:value={name}
disabled={creating}
placeholder="Enter account name"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} disabled={creating} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input id="endDate" type="date" bind:value={endDate} disabled={creating} />
</div>
</div>
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" bind:value={status} disabled={creating}>
{#each statusOptions as option (option.value)}
<option value={option.value}>{option.name}</option>
{/each}
</Select>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Account'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,356 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
calendarService,
type CalendarEvent,
type UpdateEventPayload
} from '$lib/services/calendar';
import { Alert, Button, Input, Label, Textarea } from 'flowbite-svelte';
import { GetTeamProfilesStore } from '$houdini';
interface Props {
event: CalendarEvent;
}
let { event }: Props = $props();
// Form state
let summary = $state(event.summary || '');
let description = $state(event.description || '');
let location = $state(event.location || '');
let startDate = $state<string>(''); // yyyy-mm-dd
let startTime = $state<string>(''); // HH:mm
let endDate = $state<string>('');
let endTime = $state<string>('');
let timeZone = $state<string>(event.start?.timeZone || 'UTC');
let saving = $state(false);
let error = $state<string | null>(null);
// Attendees (team + manual)
type Attendee = {
id: string;
email: string;
displayName?: string;
isFromTeam: boolean;
profileId?: string;
};
const newId = () =>
crypto?.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
let attendees: Attendee[] = $state([]);
// Team members lookup
let profiles = $state(new GetTeamProfilesStore());
let selectedTeamMembers = $state<string[]>([]);
function formatDateForInput(isoString: string): { date: string; time: string } {
const date = new Date(isoString);
const dateStr = date.toISOString().split('T')[0]; // yyyy-mm-dd
const timeStr = date.toTimeString().slice(0, 5); // HH:mm
return { date: dateStr, time: timeStr };
}
// Initialize form with event data
$effect(() => {
summary = event.summary || '';
description = event.description || '';
location = event.location || '';
timeZone = event.start?.timeZone || 'UTC';
if (event.start?.dateTime) {
const { date, time } = formatDateForInput(event.start.dateTime);
startDate = date;
startTime = time;
}
if (event.end?.dateTime) {
const { date, time } = formatDateForInput(event.end.dateTime);
endDate = date;
endTime = time;
}
// Populate attendees as manual attendees (since we can't determine which are team members from event data)
if (event.attendees?.length) {
attendees = event.attendees.map((a) => ({
id: newId(),
email: a.email,
displayName: a.displayName || '',
isFromTeam: false
}));
}
});
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
});
function handleTeamMemberChange(profileId: string, isChecked: boolean) {
if (isChecked) {
if (!selectedTeamMembers.includes(profileId)) {
selectedTeamMembers = [...selectedTeamMembers, profileId];
}
const profile = ($profiles.data?.teamProfiles ?? []).find((p) => String(p.id) === profileId);
if (profile) {
const displayName =
profile.fullName ||
`${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim() ||
'Unknown';
const newAttendee: Attendee = {
id: newId(),
email: profile.email || '',
displayName,
isFromTeam: true,
profileId
};
attendees = [...attendees, newAttendee];
}
} else {
selectedTeamMembers = selectedTeamMembers.filter((id) => id !== profileId);
attendees = attendees.filter((a) => a.profileId !== profileId);
}
}
function addManualAttendee() {
attendees = [
...attendees,
{
id: newId(),
email: '',
displayName: '',
isFromTeam: false
}
];
}
function removeAttendee(index: number) {
const attendee = attendees[index];
if (attendee.isFromTeam && attendee.profileId) {
selectedTeamMembers = selectedTeamMembers.filter((id) => id !== attendee.profileId);
}
attendees = attendees.filter((_, i) => i !== index);
}
function toISO(date: string, time: string): string | null {
if (!date || !time) return null;
// Compose local datetime and convert to ISO string
const local = new Date(`${date}T${time}`);
if (isNaN(local.getTime())) return null;
return local.toISOString();
}
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
error = null;
saving = true;
try {
const startISO = toISO(startDate, startTime);
const endISO = toISO(endDate, endTime);
if (!startISO || !endISO) {
error = 'Please provide valid start and end date & time.';
saving = false;
return;
}
if (new Date(endISO).getTime() <= new Date(startISO).getTime()) {
error = 'End time must be after start time.';
saving = false;
return;
}
const payload: UpdateEventPayload = {
summary: summary.trim() || undefined,
description: description.trim() || undefined,
location: location.trim() || undefined,
start: {
dateTime: startISO,
timeZone
},
end: {
dateTime: endISO,
timeZone
},
attendees: attendees
.filter((a) => a.email && a.email.trim())
.map((a) => ({
email: a.email.trim(),
displayName: a.displayName?.trim() || undefined
}))
};
await calendarService.updateEvent(event.id, payload);
await goto(`/calendar/${event.id}`);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update event';
} finally {
saving = false;
}
}
function handleCancel() {
goto(`/calendar/${event.id}`);
}
</script>
{#if error}
<Alert color="red" class="mb-4">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={onSubmit}>
<div class="grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="summary" class="mb-2">Event Title</Label>
<Input
id="summary"
type="text"
bind:value={summary}
required
disabled={saving}
placeholder="Weekly team meeting"
/>
</div>
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Textarea
id="description"
bind:value={description}
disabled={saving}
placeholder="Optional event description"
rows={3}
/>
</div>
<div class="md:col-span-2">
<Label for="location" class="mb-2">Location</Label>
<Input
id="location"
type="text"
bind:value={location}
disabled={saving}
placeholder="Conference Room A, Zoom link, etc."
/>
</div>
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} required disabled={saving} />
</div>
<div>
<Label for="startTime" class="mb-2">Start Time</Label>
<Input id="startTime" type="time" bind:value={startTime} required disabled={saving} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input id="endDate" type="date" bind:value={endDate} required disabled={saving} />
</div>
<div>
<Label for="endTime" class="mb-2">End Time</Label>
<Input id="endTime" type="time" bind:value={endTime} required disabled={saving} />
</div>
<div class="md:col-span-2">
<Label for="timezone" class="mb-2">Time Zone</Label>
<Input
id="timezone"
type="text"
bind:value={timeZone}
disabled={saving}
placeholder="UTC, America/New_York, etc."
/>
</div>
<!-- Team Members -->
<div class="md:col-span-2">
<Label class="mb-2">Select Team Members</Label>
{#if $profiles.fetching}
<div class="text-sm text-gray-500">Loading team profiles...</div>
{:else if ($profiles.data?.teamProfiles?.length ?? 0) === 0}
<div class="text-sm text-gray-500">No team profiles found.</div>
{:else}
<div class="grid gap-2 md:grid-cols-2">
{#each ($profiles.data?.teamProfiles ?? []).filter((p) => String(p.status) === 'ACTIVE') as profile (profile.id)}
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={selectedTeamMembers.includes(String(profile.id))}
onchange={(e) =>
handleTeamMemberChange(String(profile.id), e.currentTarget.checked)}
disabled={saving}
/>
<span
>{profile.fullName ||
`${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()}</span
>
</label>
{/each}
</div>
{/if}
</div>
<!-- Attendees -->
<div class="space-y-4 md:col-span-2">
<div class="font-medium">Attendees</div>
{#if attendees.length === 0}
<div class="text-sm text-gray-500 italic dark:text-gray-400">No attendees added</div>
{:else}
{#each attendees as attendee, i (attendee.id)}
<div
class="grid items-end gap-2 rounded border border-gray-200 bg-gray-50 p-3 md:grid-cols-3 dark:border-gray-700 dark:bg-gray-800 {attendee.isFromTeam
? 'border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30'
: ''}"
>
<div>
<Label>Email</Label>
{#if attendee.isFromTeam}
<div
class="rounded bg-gray-100 p-2 text-sm text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{attendee.email || 'Loading...'}
</div>
{:else}
<Input bind:value={attendees[i].email} placeholder="name@example.com" />
{/if}
</div>
<div>
<Label>Display Name {attendee.isFromTeam ? '' : '(optional)'}</Label>
{#if attendee.isFromTeam}
<div
class="rounded bg-gray-100 p-2 text-sm text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{attendee.displayName}
</div>
{:else}
<Input bind:value={attendees[i].displayName} placeholder="Jane Doe" />
{/if}
</div>
<div class="flex items-center gap-2">
{#if attendee.isFromTeam}
<span class="text-xs font-medium text-blue-700 dark:text-blue-400">Team Member</span
>
{:else}
<Button type="button" color="light" onclick={() => removeAttendee(i)}>
Remove
</Button>
{/if}
</div>
</div>
{/each}
{/if}
<Button type="button" color="light" onclick={addManualAttendee}>Add Manual Attendee</Button>
</div>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={saving}>
{saving ? 'Updating...' : 'Update Event'}
</Button>
<Button type="button" color="light" onclick={handleCancel}>Cancel</Button>
</div>
</form>

View File

@ -0,0 +1,301 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { calendarService, type CreateEventPayload } from '$lib/services/calendar';
import { Alert, Button, Spinner, Input, Label, Textarea } from 'flowbite-svelte';
import { GetTeamProfilesStore } from '$houdini';
// Form state
let summary = $state('');
let description = $state('');
let location = $state('');
let startDate = $state<string>(''); // yyyy-mm-dd
let startTime = $state<string>(''); // HH:mm
let endDate = $state<string>('');
let endTime = $state<string>('');
let timeZone = $state<string>(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
let loading = $state(false);
let error = $state<string | null>(null);
// Attendees (team + manual)
type Attendee = {
id: string;
email: string;
displayName?: string;
isFromTeam: boolean;
profileId?: string;
};
const newId = () =>
crypto?.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
let attendees: Attendee[] = $state([]);
// Team members lookup
let profiles = $state(new GetTeamProfilesStore());
let selectedTeamMembers = $state<string[]>([]);
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
});
function handleTeamMemberChange(profileId: string, isChecked: boolean) {
if (isChecked) {
if (!selectedTeamMembers.includes(profileId)) {
selectedTeamMembers = [...selectedTeamMembers, profileId];
}
const profile = ($profiles.data?.teamProfiles ?? []).find((p) => String(p.id) === profileId);
if (profile) {
const displayName =
profile.fullName ||
`${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim() ||
'Unknown';
const newAttendee: Attendee = {
id: newId(),
email: profile.email || '',
displayName,
isFromTeam: true,
profileId
};
attendees = [...attendees, newAttendee];
}
} else {
selectedTeamMembers = selectedTeamMembers.filter((id) => id !== profileId);
attendees = attendees.filter((a) => a.profileId !== profileId);
}
}
function addManualAttendee() {
attendees = [
...attendees,
{
id: newId(),
email: '',
displayName: '',
isFromTeam: false
}
];
}
function removeAttendee(index: number) {
const attendee = attendees[index];
if (attendee.isFromTeam && attendee.profileId) {
selectedTeamMembers = selectedTeamMembers.filter((id) => id !== attendee.profileId);
}
attendees = attendees.filter((_, i) => i !== index);
}
function toISO(date: string, time: string): string | null {
if (!date || !time) return null;
// Compose local datetime and convert to ISO string
const local = new Date(`${date}T${time}`);
if (isNaN(local.getTime())) return null;
return local.toISOString();
}
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
error = null;
loading = true;
try {
const startISO = toISO(startDate, startTime);
const endISO = toISO(endDate, endTime);
if (!startISO || !endISO) {
error = 'Please provide valid start and end date & time.';
loading = false;
return;
}
if (new Date(endISO).getTime() <= new Date(startISO).getTime()) {
error = 'End time must be after start time.';
loading = false;
return;
}
const payload: CreateEventPayload = {
summary: summary.trim() || 'Untitled event',
description: description.trim() || undefined,
location: location.trim() || undefined,
start: { dateTime: startISO, timeZone },
end: { dateTime: endISO, timeZone },
attendees: attendees
.filter((a) => a.email && a.email.trim())
.map((a) => ({
email: a.email.trim(),
displayName: a.displayName?.trim() || undefined
}))
};
const created = await calendarService.createEvent(payload);
await goto(`/calendar/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create event';
} finally {
loading = false;
}
}
</script>
{#if error}
<Alert color="red" class="mb-4">{error}</Alert>
{/if}
<form onsubmit={onSubmit} class="space-y-6">
<div class="grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="title" class="mb-2">Title</Label>
<Input
id="title"
type="text"
placeholder="Event title"
bind:value={summary}
required
disabled={loading}
/>
</div>
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Textarea
id="description"
bind:value={description}
placeholder="Details (optional)"
rows={4}
disabled={loading}
/>
</div>
<div class="md:col-span-2">
<Label for="location" class="mb-2">Location</Label>
<Input
id="location"
type="text"
placeholder="Location (optional)"
bind:value={location}
disabled={loading}
/>
</div>
<div>
<Label for="start-date" class="mb-2">Start Date</Label>
<Input id="start-date" type="date" bind:value={startDate} required disabled={loading} />
</div>
<div>
<Label for="start-time" class="mb-2">Start Time</Label>
<Input id="start-time" type="time" bind:value={startTime} required disabled={loading} />
</div>
<div>
<Label for="end-date" class="mb-2">End Date</Label>
<Input id="end-date" type="date" bind:value={endDate} required disabled={loading} />
</div>
<div>
<Label for="end-time" class="mb-2">End Time</Label>
<Input id="end-time" type="time" bind:value={endTime} required disabled={loading} />
</div>
<div class="md:col-span-2">
<Label for="time-zone" class="mb-2">Time Zone</Label>
<Input
id="time-zone"
type="text"
placeholder="e.g. UTC or America/New_York"
bind:value={timeZone}
disabled={loading}
/>
</div>
<!-- Team Members -->
<div class="md:col-span-2">
<Label class="mb-2">Select Team Members</Label>
{#if $profiles.fetching}
<div class="text-sm text-gray-500">Loading team profiles...</div>
{:else if ($profiles.data?.teamProfiles?.length ?? 0) === 0}
<div class="text-sm text-gray-500">No team profiles found.</div>
{:else}
<div class="grid gap-2 md:grid-cols-2">
{#each ($profiles.data?.teamProfiles ?? []).filter((p) => String(p.status) === 'ACTIVE') as profile (profile.id)}
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={selectedTeamMembers.includes(String(profile.id))}
onchange={(e) =>
handleTeamMemberChange(String(profile.id), e.currentTarget.checked)}
disabled={loading}
/>
<span
>{profile.fullName ||
`${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()}</span
>
</label>
{/each}
</div>
{/if}
</div>
<!-- Attendees -->
<div class="space-y-4 md:col-span-2">
<div class="font-medium">Attendees</div>
{#if attendees.length === 0}
<div class="text-sm text-gray-500 italic dark:text-gray-400">No attendees added</div>
{:else}
{#each attendees as attendee, i (attendee.id)}
<div
class="grid items-end gap-2 rounded border border-gray-200 bg-gray-50 p-3 md:grid-cols-3 dark:border-gray-700 dark:bg-gray-800 {attendee.isFromTeam
? 'border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30'
: ''}"
>
<div>
<Label>Email</Label>
{#if attendee.isFromTeam}
<div
class="rounded bg-gray-100 p-2 text-sm text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{attendee.email || 'Loading...'}
</div>
{:else}
<Input bind:value={attendees[i].email} placeholder="name@example.com" />
{/if}
</div>
<div>
<Label>Display Name {attendee.isFromTeam ? '' : '(optional)'}</Label>
{#if attendee.isFromTeam}
<div
class="rounded bg-gray-100 p-2 text-sm text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{attendee.displayName}
</div>
{:else}
<Input bind:value={attendees[i].displayName} placeholder="Jane Doe" />
{/if}
</div>
<div class="flex items-center gap-2">
{#if attendee.isFromTeam}
<span class="text-xs font-medium text-blue-700 dark:text-blue-400">Team Member</span
>
{:else}
<Button type="button" color="light" onclick={() => removeAttendee(i)}>
Remove
</Button>
{/if}
</div>
</div>
{/each}
{/if}
<Button type="button" color="light" onclick={addManualAttendee}>Add Manual Attendee</Button>
</div>
</div>
<div class="flex items-center gap-3">
<Button color="primary" type="submit" disabled={loading}>
{#if loading}
<Spinner size="4" class="mr-2" />
Creating...
{:else}
Create Event
{/if}
</Button>
<Button color="light" href="/calendar">Cancel</Button>
</div>
</form>

View File

@ -0,0 +1,492 @@
<script lang="ts">
import {
calendarService,
type CreateEventPayload,
type UpdateEventPayload
} from '$lib/services/calendar';
import { Button, Input, Label, Textarea, Select } from 'flowbite-svelte';
import { buildRFC3339RangeForLocalDate, DETROIT_TZ, sleep } from '$lib/utils/date';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { GetTeamProfilesStore } from '$houdini';
// Incoming project context (business date, labels, etc.)
let {
address = '',
customerName = '',
accountName = '',
name = '',
date = '',
notes = ''
} = $props();
// Event metadata
let eventId: string = $state('');
let eventSummary: string = $state(`PROJECT: ${name} - ${accountName} - ${customerName}`);
let eventDescription: string = $state(notes);
let eventLocation: string = $state(address);
// Time inputs (24h HH:mm)
let eventStartTime: string = $state('');
let eventEndTime: string = $state('');
// Locked time zone (America/Detroit)
const TIME_ZONE = DETROIT_TZ;
// Overnight handling:
// - If end time < start time: end auto-rolls to the next day (handled by buildRFC3339RangeForLocalDate).
// - If work starts after midnight relative to the project date (business date is the previous day),
// toggle this to shift both start and end by +1 day.
let startNextDay: boolean = $state(false);
let submitting = $state(false);
let message: string = $state('');
let error: string = $state('');
function goBack(fallbackHref = '/projects') {
if (browser && history.length > 1) {
history.back();
} else {
goto(fallbackHref);
}
}
// Attendees (team + manual)
type Attendee = {
id: string;
email: string;
displayName?: string;
isFromTeam: boolean;
profileId?: string;
};
const newId = () =>
crypto?.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
let attendees: Attendee[] = $state([]);
// Team members lookup
let profiles = $state(new GetTeamProfilesStore());
let selectedTeamMembers = $state<string[]>([]);
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
});
function handleTeamMemberChange(profileId: string, isChecked: boolean) {
if (isChecked) {
if (!selectedTeamMembers.includes(profileId)) {
selectedTeamMembers = [...selectedTeamMembers, profileId];
}
const profile = ($profiles.data?.teamProfiles ?? []).find((p) => String(p.id) === profileId);
if (profile) {
const displayName =
profile.fullName ||
`${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim() ||
'Unknown';
const newAttendee: Attendee = {
id: newId(),
email: profile.email || '',
displayName,
isFromTeam: true,
profileId
};
attendees = [...attendees, newAttendee];
}
} else {
selectedTeamMembers = selectedTeamMembers.filter((id) => id !== profileId);
attendees = attendees.filter((a) => a.profileId !== profileId);
}
}
function addManualAttendee() {
attendees = [
...attendees,
{
id: newId(),
email: '',
displayName: '',
isFromTeam: false
}
];
}
function removeAttendee(index: number) {
const attendee = attendees[index];
if (attendee.isFromTeam && attendee.profileId) {
selectedTeamMembers = selectedTeamMembers.filter((id) => id !== attendee.profileId);
}
attendees = attendees.filter((_, i) => i !== index);
}
// Reminders
type Reminder = { id: string; method: 'email' | 'popup'; minutes: number };
let useReminders: boolean = $state(false);
let reminders: Reminder[] = $state([{ id: newId(), method: 'email', minutes: 24 * 60 }]);
// Preview the RFC values (with shift for "startNextDay")
function shiftIsoByDays(iso: string, days: number): string {
// Avoid mutating Date instances; compute new timestamp and format
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return iso;
const shifted = ms + days * 24 * 60 * 60 * 1000;
return new Date(shifted).toISOString();
}
function asRfcPreview(
d: string,
startT: string,
endT: string
): { startStr: string; endStr: string } | null {
try {
const { start, end } = buildRFC3339RangeForLocalDate(d, startT, endT, TIME_ZONE, false);
const shiftIfNeeded = (iso: string) => {
if (!startNextDay) return iso;
return shiftIsoByDays(iso, 1);
};
const startIso = shiftIfNeeded(start.dateTime);
const endIso = shiftIfNeeded(end.dateTime);
return {
startStr: `${startIso} (${start.timeZone})`,
endStr: `${endIso} (${end.timeZone})`
};
} catch {
return null;
}
}
const rfcPreview: { startStr: string; endStr: string } | null = $derived(
date && eventStartTime && eventEndTime ? asRfcPreview(date, eventStartTime, eventEndTime) : null
);
// Validation
function validate(): string | null {
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!eventSummary.trim()) return 'Summary is required.';
if (!date) return 'Date is required.';
if (!eventStartTime) return 'Start time is required.';
if (!eventEndTime) return 'End time is required.';
const hhmm = /^\d{2}:\d{2}$/;
if (!hhmm.test(eventStartTime) || !hhmm.test(eventEndTime)) {
return 'Times must be in 24h HH:mm format (e.g., 22:00 for 10pm).';
}
for (const a of attendees) {
if (!a.email) continue; // allow blank until the user finishes typing
if (!emailRe.test(a.email)) return `Invalid attendee email: ${a.email}`;
}
if (useReminders) {
for (const r of reminders) {
if (r.minutes == null || r.minutes < 0) return 'Reminder minutes must be >= 0';
if (r.method !== 'email' && r.method !== 'popup')
return 'Reminder method must be email or popup';
}
}
return null;
}
// Submit
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
message = '';
const err = validate();
if (err) {
error = err;
return;
}
submitting = true;
try {
const { start, end } = buildRFC3339RangeForLocalDate(
date,
eventStartTime,
eventEndTime,
TIME_ZONE,
false
);
const shiftIfNeeded = (iso: string) => {
if (!startNextDay) return iso;
return shiftIsoByDays(iso, 1);
};
const startShifted = { ...start, dateTime: shiftIfNeeded(start.dateTime) };
const endShifted = { ...end, dateTime: shiftIfNeeded(end.dateTime) };
const payload: CreateEventPayload & {
reminders?: {
useDefault?: boolean;
overrides?: { method: 'email' | 'popup'; minutes: number }[];
};
} = {
summary: eventSummary.trim(),
description: eventDescription?.trim() || undefined,
location: eventLocation?.trim() || undefined,
start: startShifted,
end: endShifted,
attendees: attendees
.filter((a) => a.email && a.email.trim())
.map((a) => ({
email: a.email.trim(),
displayName: a.displayName?.trim() || undefined
})),
...(useReminders
? {
reminders: {
useDefault: false,
overrides: reminders.map((r) => ({ method: r.method, minutes: r.minutes }))
}
}
: {})
};
if (eventId && eventId.trim()) {
let exists = false;
try {
const existing = await calendarService.getEvent(eventId.trim());
exists = !!existing?.id;
} catch {
exists = false;
}
if (exists) {
const updatePayload: UpdateEventPayload = { ...payload };
await calendarService.updateEvent(eventId.trim(), updatePayload);
message = 'Event updated successfully.';
} else {
await calendarService.createEvent({ ...payload, id: eventId.trim() });
message = 'Event created successfully.';
}
} else {
await calendarService.createEvent(payload);
message = 'Event created successfully.';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save event';
} finally {
submitting = false;
await sleep(1500);
goBack('/projects');
}
}
const handleCancel = (event: Event) => {
event.preventDefault();
goBack('/projects');
};
</script>
<form class="flex flex-col gap-4" onsubmit={onSubmit}>
<!-- Project details -->
<div class="space-y-2">
<div><span class="font-medium">Customer:</span> {customerName || '-'}</div>
<div><span class="font-medium">Account:</span> {accountName || '-'}</div>
<div><span class="font-medium">Address:</span> {address || '-'}</div>
<div><span class="font-medium">Name:</span> {name || '-'}</div>
<div><span class="font-medium">Date (business date):</span> {date || '-'}</div>
{#if notes}
<div>
<div class="font-medium">Notes:</div>
<div class="whitespace-pre-wrap">{notes}</div>
</div>
{/if}
</div>
<hr class="my-2" />
<div class="mb-6 grid gap-6 md:grid-cols-2">
<!-- Event ID -->
<Label for="eventId" class="mb-2">Event ID (optional)</Label>
<Input id="eventId" placeholder="Provide to update/avoid duplicates" bind:value={eventId} />
<!-- Timezone (locked) -->
<div class="text-sm text-gray-700 md:col-span-2">
Time Zone: <span class="font-medium">America/Detroit</span> (locked)
</div>
<!-- Summary -->
<Label for="summary" class="mb-2 md:col-span-2">Summary</Label>
<Input id="summary" class="md:col-span-2" bind:value={eventSummary} required />
<!-- Description -->
<Label for="description" class="mb-2 md:col-span-2">Description</Label>
<Textarea id="description" rows={3} class="md:col-span-2" bind:value={eventDescription} />
<!-- Location -->
<Label for="location" class="mb-2 md:col-span-2">Location</Label>
<Input id="location" class="md:col-span-2" bind:value={eventLocation} />
<!-- Times -->
<Label for="start" class="mb-2">Start Time</Label>
<Input id="start" type="time" step="60" bind:value={eventStartTime} required />
<Label for="end" class="mb-2">End Time</Label>
<Input id="end" type="time" step="60" bind:value={eventEndTime} required />
<!-- Special overnight handling -->
<div class="flex items-center gap-2 md:col-span-2">
<input id="startNextDay" type="checkbox" bind:checked={startNextDay} class="h-4 w-4" />
<Label for="startNextDay">Start after midnight (next day relative to business date)</Label>
</div>
<!-- RFC preview -->
{#if rfcPreview}
<div class="space-y-1 text-sm text-gray-600 md:col-span-2 dark:text-gray-300">
<div class="font-medium">Will be saved as:</div>
<div>Start: {rfcPreview.startStr}</div>
<div>End: {rfcPreview.endStr}</div>
<div class="italic">
If end time is earlier than start time, the end will be set to the next day.
</div>
</div>
{/if}
<!-- Team Members -->
<div class="md:col-span-2">
<Label class="mb-2">Select Team Members</Label>
{#if $profiles.fetching}
<div class="text-sm text-gray-500">Loading team profiles...</div>
{:else if ($profiles.data?.teamProfiles?.length ?? 0) === 0}
<div class="text-sm text-gray-500">No team profiles found.</div>
{:else}
<div class="grid gap-2 md:grid-cols-2">
{#each ($profiles.data?.teamProfiles ?? []).filter((p) => String(p.status) === 'ACTIVE') as profile (profile.id)}
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={selectedTeamMembers.includes(String(profile.id))}
onchange={(e) =>
handleTeamMemberChange(String(profile.id), e.currentTarget.checked)}
disabled={submitting}
/>
<span
>{profile.fullName ||
`${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()}</span
>
</label>
{/each}
</div>
{/if}
</div>
<!-- Attendees -->
<div class="space-y-4 md:col-span-2">
<div class="font-medium">Attendees</div>
{#if attendees.length === 0}
<div class="text-sm text-gray-500 italic dark:text-gray-400">No attendees added</div>
{:else}
{#each attendees as attendee, i (attendee.id)}
<div
class="grid items-end gap-2 rounded border border-gray-200 bg-gray-50 p-3 md:grid-cols-3 dark:border-gray-700 dark:bg-gray-800 {attendee.isFromTeam
? 'border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30'
: ''}"
>
<div>
<Label>Email</Label>
{#if attendee.isFromTeam}
<div
class="rounded bg-gray-100 p-2 text-sm text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{attendee.email || 'Loading...'}
</div>
{:else}
<Input bind:value={attendees[i].email} placeholder="name@example.com" />
{/if}
</div>
<div>
<Label>Display Name {attendee.isFromTeam ? '' : '(optional)'}</Label>
{#if attendee.isFromTeam}
<div
class="rounded bg-gray-100 p-2 text-sm text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{attendee.displayName}
</div>
{:else}
<Input bind:value={attendees[i].displayName} placeholder="Jane Doe" />
{/if}
</div>
<div class="flex items-center gap-2">
{#if attendee.isFromTeam}
<span class="text-xs font-medium text-blue-700 dark:text-blue-400">Team Member</span
>
{:else}
<Button type="button" color="light" onclick={() => removeAttendee(i)}>
Remove
</Button>
{/if}
</div>
</div>
{/each}
{/if}
<Button type="button" color="light" onclick={addManualAttendee}>Add Manual Attendee</Button>
</div>
<!-- Reminders -->
<div
class="space-y-3 rounded-lg border border-gray-300 bg-gray-100 p-4 md:col-span-2 dark:border-gray-700 dark:bg-gray-800"
>
<div class="font-medium text-gray-900 dark:text-gray-100">Reminders</div>
<div class="flex items-center gap-2">
<input id="useReminders" type="checkbox" bind:checked={useReminders} class="h-4 w-4" />
<Label for="useReminders">Custom reminders</Label>
</div>
{#if useReminders}
{#each reminders as r, i (r.id)}
<div class="grid items-end gap-3 md:grid-cols-3">
<div>
<Label>Method</Label>
<Select class="w-full" bind:value={reminders[i].method}>
<option value="email">Email</option>
<option value="popup">Popup</option>
</Select>
</div>
<div>
<Label>Minutes before</Label>
<Input type="number" min="0" class="w-full" bind:value={reminders[i].minutes} />
</div>
<div class="flex gap-2">
<Button type="button" color="light" onclick={() => reminders.splice(i, 1)}
>Remove</Button
>
</div>
</div>
{/each}
<Button
type="button"
color="light"
onclick={() => reminders.push({ id: newId(), method: 'popup', minutes: 30 })}
>
Add reminder
</Button>
{/if}
</div>
<!-- Messages -->
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if message}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
{message}
</div>
{/if}
<!-- Actions -->
<Button type="submit" color="primary" class="mb-2" disabled={submitting}>
{submitting ? 'Saving...' : 'Save to Calendar'}
</Button>
<Button type="button" color="red" class="mb-2" onclick={handleCancel}>Cancel</Button>
</div>
</form>

View File

@ -0,0 +1,155 @@
<script lang="ts">
import { UpdateCustomerStore } from '$houdini';
import { Alert, Button, Input, Label, Select } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
customer: {
id: string;
name: string;
billingEmail: string;
billingTerms: string | null;
startDate: string | null;
endDate: string | null;
status: string;
};
onSuccess?: () => void;
onCancel?: () => void;
}
let { customer, onSuccess, onCancel }: Props = $props();
let update = $state(new UpdateCustomerStore());
let error = $state('');
let submitting = $state(false);
let statusOptions = [
{ value: 'ACTIVE', name: 'Active' },
{ value: 'PENDING', name: 'Pending' },
{ value: 'INACTIVE', name: 'Inactive' }
];
function toDateInput(dateStr: string | null): string {
if (!dateStr) return '';
try {
return new Date(dateStr).toISOString().split('T')[0];
} catch {
return '';
}
}
let name = $state(customer.name ?? '');
let billingEmail = $state(customer.billingEmail ?? '');
let billingTerms = $state(customer.billingTerms ?? '');
let startDate = $state(toDateInput(customer.startDate));
let endDate = $state(toDateInput(customer.endDate));
let status = $state(customer.status ?? 'ACTIVE');
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!name || !billingEmail) {
error = 'Please provide name and billing email';
return;
}
submitting = true;
error = '';
try {
const res = await update.mutate({
input: {
id: customer.id,
name,
billingEmail,
billingTerms,
startDate: startDate || null,
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 update customer';
} finally {
submitting = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="name" class="mb-2">Customer Name</Label>
<Input
id="name"
type="text"
bind:value={name}
placeholder="Enter customer name"
disabled={submitting}
/>
</div>
<div>
<Label for="billingEmail" class="mb-2">Billing Email</Label>
<Input
id="billingEmail"
type="email"
bind:value={billingEmail}
placeholder="Enter billing email"
disabled={submitting}
/>
</div>
<div>
<Label for="billingTerms" class="mb-2">Billing Terms</Label>
<Input
id="billingTerms"
type="text"
bind:value={billingTerms}
placeholder="Enter billing terms"
disabled={submitting}
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} disabled={submitting} />
</div>
<div>
<Label for="endDate" class="mb-2">End Date</Label>
<Input id="endDate" type="date" bind:value={endDate} disabled={submitting} />
</div>
</div>
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" bind:value={status} disabled={submitting}>
{#each statusOptions as option (option.value)}
<option value={option.value}>{option.name}</option>
{/each}
</Select>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={submitting}>
{submitting ? 'Saving…' : 'Save'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={submitting}
>Cancel</Button
>
</div>
</form>
</div>

View File

@ -0,0 +1,111 @@
<script lang="ts">
import { CreateCustomerStore } from '$houdini';
import { Alert, Button, Input, Label } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
onSuccess?: () => void;
onCancel?: () => void;
}
let { onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let name = $state('');
let billingEmail = $state('');
let billingTerms = $state('');
let startDate = $state('');
let create = $state(new CreateCustomerStore());
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!name || !billingEmail || !startDate) {
error = 'Please provide name, billing email, and start date';
return;
}
const res = await create.mutate({
input: {
name,
billingEmail,
billingTerms,
startDate,
status: 'ACTIVE'
}
});
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 create customer';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="name" class="mb-2">Customer Name</Label>
<Input
id="name"
type="text"
bind:value={name}
disabled={creating}
placeholder="Enter customer name"
/>
</div>
<div>
<Label for="billingEmail" class="mb-2">Billing Email</Label>
<Input
id="billingEmail"
type="email"
bind:value={billingEmail}
disabled={creating}
placeholder="Enter billing email"
/>
</div>
<div>
<Label for="billingTerms" class="mb-2">Billing Terms</Label>
<Input
id="billingTerms"
type="text"
bind:value={billingTerms}
disabled={creating}
placeholder="Enter billing terms (optional)"
/>
</div>
<div>
<Label for="startDate" class="mb-2">Start Date</Label>
<Input id="startDate" type="date" bind:value={startDate} disabled={creating} />
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create Customer'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,528 @@
<script lang="ts">
import {
UpdateNotificationRuleStore,
GetTeamProfilesStore,
GetCustomerProfilesStore,
type UpdateNotificationRule$input
} from '$houdini';
import {
Alert,
Button,
Input,
Label,
Textarea,
Checkbox,
Toggle,
Badge,
Helper,
Search
} from 'flowbite-svelte';
import { ChevronDownOutline, ChevronUpOutline } from 'flowbite-svelte-icons';
import { offCanvas } from '$lib/stores/offCanvas';
import { fromGlobalId } from '$lib/utils/relay';
import { SvelteSet } from 'svelte/reactivity';
import {
EVENT_TYPE_CATEGORIES,
formatEventType,
type EventTypeCategory
} from '$lib/constants/eventTypes';
interface Props {
rule: {
id: string;
name: string;
description: string | null;
eventTypes: string[];
channels: string[];
targetRoles: string[];
targetTeamProfileIds?: string[];
targetCustomerProfileIds?: string[];
templateSubject: string;
templateBody: string;
isActive: boolean;
};
onSuccess?: () => void;
onCancel?: () => void;
}
let { rule, onSuccess, onCancel }: Props = $props();
let updating = $state(false);
let error = $state('');
// Form fields initialized with existing values
let name = $state(rule.name);
let description = $state(rule.description || '');
let templateSubject = $state(rule.templateSubject);
let templateBody = $state(rule.templateBody);
let isActive = $state(rule.isActive);
// Multi-select fields
let selectedEventTypes = $state<string[]>([...rule.eventTypes]);
let selectedChannels = $state<string[]>([...rule.channels]);
let selectedRoles = $state<string[]>([...rule.targetRoles]);
let selectedTeamProfileIds = $state<string[]>([...(rule.targetTeamProfileIds || [])]);
let selectedCustomerProfileIds = $state<string[]>([...(rule.targetCustomerProfileIds || [])]);
// Search state
let eventSearchQuery = $state('');
let teamSearchQuery = $state('');
let customerSearchQuery = $state('');
// Collapsed state for categories
let collapsedCategories = new SvelteSet<string>();
const updateStore = $derived(new UpdateNotificationRuleStore());
const teamProfilesStore = new GetTeamProfilesStore();
const customerProfilesStore = new GetCustomerProfilesStore();
// Fetch profiles on mount
$effect(() => {
teamProfilesStore.fetch().catch(() => {});
customerProfilesStore.fetch().catch(() => {});
});
const channels = ['IN_APP', 'EMAIL', 'SMS'];
const roles = ['ADMIN', 'TEAM_LEADER', 'TEAM_MEMBER'];
// Filter categories based on search
const filteredCategories = $derived(() => {
if (!eventSearchQuery.trim()) return EVENT_TYPE_CATEGORIES;
const query = eventSearchQuery.toLowerCase();
return EVENT_TYPE_CATEGORIES.map((category) => ({
...category,
events: category.events.filter((event) =>
formatEventType(event).toLowerCase().includes(query)
)
})).filter((category) => category.events.length > 0);
});
// Filter team profiles based on search
const filteredTeamProfiles = $derived(() => {
const profiles = $teamProfilesStore.data?.teamProfiles || [];
if (!teamSearchQuery.trim()) return profiles;
const query = teamSearchQuery.toLowerCase();
return profiles.filter((p) => p.fullName.toLowerCase().includes(query));
});
// Filter customer profiles based on search
const filteredCustomerProfiles = $derived(() => {
const profiles = $customerProfilesStore.data?.customerProfiles || [];
if (!customerSearchQuery.trim()) return profiles;
const query = customerSearchQuery.toLowerCase();
return profiles.filter((p) => p.fullName.toLowerCase().includes(query));
});
function toggleCategory(categoryLabel: string) {
if (collapsedCategories.has(categoryLabel)) {
collapsedCategories.delete(categoryLabel);
} else {
collapsedCategories.add(categoryLabel);
}
}
function toggleEventType(eventType: string) {
if (selectedEventTypes.includes(eventType)) {
selectedEventTypes = selectedEventTypes.filter((t) => t !== eventType);
} else {
selectedEventTypes = [...selectedEventTypes, eventType];
}
}
function selectAllInCategory(category: EventTypeCategory) {
const newEvents = category.events.filter((e) => !selectedEventTypes.includes(e));
selectedEventTypes = [...selectedEventTypes, ...newEvents];
}
function deselectAllInCategory(category: EventTypeCategory) {
selectedEventTypes = selectedEventTypes.filter((e) => !category.events.includes(e));
}
function selectAllEvents() {
selectedEventTypes = filteredCategories().flatMap((c) => c.events);
}
function clearAllEvents() {
selectedEventTypes = [];
}
function toggleChannel(channel: string) {
if (selectedChannels.includes(channel)) {
selectedChannels = selectedChannels.filter((c) => c !== channel);
} else {
selectedChannels = [...selectedChannels, channel];
}
}
function toggleRole(role: string) {
if (selectedRoles.includes(role)) {
selectedRoles = selectedRoles.filter((r) => r !== role);
} else {
selectedRoles = [...selectedRoles, role];
}
}
function toggleTeamProfile(profileId: string) {
if (selectedTeamProfileIds.includes(profileId)) {
selectedTeamProfileIds = selectedTeamProfileIds.filter((id) => id !== profileId);
} else {
selectedTeamProfileIds = [...selectedTeamProfileIds, profileId];
}
}
function toggleCustomerProfile(profileId: string) {
if (selectedCustomerProfileIds.includes(profileId)) {
selectedCustomerProfileIds = selectedCustomerProfileIds.filter((id) => id !== profileId);
} else {
selectedCustomerProfileIds = [...selectedCustomerProfileIds, profileId];
}
}
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
updating = true;
error = '';
try {
if (!name || !templateSubject || !templateBody) {
error = 'Please provide name, subject, and body';
return;
}
if (selectedEventTypes.length === 0) {
error = 'Please select at least one event type';
return;
}
if (selectedChannels.length === 0) {
error = 'Please select at least one channel';
return;
}
if (
selectedRoles.length === 0 &&
selectedTeamProfileIds.length === 0 &&
selectedCustomerProfileIds.length === 0
) {
error = 'Please select at least one target (role, team member, or customer)';
return;
}
// Decode profile IDs from global IDs to UUIDs
const teamProfileUuids = selectedTeamProfileIds.length > 0
? selectedTeamProfileIds.map((id) => fromGlobalId(id))
: null;
const customerProfileUuids = selectedCustomerProfileIds.length > 0
? selectedCustomerProfileIds.map((id) => fromGlobalId(id))
: null;
const res = await updateStore.mutate({
input: {
id: rule.id,
name,
description: description || null,
eventTypes: selectedEventTypes as UpdateNotificationRule$input['input']['eventTypes'],
channels: selectedChannels as UpdateNotificationRule$input['input']['channels'],
targetRoles: selectedRoles.length > 0 ? (selectedRoles as UpdateNotificationRule$input['input']['targetRoles']) : null,
targetTeamProfileIds: teamProfileUuids,
targetCustomerProfileIds: customerProfileUuids,
templateSubject,
templateBody,
isActive,
conditions: 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 update notification rule';
} finally {
updating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit} novalidate>
<!-- Name -->
<div>
<Label for="name" class="mb-2">Rule Name *</Label>
<Input id="name" type="text" bind:value={name} disabled={updating} required />
</div>
<!-- Description -->
<div>
<Label for="description" class="mb-2">Description</Label>
<Textarea id="description" bind:value={description} rows={2} disabled={updating} />
</div>
<!-- Event Types -->
<div>
<div class="mb-2 flex items-center justify-between">
<Label>Event Types *</Label>
<Badge color="gray">{selectedEventTypes.length} selected</Badge>
</div>
<Search
bind:value={eventSearchQuery}
placeholder="Search event types..."
class="mb-3"
size="sm"
/>
<div class="mb-2 flex gap-2">
<Button size="xs" color="light" onclick={selectAllEvents} disabled={updating}>
Select All
</Button>
<Button size="xs" color="light" onclick={clearAllEvents} disabled={updating}>
Clear All
</Button>
</div>
<div class="max-h-96 space-y-2 overflow-y-auto rounded-lg border p-3 dark:border-gray-600">
{#each filteredCategories() as category (category.label)}
<div class="border-b pb-2 last:border-b-0 dark:border-gray-700">
<button
type="button"
onclick={() => toggleCategory(category.label)}
class="flex w-full items-center justify-between py-1 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span class="flex items-center gap-2">
{#if collapsedCategories.has(category.label)}
<ChevronUpOutline size="xs" />
{:else}
<ChevronDownOutline size="xs" />
{/if}
<span class="font-semibold text-sm">{category.label}</span>
<Badge color="blue" class="text-xs">
{category.events.filter((e) => selectedEventTypes.includes(e)).length}/{category
.events.length}
</Badge>
</span>
<span class="flex gap-1">
<Button
size="xs"
color="light"
onclick={(e: Event) => {
e.stopPropagation();
selectAllInCategory(category);
}}
disabled={updating}
>
All
</Button>
<Button
size="xs"
color="light"
onclick={(e: Event) => {
e.stopPropagation();
deselectAllInCategory(category);
}}
disabled={updating}
>
None
</Button>
</span>
</button>
{#if !collapsedCategories.has(category.label)}
<div class="ml-6 mt-2 space-y-1">
{#each category.events as eventType (eventType)}
<Checkbox
checked={selectedEventTypes.includes(eventType)}
onchange={() => toggleEventType(eventType)}
disabled={updating}
class="text-sm"
>
{formatEventType(eventType)}
</Checkbox>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- Channels -->
<div>
<Label class="mb-2">Notification Channels *</Label>
<div class="space-y-2">
{#each channels as channel (channel)}
<Checkbox
checked={selectedChannels.includes(channel)}
onchange={() => toggleChannel(channel)}
disabled={updating}
>
{channel}
</Checkbox>
{/each}
</div>
</div>
<!-- Target Recipients -->
<div class="rounded-lg border p-4 dark:border-gray-600">
<Label class="mb-3">Target Recipients *</Label>
<Helper class="mb-4">
Select who should receive notifications. You can target by role, specific team members, or
customers. At least one must be selected.
</Helper>
<!-- Target Roles -->
<div class="mb-4">
<Label class="mb-2 text-sm">By Role</Label>
<div class="space-y-2">
{#each roles as role (role)}
<Checkbox
checked={selectedRoles.includes(role)}
onchange={() => toggleRole(role)}
disabled={updating}
class="text-sm"
>
{role.replace(/_/g, ' ')}
</Checkbox>
{/each}
</div>
</div>
<!-- Specific Team Members -->
<div class="mb-4">
<div class="mb-2 flex items-center justify-between">
<Label class="text-sm">Specific Team Members</Label>
<Badge color="gray" class="text-xs">{selectedTeamProfileIds.length} selected</Badge>
</div>
<Search
bind:value={teamSearchQuery}
placeholder="Search team members..."
class="mb-2"
size="sm"
/>
<div
class="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3 dark:border-gray-600"
>
{#if filteredTeamProfiles().length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">
{teamSearchQuery ? 'No team members found' : 'Loading team members...'}
</p>
{:else}
{#each filteredTeamProfiles() as profile (profile.id)}
<Checkbox
checked={selectedTeamProfileIds.includes(profile.id)}
onchange={() => toggleTeamProfile(profile.id)}
disabled={updating}
class="text-sm"
>
<span class="flex items-center gap-2">
{profile.fullName}
<Badge color="blue" class="text-xs">{profile.role}</Badge>
</span>
</Checkbox>
{/each}
{/if}
</div>
</div>
<!-- Specific Customers -->
<div>
<div class="mb-2 flex items-center justify-between">
<Label class="text-sm">Specific Customers</Label>
<Badge color="gray" class="text-xs">{selectedCustomerProfileIds.length} selected</Badge>
</div>
<Search
bind:value={customerSearchQuery}
placeholder="Search customers..."
class="mb-2"
size="sm"
/>
<div
class="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3 dark:border-gray-600"
>
{#if filteredCustomerProfiles().length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">
{customerSearchQuery ? 'No customers found' : 'Loading customers...'}
</p>
{:else}
{#each filteredCustomerProfiles() as profile (profile.id)}
<Checkbox
checked={selectedCustomerProfileIds.includes(profile.id)}
onchange={() => toggleCustomerProfile(profile.id)}
disabled={updating}
class="text-sm"
>
{profile.fullName}
</Checkbox>
{/each}
{/if}
</div>
</div>
</div>
<!-- Template Subject -->
<div>
<Label for="subject" class="mb-2">Notification Subject *</Label>
<Input
id="subject"
type="text"
bind:value={templateSubject}
disabled={updating}
required
placeholder="e.g., Project Completed"
/>
<Helper class="mt-1">
Use Python format strings with curly braces. Example: "Status changed from
&#123;old_status&#125; to &#123;new_status&#125;". Available variables: event_type,
entity_type, entity_id, plus metadata fields like status, old_status, new_status, date,
etc.
</Helper>
</div>
<!-- Template Body -->
<div>
<Label for="body" class="mb-2">Notification Body *</Label>
<Textarea
id="body"
bind:value={templateBody}
rows={4}
disabled={updating}
required
placeholder="e.g., The project has been updated."
/>
<Helper class="mt-1">
Use Python format strings. Example: "The &#123;entity_type&#125; with ID
&#123;entity_id&#125; was updated." Available: event_type, entity_type, entity_id, plus
metadata like status, old_status, new_status, etc.
</Helper>
</div>
<!-- Is Active -->
<div>
<Toggle bind:checked={isActive} disabled={updating}>Active</Toggle>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 border-t pt-4 dark:border-gray-700">
<Button color="alternative" onclick={handleCancel} disabled={updating}>Cancel</Button>
<Button type="submit" color="primary" disabled={updating}>
{updating ? 'Updating...' : 'Update Rule'}
</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,514 @@
<script lang="ts">
import {
CreateNotificationRuleStore,
GetTeamProfilesStore,
GetCustomerProfilesStore,
type CreateNotificationRule$input
} from '$houdini';
import {
Alert,
Button,
Input,
Label,
Textarea,
Checkbox,
Toggle,
Badge,
Helper,
Search
} from 'flowbite-svelte';
import { ChevronDownOutline, ChevronUpOutline } from 'flowbite-svelte-icons';
import { offCanvas } from '$lib/stores/offCanvas';
import { fromGlobalId } from '$lib/utils/relay';
import { SvelteSet } from 'svelte/reactivity';
import {
EVENT_TYPE_CATEGORIES,
formatEventType,
type EventTypeCategory
} from '$lib/constants/eventTypes';
interface Props {
onSuccess?: () => void;
onCancel?: () => void;
}
let { onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
// Form fields
let name = $state('');
let description = $state('');
let templateSubject = $state('');
let templateBody = $state('');
let isActive = $state(true);
// Multi-select fields
let selectedEventTypes = $state<string[]>([]);
let selectedChannels = $state<string[]>(['IN_APP']);
let selectedRoles = $state<string[]>([]);
let selectedTeamProfileIds = $state<string[]>([]);
let selectedCustomerProfileIds = $state<string[]>([]);
// Search state
let eventSearchQuery = $state('');
let teamSearchQuery = $state('');
let customerSearchQuery = $state('');
// Collapsed state for categories
let collapsedCategories = new SvelteSet<string>();
const createStore = $derived(new CreateNotificationRuleStore());
const teamProfilesStore = new GetTeamProfilesStore();
const customerProfilesStore = new GetCustomerProfilesStore();
// Fetch profiles on mount
$effect(() => {
teamProfilesStore.fetch().catch(() => {});
customerProfilesStore.fetch().catch(() => {});
});
const channels = ['IN_APP', 'EMAIL', 'SMS'];
const roles = ['ADMIN', 'TEAM_LEADER', 'TEAM_MEMBER'];
// Filter categories based on search
const filteredCategories = $derived(() => {
if (!eventSearchQuery.trim()) return EVENT_TYPE_CATEGORIES;
const query = eventSearchQuery.toLowerCase();
return EVENT_TYPE_CATEGORIES.map((category) => ({
...category,
events: category.events.filter((event) =>
formatEventType(event).toLowerCase().includes(query)
)
})).filter((category) => category.events.length > 0);
});
// Filter team profiles based on search
const filteredTeamProfiles = $derived(() => {
const profiles = $teamProfilesStore.data?.teamProfiles || [];
if (!teamSearchQuery.trim()) return profiles;
const query = teamSearchQuery.toLowerCase();
return profiles.filter((p) => p.fullName.toLowerCase().includes(query));
});
// Filter customer profiles based on search
const filteredCustomerProfiles = $derived(() => {
const profiles = $customerProfilesStore.data?.customerProfiles || [];
if (!customerSearchQuery.trim()) return profiles;
const query = customerSearchQuery.toLowerCase();
return profiles.filter((p) => p.fullName.toLowerCase().includes(query));
});
function toggleCategory(categoryLabel: string) {
if (collapsedCategories.has(categoryLabel)) {
collapsedCategories.delete(categoryLabel);
} else {
collapsedCategories.add(categoryLabel);
}
}
function toggleEventType(eventType: string) {
if (selectedEventTypes.includes(eventType)) {
selectedEventTypes = selectedEventTypes.filter((t) => t !== eventType);
} else {
selectedEventTypes = [...selectedEventTypes, eventType];
}
}
function selectAllInCategory(category: EventTypeCategory) {
const newEvents = category.events.filter((e) => !selectedEventTypes.includes(e));
selectedEventTypes = [...selectedEventTypes, ...newEvents];
}
function deselectAllInCategory(category: EventTypeCategory) {
selectedEventTypes = selectedEventTypes.filter((e) => !category.events.includes(e));
}
function selectAllEvents() {
selectedEventTypes = filteredCategories().flatMap((c) => c.events);
}
function clearAllEvents() {
selectedEventTypes = [];
}
function toggleChannel(channel: string) {
if (selectedChannels.includes(channel)) {
selectedChannels = selectedChannels.filter((c) => c !== channel);
} else {
selectedChannels = [...selectedChannels, channel];
}
}
function toggleRole(role: string) {
if (selectedRoles.includes(role)) {
selectedRoles = selectedRoles.filter((r) => r !== role);
} else {
selectedRoles = [...selectedRoles, role];
}
}
function toggleTeamProfile(profileId: string) {
if (selectedTeamProfileIds.includes(profileId)) {
selectedTeamProfileIds = selectedTeamProfileIds.filter((id) => id !== profileId);
} else {
selectedTeamProfileIds = [...selectedTeamProfileIds, profileId];
}
}
function toggleCustomerProfile(profileId: string) {
if (selectedCustomerProfileIds.includes(profileId)) {
selectedCustomerProfileIds = selectedCustomerProfileIds.filter((id) => id !== profileId);
} else {
selectedCustomerProfileIds = [...selectedCustomerProfileIds, profileId];
}
}
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!name || !templateSubject || !templateBody) {
error = 'Please provide name, subject, and body';
return;
}
if (selectedEventTypes.length === 0) {
error = 'Please select at least one event type';
return;
}
if (selectedChannels.length === 0) {
error = 'Please select at least one channel';
return;
}
if (
selectedRoles.length === 0 &&
selectedTeamProfileIds.length === 0 &&
selectedCustomerProfileIds.length === 0
) {
error = 'Please select at least one target (role, team member, or customer)';
return;
}
// Decode profile IDs from global IDs to UUIDs
const teamProfileUuids = selectedTeamProfileIds.length > 0
? selectedTeamProfileIds.map((id) => fromGlobalId(id))
: null;
const customerProfileUuids = selectedCustomerProfileIds.length > 0
? selectedCustomerProfileIds.map((id) => fromGlobalId(id))
: null;
const res = await createStore.mutate({
input: {
name,
description: description || null,
eventTypes: selectedEventTypes as CreateNotificationRule$input['input']['eventTypes'],
channels: selectedChannels as CreateNotificationRule$input['input']['channels'],
targetRoles: selectedRoles.length > 0 ? (selectedRoles as CreateNotificationRule$input['input']['targetRoles']) : null,
targetTeamProfileIds: teamProfileUuids,
targetCustomerProfileIds: customerProfileUuids,
templateSubject,
templateBody,
isActive,
conditions: 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 create notification rule';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit} novalidate>
<!-- Name -->
<div>
<Label for="name" class="mb-2">Rule Name *</Label>
<Input id="name" type="text" bind:value={name} disabled={creating} required />
</div>
<!-- Description -->
<div>
<Label for="description" class="mb-2">Description</Label>
<Textarea id="description" bind:value={description} rows={2} disabled={creating} />
</div>
<!-- Event Types -->
<div>
<div class="mb-2 flex items-center justify-between">
<Label>Event Types *</Label>
<Badge color="gray">{selectedEventTypes.length} selected</Badge>
</div>
<Search
bind:value={eventSearchQuery}
placeholder="Search event types..."
class="mb-3"
size="sm"
/>
<div class="mb-2 flex gap-2">
<Button size="xs" color="light" onclick={selectAllEvents} disabled={creating}>
Select All
</Button>
<Button size="xs" color="light" onclick={clearAllEvents} disabled={creating}>
Clear All
</Button>
</div>
<div class="max-h-96 space-y-2 overflow-y-auto rounded-lg border p-3 dark:border-gray-600">
{#each filteredCategories() as category (category.label)}
<div class="border-b pb-2 last:border-b-0 dark:border-gray-700">
<button
type="button"
onclick={() => toggleCategory(category.label)}
class="flex w-full items-center justify-between py-1 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span class="flex items-center gap-2">
{#if collapsedCategories.has(category.label)}
<ChevronUpOutline size="xs" />
{:else}
<ChevronDownOutline size="xs" />
{/if}
<span class="font-semibold text-sm">{category.label}</span>
<Badge color="blue" class="text-xs">
{category.events.filter((e) => selectedEventTypes.includes(e)).length}/{category
.events.length}
</Badge>
</span>
<span class="flex gap-1">
<Button
size="xs"
color="light"
onclick={(e: Event) => {
e.stopPropagation();
selectAllInCategory(category);
}}
disabled={creating}
>
All
</Button>
<Button
size="xs"
color="light"
onclick={(e: Event) => {
e.stopPropagation();
deselectAllInCategory(category);
}}
disabled={creating}
>
None
</Button>
</span>
</button>
{#if !collapsedCategories.has(category.label)}
<div class="ml-6 mt-2 space-y-1">
{#each category.events as eventType (eventType)}
<Checkbox
checked={selectedEventTypes.includes(eventType)}
onchange={() => toggleEventType(eventType)}
disabled={creating}
class="text-sm"
>
{formatEventType(eventType)}
</Checkbox>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- Channels -->
<div>
<Label class="mb-2">Notification Channels *</Label>
<div class="space-y-2">
{#each channels as channel (channel)}
<Checkbox
checked={selectedChannels.includes(channel)}
onchange={() => toggleChannel(channel)}
disabled={creating}
>
{channel}
</Checkbox>
{/each}
</div>
</div>
<!-- Target Recipients -->
<div class="rounded-lg border p-4 dark:border-gray-600">
<Label class="mb-3">Target Recipients *</Label>
<Helper class="mb-4">
Select who should receive notifications. You can target by role, specific team members, or
customers. At least one must be selected.
</Helper>
<!-- Target Roles -->
<div class="mb-4">
<Label class="mb-2 text-sm">By Role</Label>
<div class="space-y-2">
{#each roles as role (role)}
<Checkbox
checked={selectedRoles.includes(role)}
onchange={() => toggleRole(role)}
disabled={creating}
class="text-sm"
>
{role.replace(/_/g, ' ')}
</Checkbox>
{/each}
</div>
</div>
<!-- Specific Team Members -->
<div class="mb-4">
<div class="mb-2 flex items-center justify-between">
<Label class="text-sm">Specific Team Members</Label>
<Badge color="gray" class="text-xs">{selectedTeamProfileIds.length} selected</Badge>
</div>
<Search
bind:value={teamSearchQuery}
placeholder="Search team members..."
class="mb-2"
size="sm"
/>
<div
class="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3 dark:border-gray-600"
>
{#if filteredTeamProfiles().length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">
{teamSearchQuery ? 'No team members found' : 'Loading team members...'}
</p>
{:else}
{#each filteredTeamProfiles() as profile (profile.id)}
<Checkbox
checked={selectedTeamProfileIds.includes(profile.id)}
onchange={() => toggleTeamProfile(profile.id)}
disabled={creating}
class="text-sm"
>
<span class="flex items-center gap-2">
{profile.fullName}
<Badge color="blue" class="text-xs">{profile.role}</Badge>
</span>
</Checkbox>
{/each}
{/if}
</div>
</div>
<!-- Specific Customers -->
<div>
<div class="mb-2 flex items-center justify-between">
<Label class="text-sm">Specific Customers</Label>
<Badge color="gray" class="text-xs">{selectedCustomerProfileIds.length} selected</Badge>
</div>
<Search
bind:value={customerSearchQuery}
placeholder="Search customers..."
class="mb-2"
size="sm"
/>
<div
class="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3 dark:border-gray-600"
>
{#if filteredCustomerProfiles().length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">
{customerSearchQuery ? 'No customers found' : 'Loading customers...'}
</p>
{:else}
{#each filteredCustomerProfiles() as profile (profile.id)}
<Checkbox
checked={selectedCustomerProfileIds.includes(profile.id)}
onchange={() => toggleCustomerProfile(profile.id)}
disabled={creating}
class="text-sm"
>
{profile.fullName}
</Checkbox>
{/each}
{/if}
</div>
</div>
</div>
<!-- Template Subject -->
<div>
<Label for="subject" class="mb-2">Notification Subject *</Label>
<Input
id="subject"
type="text"
bind:value={templateSubject}
disabled={creating}
required
placeholder="e.g., Project Completed"
/>
<Helper class="mt-1">
Use Python format strings with curly braces. Example: "Status changed from
&#123;old_status&#125; to &#123;new_status&#125;". Available variables: event_type,
entity_type, entity_id, plus metadata fields like status, old_status, new_status, date,
etc.
</Helper>
</div>
<!-- Template Body -->
<div>
<Label for="body" class="mb-2">Notification Body *</Label>
<Textarea
id="body"
bind:value={templateBody}
rows={4}
disabled={creating}
required
placeholder="e.g., The project has been updated."
/>
<Helper class="mt-1">
Use Python format strings. Example: "The &#123;entity_type&#125; with ID
&#123;entity_id&#125; was updated." Available: event_type, entity_type, entity_id, plus
metadata like status, old_status, new_status, etc.
</Helper>
</div>
<!-- Is Active -->
<div>
<Toggle bind:checked={isActive} disabled={creating}>Active</Toggle>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 border-t pt-4 dark:border-gray-700">
<Button color="alternative" onclick={handleCancel} disabled={creating}>Cancel</Button>
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Rule'}
</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,170 @@
<script lang="ts">
import { UpdateTeamProfileStore, UpdateCustomerProfileStore } from '$houdini';
import { Label, Input, Button, Alert, Spinner } from 'flowbite-svelte';
import { goto } from '$app/navigation';
import { offCanvas } from '$lib/utils/offCanvas';
type AnyProfile = {
__typename?: string;
id: string;
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
};
let {
profile,
email,
isOffCanvas = false,
onSuccess,
onCancel
} = $props<{
profile: AnyProfile;
email?: string;
isOffCanvas?: boolean;
onSuccess?: () => void;
onCancel?: () => void;
}>();
let teamProfile = $derived(new UpdateTeamProfileStore());
let customerProfile = $derived(new UpdateCustomerProfileStore());
let firstName = $state(profile?.firstName ?? '');
let lastName = $state(profile?.lastName ?? '');
let phone = $state(profile?.phone ?? '');
let userEmail = $state(email ?? profile?.email ?? '');
let error = $state<string | null>(null);
let isSubmitting = $state(false);
function canSubmit() {
return Boolean(profile?.id) && Boolean(firstName?.trim()) && Boolean(lastName?.trim());
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
error = null;
isSubmitting = true;
try {
let result;
if (profile.__typename === 'CustomerProfileType') {
result = await customerProfile.mutate({
input: {
id: profile.id,
firstName: firstName.trim(),
lastName: lastName.trim(),
email: userEmail?.trim() || null,
phone: phone?.trim() || null
}
});
} else {
result = await teamProfile.mutate({
input: {
id: profile.id,
firstName: firstName.trim(),
lastName: lastName.trim(),
email: userEmail?.trim() || null,
phone: phone?.trim() || null
}
});
}
if (result?.errors?.length) {
error = result.errors.map((e: { message: string }) => e.message).join(', ');
return;
}
if (isOffCanvas) {
offCanvas.closeRight();
onSuccess?.();
} else {
await goto('/profile');
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update profile';
} finally {
isSubmitting = false;
}
}
function handleCancel() {
if (isOffCanvas) {
offCanvas.closeRight();
onCancel?.();
} else {
goto('/profile');
}
}
</script>
<form class={isOffCanvas ? '' : 'max-w-xl'} onsubmit={handleSubmit}>
<div
class={isOffCanvas
? 'space-y-6'
: 'rounded border border-gray-200 bg-white p-6 shadow-md dark:border-gray-700 dark:bg-gray-800'}
>
<div class={isOffCanvas ? 'space-y-6' : 'grid grid-cols-1 gap-4'}>
<div>
<Label for="firstName" class="mb-2" color="primary">First name</Label>
<Input
id="firstName"
type="text"
bind:value={firstName}
required
disabled={isSubmitting}
placeholder="First name"
/>
</div>
<div>
<Label for="lastName" class="mb-2" color="primary">Last name</Label>
<Input
id="lastName"
type="text"
bind:value={lastName}
required
disabled={isSubmitting}
placeholder="Last name"
/>
</div>
<div>
<Label for="phone" class="mb-2" color="primary">Phone</Label>
<Input
id="phone"
type="tel"
bind:value={phone}
disabled={isSubmitting}
placeholder="Phone number"
/>
</div>
<div>
<Label for="email" class="mb-2" color="primary">Email</Label>
<Input
id="email"
type="email"
bind:value={userEmail}
disabled={isSubmitting}
placeholder="Email address"
/>
</div>
</div>
{#if error}
<Alert color="red" class="mt-4">{error}</Alert>
{/if}
<div class="mt-6 flex items-center gap-2">
<Button type="submit" color="primary" disabled={!canSubmit() || isSubmitting}>
{#if isSubmitting}
<Spinner class="me-2 inline h-4 w-4" />
Saving...
{:else}
Save changes
{/if}
</Button>
<Button type="button" color="light" disabled={isSubmitting} onclick={handleCancel}
>Cancel</Button
>
</div>
</div>
</form>

View File

@ -0,0 +1,113 @@
<script lang="ts">
import { CreateCustomerProfileStore } from '$houdini';
import { Alert, Button, Input, Label, Select, Textarea } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
onSuccess?: () => void;
onCancel?: () => void;
}
let { onSuccess, onCancel }: Props = $props();
let create = $state(new CreateCustomerProfileStore());
let error = $state('');
let submitting = $state(false);
let statusOptions = [
{ value: 'ACTIVE', name: 'Active' },
{ value: 'PENDING', name: 'Pending' },
{ value: 'INACTIVE', name: 'Inactive' }
];
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let status = $state('ACTIVE');
let notes = $state('');
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!firstName || !lastName) {
error = 'Please provide first and last name';
return;
}
submitting = true;
error = '';
try {
const res = await create.mutate({
input: {
firstName,
lastName,
email: email || null,
phone: phone || null,
status,
notes: notes || 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 create customer profile';
} finally {
submitting = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="firstName" class="mb-2">First Name</Label>
<Input id="firstName" type="text" bind:value={firstName} disabled={submitting} />
</div>
<div>
<Label for="lastName" class="mb-2">Last Name</Label>
<Input id="lastName" type="text" bind:value={lastName} disabled={submitting} />
</div>
<div>
<Label for="email" class="mb-2">Email</Label>
<Input id="email" type="email" bind:value={email} disabled={submitting} />
</div>
<div>
<Label for="phone" class="mb-2">Phone</Label>
<Input id="phone" type="tel" bind:value={phone} disabled={submitting} />
</div>
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" items={statusOptions} bind:value={status} disabled={submitting} />
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Textarea id="notes" bind:value={notes} disabled={submitting} rows={4} />
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={submitting}>
Cancel
</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,123 @@
<script lang="ts">
import { UpdateCustomerProfileStore } from '$houdini';
import { Alert, Button, Input, Label, Select, Textarea } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
profile: {
id: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
status: string;
notes: string | null;
};
onSuccess?: () => void;
onCancel?: () => void;
}
let { profile, onSuccess, onCancel }: Props = $props();
let update = $state(new UpdateCustomerProfileStore());
let error = $state('');
let submitting = $state(false);
let statusOptions = [
{ value: 'ACTIVE', name: 'Active' },
{ value: 'PENDING', name: 'Pending' },
{ value: 'INACTIVE', name: 'Inactive' }
];
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 ?? '');
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!firstName || !lastName) {
error = 'Please provide first and last name';
return;
}
submitting = true;
error = '';
try {
const res = await update.mutate({
input: {
id: profile.id,
firstName,
lastName,
email: email || null,
phone: phone || null,
status,
notes: notes || 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 update customer profile';
} finally {
submitting = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="firstName" class="mb-2">First Name</Label>
<Input id="firstName" type="text" bind:value={firstName} disabled={submitting} />
</div>
<div>
<Label for="lastName" class="mb-2">Last Name</Label>
<Input id="lastName" type="text" bind:value={lastName} disabled={submitting} />
</div>
<div>
<Label for="email" class="mb-2">Email</Label>
<Input id="email" type="email" bind:value={email} disabled={submitting} />
</div>
<div>
<Label for="phone" class="mb-2">Phone</Label>
<Input id="phone" type="tel" bind:value={phone} disabled={submitting} />
</div>
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" items={statusOptions} bind:value={status} disabled={submitting} />
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Textarea id="notes" bind:value={notes} disabled={submitting} rows={4} />
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={submitting}>
{submitting ? 'Saving…' : 'Save'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={submitting}>
Cancel
</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,126 @@
<script lang="ts">
import { CreateTeamProfileStore } from '$houdini';
import { Alert, Button, Input, Label, Select, Textarea } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
onSuccess?: () => void;
onCancel?: () => void;
}
let { onSuccess, onCancel }: Props = $props();
let create = $state(new CreateTeamProfileStore());
let error = $state('');
let submitting = $state(false);
let statusOptions = [
{ value: 'ACTIVE', name: 'Active' },
{ value: 'PENDING', name: 'Pending' },
{ value: 'INACTIVE', name: 'Inactive' }
];
let roleOptions = [
{ value: 'ADMIN', name: 'Admin' },
{ value: 'TEAM_LEADER', name: 'Team Leader' },
{ value: 'TEAM_MEMBER', name: 'Team Member' }
];
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let status = $state('ACTIVE');
let role = $state('TEAM_MEMBER');
let notes = $state('');
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!firstName || !lastName) {
error = 'Please provide first and last name';
return;
}
submitting = true;
error = '';
try {
const res = await create.mutate({
input: {
firstName,
lastName,
email: email || null,
phone: phone || null,
status,
role,
notes: notes || 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 create team profile';
} finally {
submitting = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="firstName" class="mb-2">First Name</Label>
<Input id="firstName" type="text" bind:value={firstName} disabled={submitting} />
</div>
<div>
<Label for="lastName" class="mb-2">Last Name</Label>
<Input id="lastName" type="text" bind:value={lastName} disabled={submitting} />
</div>
<div>
<Label for="email" class="mb-2">Email</Label>
<Input id="email" type="email" bind:value={email} disabled={submitting} />
</div>
<div>
<Label for="phone" class="mb-2">Phone</Label>
<Input id="phone" type="tel" bind:value={phone} disabled={submitting} />
</div>
<div>
<Label for="role" class="mb-2">Role</Label>
<Select id="role" items={roleOptions} bind:value={role} disabled={submitting} />
</div>
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" items={statusOptions} bind:value={status} disabled={submitting} />
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Textarea id="notes" bind:value={notes} disabled={submitting} rows={4} />
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={submitting}>
Cancel
</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,137 @@
<script lang="ts">
import { UpdateTeamProfileStore } from '$houdini';
import { Alert, Button, Input, Label, Select, Textarea } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
profile: {
id: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
status: string;
role: string;
notes: string | null;
};
onSuccess?: () => void;
onCancel?: () => void;
}
let { profile, onSuccess, onCancel }: Props = $props();
let update = $state(new UpdateTeamProfileStore());
let error = $state('');
let submitting = $state(false);
let statusOptions = [
{ value: 'ACTIVE', name: 'Active' },
{ value: 'PENDING', name: 'Pending' },
{ value: 'INACTIVE', name: 'Inactive' }
];
let roleOptions = [
{ value: 'ADMIN', name: 'Admin' },
{ value: 'TEAM_LEADER', name: 'Team Leader' },
{ value: 'TEAM_MEMBER', name: 'Team Member' }
];
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 role = $state(profile.role ?? 'TEAM_MEMBER');
let notes = $state(profile.notes ?? '');
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!firstName || !lastName) {
error = 'Please provide first and last name';
return;
}
submitting = true;
error = '';
try {
const res = await update.mutate({
input: {
id: profile.id,
firstName,
lastName,
email: email || null,
phone: phone || null,
status,
role,
notes: notes || 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 update team profile';
} finally {
submitting = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="firstName" class="mb-2">First Name</Label>
<Input id="firstName" type="text" bind:value={firstName} disabled={submitting} />
</div>
<div>
<Label for="lastName" class="mb-2">Last Name</Label>
<Input id="lastName" type="text" bind:value={lastName} disabled={submitting} />
</div>
<div>
<Label for="email" class="mb-2">Email</Label>
<Input id="email" type="email" bind:value={email} disabled={submitting} />
</div>
<div>
<Label for="phone" class="mb-2">Phone</Label>
<Input id="phone" type="tel" bind:value={phone} disabled={submitting} />
</div>
<div>
<Label for="role" class="mb-2">Role</Label>
<Select id="role" items={roleOptions} bind:value={role} disabled={submitting} />
</div>
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" items={statusOptions} bind:value={status} disabled={submitting} />
</div>
<div>
<Label for="notes" class="mb-2">Notes</Label>
<Textarea id="notes" bind:value={notes} disabled={submitting} rows={4} />
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={submitting}>
{submitting ? 'Saving…' : 'Save'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={submitting}>
Cancel
</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,122 @@
<script lang="ts">
import { Input, Label, Button, Spinner, Alert } from 'flowbite-svelte';
import { UpdateProjectCategoryStore, GetProjectCategoryStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas';
import { sleep } from '$lib/utils/date';
let {
id,
onupdated
}: {
id: string;
onupdated?: () => void;
} = $props();
// form state
let name = $state('');
let orderStr = $state('0');
let loading = $state(false);
let error = $state('');
let success = $state(false);
const getCategoryStore = new GetProjectCategoryStore();
const updateStore = new UpdateProjectCategoryStore();
// Fetch category data
$effect(() => {
if (!id) return;
loading = true;
error = '';
getCategoryStore
.fetch({ variables: { id }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.projectScopeCategory) {
const category = res.data.projectScopeCategory;
name = category.name || '';
orderStr = String(category.order ?? 0);
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load category'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
success = false;
try {
const order = orderStr.trim() === '' ? null : Number(orderStr);
const res = await updateStore.mutate({ input: { id, name, order } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
if (res.data?.updateProjectScopeCategory?.id) {
success = true;
await sleep(1000);
offCanvas.closeRight();
onupdated?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update category';
} finally {
loading = false;
}
};
const handleCancel = (e: Event) => {
e.preventDefault();
offCanvas.closeRight();
};
</script>
{#if loading || $getCategoryStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading category data...
</div>
{:else if $getCategoryStore.data?.projectScopeCategory}
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Category updated!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="name" class="mb-2">Name</Label>
<Input
id="name"
type="text"
bind:value={name}
required
disabled={loading}
placeholder="Category name"
/>
</div>
<div>
<Label for="order" class="mb-2">Order</Label>
<Input id="order" type="number" bind:value={orderStr} min="0" step="1" disabled={loading} />
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{#if loading}
Saving...
{:else}
Save Changes
{/if}
</Button>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Category not found.</div>
{/if}

View File

@ -0,0 +1,87 @@
<script lang="ts">
import { Input, Label, Button } from 'flowbite-svelte';
import { CreateProjectCategoryStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas';
import { sleep } from '$lib/utils/date';
let { scopeId, onSuccess } = $props<{ scopeId: string; onSuccess?: () => void }>();
let projectScopeId = $derived(scopeId);
// form state
let name = $state('');
let orderStr = $state('0'); // keep as string for input binding, coerce on submit
let loading = $state(false);
let error = $state('');
let success = $state(false);
const createStore = new CreateProjectCategoryStore();
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!projectScopeId) return;
loading = true;
error = '';
success = false;
try {
const order = Number.isFinite(Number(orderStr)) ? Number(orderStr) : 0;
const res = await createStore.mutate({ input: { name, order, scopeId: projectScopeId } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
if (res.data?.createProjectScopeCategory?.id) {
success = true;
await sleep(1000);
offCanvas.closeRight();
onSuccess?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create category';
} finally {
loading = false;
}
};
const handleCancel = (e: Event) => {
e.preventDefault();
offCanvas.closeRight();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Category created!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="name" class="mb-2">Name</Label>
<Input
id="name"
type="text"
bind:value={name}
required
disabled={loading}
placeholder="Category name"
/>
</div>
<div>
<Label for="order" class="mb-2">Order</Label>
<Input id="order" type="number" bind:value={orderStr} min="0" step="1" disabled={loading} />
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{#if loading}
Creating...
{:else}
Create Category
{/if}
</Button>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,115 @@
<script lang="ts">
import { Input, Label, Button, Checkbox, Spinner, Alert } from 'flowbite-svelte';
import { UpdateProjectScopeStore, GetProjectScopeStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas';
import { sleep } from '$lib/utils/date';
let {
id,
onupdated
}: {
id: string;
onupdated?: () => void;
} = $props();
let name = $state('');
let description = $state('');
let isActive = $state(true);
let loading = $state(false);
let error = $state('');
let success = $state(false);
const getScopeStore = new GetProjectScopeStore();
const updateStore = new UpdateProjectScopeStore();
// Fetch scope data
$effect(() => {
if (!id) return;
loading = true;
error = '';
getScopeStore
.fetch({ variables: { id }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.projectScope) {
const scope = res.data.projectScope;
name = scope.name || '';
description = scope.description || '';
isActive = scope.isActive ?? true;
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load scope'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
success = false;
try {
const res = await updateStore.mutate({ input: { id, name, description, isActive } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(1000);
offCanvas.closeRight();
onupdated?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update scope';
} finally {
loading = false;
}
};
const handleCancel = (e: Event) => {
e.preventDefault();
offCanvas.closeRight();
};
</script>
{#if loading || $getScopeStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading scope data...
</div>
{:else if $getScopeStore.data?.projectScope}
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if success}
<Alert color="green">Scope updated!</Alert>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div>
<Label for="name" class="mb-2">Name</Label>
<Input id="name" type="text" bind:value={name} required disabled={loading} />
</div>
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input id="description" type="text" bind:value={description} disabled={loading} />
</div>
<div class="flex items-center gap-2">
<Checkbox id="active" bind:checked={isActive} disabled={loading} />
<Label for="active">Active</Label>
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{#if loading}
Saving...
{:else}
Save Changes
{/if}
</Button>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Scope not found.</div>
{/if}

View File

@ -0,0 +1,198 @@
<script lang="ts">
import { Input, Label, Button, Checkbox, Select } from 'flowbite-svelte';
import {
CreateProjectScopeStore,
GetProjectScopeTemplatesStore,
CreateProjectScopeFromTemplateStore
} from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
import { offCanvas } from '$lib/stores/offCanvas';
import { sleep } from '$lib/utils/date';
let { project, onSuccess } = $props<{
project: { id: string; accountId?: string; accountAddressId?: string };
onSuccess?: () => void;
}>();
let projectId = $derived(project?.id || null);
let accountId = $derived(project?.accountId || null);
let accountAddressId = $derived(project?.accountAddressId || null);
// form state
let name = $state('');
let description = $state('');
let isActive = $state(true);
let loading = $state(false);
let error = $state('');
let success = $state(false);
// creation mode: 'manual' or 'template'
let mode: 'manual' | 'template' = $state('manual');
let selectedTemplateId: string = $state('');
// stores
const templates = new GetProjectScopeTemplatesStore();
const createStore = new CreateProjectScopeStore();
const createFromTemplate = new CreateProjectScopeFromTemplateStore();
$effect(() => {
// load active templates on mount
templates
.fetch({ variables: { filters: { isActive: true } }, policy: 'NetworkOnly' })
.catch(() => {});
});
const handleTemplateChange = (e: Event) => {
const id = (e.target as HTMLSelectElement).value;
selectedTemplateId = id;
const list = ($templates.data?.projectScopeTemplates ?? []) as {
id: string;
name: string;
description: string;
isActive: boolean;
}[];
const t = list.find((x) => x.id === id);
if (t) {
if (!name) name = t.name;
if (!description) description = t.description;
}
};
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!projectId) return;
loading = true;
error = '';
success = false;
try {
let createdId: string | undefined;
if (mode === 'template' && selectedTemplateId) {
const res = await createFromTemplate.mutate({
input: {
projectId,
accountId: fromGlobalId(accountId) || null,
accountAddressId: fromGlobalId(accountAddressId) || null,
templateId: selectedTemplateId,
name: name || null,
description: description || null,
isActive
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
createdId = res.data?.createProjectScopeFromTemplate?.id;
} else {
const res = await createStore.mutate({ input: { name, description, isActive, projectId } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
createdId = res.data?.createProjectScope?.id;
}
if (createdId) {
success = true;
await sleep(1000);
offCanvas.closeRight();
onSuccess?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create project scope';
} finally {
loading = false;
}
};
const handleCancel = (e: Event) => {
e.preventDefault();
offCanvas.closeRight();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Scope created!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div class="flex items-center gap-4 md:col-span-2">
<label class="flex items-center gap-2 text-sm">
<input type="radio" name="mode" value="manual" bind:group={mode} disabled={loading} />
<span>Create manually</span>
</label>
<label class="flex items-center gap-2 text-sm">
<input
type="radio"
name="mode"
value="template"
bind:group={mode}
disabled={loading || !$templates.data?.projectScopeTemplates?.length}
/>
<span>Create from template</span>
</label>
</div>
{#if mode === 'template'}
<div class="md:col-span-2">
<Label for="template" class="mb-2">Template</Label>
<Select
id="template"
bind:value={selectedTemplateId}
disabled={loading}
onchange={handleTemplateChange}
>
<option value="">Select a template…</option>
{#if $templates.data?.projectScopeTemplates?.length}
{#each $templates.data.projectScopeTemplates as t (t.id)}
<option value={t.id}>{t.name}</option>
{/each}
{:else}
<option disabled>No templates available</option>
{/if}
</Select>
</div>
{:else}
<div>
<Label for="name" class="mb-2">Name</Label>
<Input
id="name"
type="text"
bind:value={name}
required
disabled={loading}
placeholder="Nightly Cleaning"
/>
</div>
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input
id="description"
type="text"
bind:value={description}
disabled={loading}
placeholder="Scope description"
/>
</div>
<div class="flex items-center gap-2">
<Checkbox id="active" bind:checked={isActive} disabled={loading} />
<Label for="active">Active</Label>
</div>
{/if}
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{#if loading}
Creating...
{:else if mode === 'template'}
Create From Template
{:else}
Create Scope
{/if}
</Button>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,156 @@
<script lang="ts">
import { Input, Label, Button, Spinner, Alert } from 'flowbite-svelte';
import { UpdateProjectTaskStore, GetProjectTaskStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas';
import { sleep } from '$lib/utils/date';
let {
id,
onupdated
}: {
id: string;
onupdated?: () => void;
} = $props();
// form state
let description = $state('');
let checklistDescription = $state('');
let estimatedMinutesStr = $state('');
let orderStr = $state('0');
let loading = $state(false);
let error = $state('');
let success = $state(false);
const getTaskStore = new GetProjectTaskStore();
const updateStore = new UpdateProjectTaskStore();
// Fetch task data
$effect(() => {
if (!id) return;
loading = true;
error = '';
getTaskStore
.fetch({ variables: { id }, policy: 'NetworkOnly' })
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
} else if (res.data?.projectScopeTask) {
const task = res.data.projectScopeTask;
description = task.description || '';
checklistDescription = task.checklistDescription || '';
estimatedMinutesStr = task.estimatedMinutes ? String(task.estimatedMinutes) : '';
orderStr = String(task.order ?? 0);
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load task'))
.finally(() => (loading = false));
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
success = false;
try {
const estimatedMinutes =
estimatedMinutesStr.trim() === '' ? null : Number(estimatedMinutesStr);
const order = orderStr.trim() === '' ? null : Number(orderStr);
const res = await updateStore.mutate({
input: {
id,
description,
checklistDescription,
estimatedMinutes,
order
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
if (res.data?.updateProjectScopeTask?.id) {
success = true;
await sleep(1000);
offCanvas.closeRight();
onupdated?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update task';
} finally {
loading = false;
}
};
const handleCancel = (e: Event) => {
e.preventDefault();
offCanvas.closeRight();
};
</script>
{#if loading || $getTaskStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading task data...
</div>
{:else if $getTaskStore.data?.projectScopeTask}
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<Alert color="red">{error}</Alert>
{/if}
{#if success}
<Alert color="green">Task updated!</Alert>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input
id="description"
type="text"
bind:value={description}
required
disabled={loading}
placeholder="Task description"
/>
</div>
<div class="md:col-span-2">
<Label for="checklist" class="mb-2">Checklist Description</Label>
<Input
id="checklist"
type="text"
bind:value={checklistDescription}
disabled={loading}
placeholder="Checklist details (optional)"
/>
</div>
<div>
<Label for="estimatedMinutes" class="mb-2">Estimated Minutes</Label>
<Input
id="estimatedMinutes"
type="number"
bind:value={estimatedMinutesStr}
min="0"
step="1"
disabled={loading}
placeholder="e.g., 15"
/>
</div>
<div>
<Label for="order" class="mb-2">Order</Label>
<Input id="order" type="number" bind:value={orderStr} min="0" step="1" disabled={loading} />
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{#if loading}
Saving...
{:else}
Save Changes
{/if}
</Button>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>
{:else}
<div class="text-gray-600 dark:text-gray-300">Task not found.</div>
{/if}

View File

@ -0,0 +1,121 @@
<script lang="ts">
import { Input, Label, Button } from 'flowbite-svelte';
import { CreateProjectTaskStore } from '$houdini';
import { offCanvas } from '$lib/stores/offCanvas';
import { sleep } from '$lib/utils/date';
let { categoryId, onSuccess } = $props<{ categoryId: string; onSuccess?: () => void }>();
let projectCategoryId = $derived(categoryId);
// form state
let description = $state('');
let checklistDescription = $state('');
let estimatedMinutesStr = $state(''); // string for binding; coerce to number or null
let orderStr = $state('0');
let loading = $state(false);
let error = $state('');
let success = $state(false);
const createStore = new CreateProjectTaskStore();
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!projectCategoryId) return;
loading = true;
error = '';
success = false;
try {
const estimatedMinutes =
estimatedMinutesStr.trim() === '' ? null : Number(estimatedMinutesStr);
const order = Number.isFinite(Number(orderStr)) ? Number(orderStr) : 0;
const res = await createStore.mutate({
input: {
categoryId: projectCategoryId,
description,
checklistDescription,
estimatedMinutes,
order
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
if (res.data?.createProjectScopeTask?.id) {
success = true;
await sleep(1000);
offCanvas.closeRight();
onSuccess?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create task';
} finally {
loading = false;
}
};
const handleCancel = (e: Event) => {
e.preventDefault();
offCanvas.closeRight();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Task created!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input
id="description"
type="text"
bind:value={description}
required
disabled={loading}
placeholder="Task description"
/>
</div>
<div class="md:col-span-2">
<Label for="checklist" class="mb-2">Checklist Description</Label>
<Input
id="checklist"
type="text"
bind:value={checklistDescription}
disabled={loading}
placeholder="Checklist details (optional)"
/>
</div>
<div>
<Label for="estimatedMinutes" class="mb-2">Estimated Minutes</Label>
<Input
id="estimatedMinutes"
type="number"
bind:value={estimatedMinutesStr}
min="0"
step="1"
disabled={loading}
placeholder="e.g., 15"
/>
</div>
<div>
<Label for="order" class="mb-2">Order</Label>
<Input id="order" type="number" bind:value={orderStr} min="0" step="1" disabled={loading} />
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{#if loading}
Creating...
{:else}
Create Task
{/if}
</Button>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,50 @@
<script lang="ts">
import { Button, Input, Label } from 'flowbite-svelte';
import { CreateProjectCategoryTemplateStore } from '$houdini';
let { scopeTemplateId, onadded }: { scopeTemplateId: string; onadded?: () => void } = $props();
let name = $state('');
let order = $state(0);
let loading = $state(false);
let error = $state('');
const createStore = new CreateProjectCategoryTemplateStore();
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
try {
const res = await createStore.mutate({ input: { name, order, scopeTemplateId } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else {
name = '';
order = 0;
onadded?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to add category';
} finally {
loading = false;
}
};
</script>
<form class="mb-4 flex flex-wrap items-end gap-3" onsubmit={handleSubmit}>
<div>
<Label for="catName" class="mb-1">Name</Label>
<Input id="catName" bind:value={name} required />
</div>
<div>
<Label for="catOrder" class="mb-1">Order</Label>
<Input id="catOrder" type="number" bind:value={order} min={0} />
</div>
<Button type="submit" color="primary" disabled={loading}
>{loading ? 'Adding…' : 'Add Category'}</Button
>
{#if error}
<span class="text-xs text-red-600">{error}</span>
{/if}
</form>

View File

@ -0,0 +1,66 @@
<script lang="ts">
import { Input, Label, Button, Checkbox } from 'flowbite-svelte';
import { UpdateProjectScopeTemplateStore } from '$houdini';
let {
id,
name,
description,
isActive,
onupdated
}: {
id: string;
name: string;
description: string;
isActive: boolean;
onupdated?: () => void;
} = $props();
let saving = $state(false);
let error = $state('');
let success = $state(false);
const updateStore = new UpdateProjectScopeTemplateStore();
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
saving = true;
error = '';
success = false;
try {
const res = await updateStore.mutate({ input: { id, name, description, isActive } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else {
success = true;
onupdated?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update template';
} finally {
saving = false;
}
};
</script>
<form class="mb-6 grid gap-4 md:grid-cols-2" onsubmit={handleSubmit}>
<div>
<Label for="name" class="mb-2">Name</Label>
<Input id="name" type="text" bind:value={name} required />
</div>
<div class="md:col-span-2">
<Label for="desc" class="mb-2">Description</Label>
<Input id="desc" type="text" bind:value={description} />
</div>
<div class="flex items-center gap-2">
<Checkbox id="active" bind:checked={isActive} />
<Label for="active">Active</Label>
</div>
<div class="flex items-center gap-2 md:col-span-2">
<Button type="submit" color="primary" disabled={saving}
>{saving ? 'Saving…' : 'Save Changes'}</Button
>
{#if error}<span class="text-sm text-red-600">{error}</span>{/if}
{#if success}<span class="text-sm text-green-600">Saved!</span>{/if}
</div>
</form>

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { Input, Label, Button, Checkbox } from 'flowbite-svelte';
import { CreateProjectScopeTemplateStore } from '$houdini';
import { sleep } from '$lib/utils/date';
import { goto } from '$app/navigation';
import { offCanvas } from '$lib/utils/offCanvas';
let { oncreated }: { oncreated?: () => void } = $props();
let name = $state('');
let description = $state('');
let isActive = $state(true);
let loading = $state(false);
let error = $state('');
let success = $state(false);
const createStore = new CreateProjectScopeTemplateStore();
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
success = false;
try {
const res = await createStore.mutate({ input: { name, description, isActive } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else if (res.data?.createProjectScopeTemplate?.id) {
success = true;
await sleep(800);
oncreated?.();
offCanvas.closeRight();
await goto(`/project-scopes/templates/edit/${res.data.createProjectScopeTemplate.id}`);
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create template';
} finally {
loading = false;
}
};
const handleCancel = (e: Event) => {
e.preventDefault();
offCanvas.closeRight();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Template created!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div>
<Label for="name" class="mb-2">Name</Label>
<Input
id="name"
type="text"
bind:value={name}
required
disabled={loading}
placeholder="Project Scope Template"
/>
</div>
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input
id="description"
type="text"
bind:value={description}
disabled={loading}
placeholder="Template description"
/>
</div>
<div class="flex items-center gap-2">
<Checkbox id="active" bind:checked={isActive} disabled={loading} />
<Label for="active">Active</Label>
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}
>{loading ? 'Creating…' : 'Create Template'}</Button
>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,88 @@
<script lang="ts">
import { Button, Input, Label } from 'flowbite-svelte';
import { CreateProjectTaskTemplateStore } from '$houdini';
let { areaId, onadded }: { areaId: string; onadded?: () => void } = $props();
let description = $state('');
let checklistDescription = $state('');
let order = $state(0);
let estimatedMinutes: number | null = $state(null);
let loading = $state(false);
let error = $state('');
const createStore = new CreateProjectTaskTemplateStore();
// proxy for the input binding (never null)
let estimatedMinutesProxy: string | number = $derived('');
// keep proxy in sync when model changes externally
$effect(() => {
estimatedMinutesProxy = estimatedMinutes ?? '';
});
function syncEstimatedMinutes() {
estimatedMinutes = estimatedMinutesProxy === '' ? null : Number(estimatedMinutesProxy);
}
const submit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
try {
const res = await createStore.mutate({
input: {
areaTemplateId: areaId,
description,
checklistDescription,
order,
estimatedMinutes: estimatedMinutes ?? undefined
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else {
description = '';
checklistDescription = '';
order = 0;
estimatedMinutes = null;
onadded?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create task';
} finally {
loading = false;
}
};
</script>
<form class="mb-3 flex flex-wrap items-end gap-2" onsubmit={submit}>
<div>
<Label class="mb-1">Description</Label>
<Input bind:value={description} required class="w-64" />
</div>
<div>
<Label class="mb-1">Checklist Description</Label>
<Input bind:value={checklistDescription} class="w-64" placeholder="Optional checklist text" />
</div>
<div>
<Label class="mb-1">Order</Label>
<Input type="number" bind:value={order} class="w-24" />
</div>
<div>
<Label class="mb-1">Minutes</Label>
<Input
type="number"
class="w-24"
bind:value={estimatedMinutesProxy}
oninput={syncEstimatedMinutes}
/>
</div>
<Button type="submit" color="primary" size="sm" disabled={loading}
>{loading ? 'Adding…' : 'Add Task'}</Button
>
{#if error}
<span class="text-xs text-red-600">{error}</span>
{/if}
</form>

View File

@ -0,0 +1,233 @@
<script lang="ts">
import { Button, Input, Label, Textarea, Select } from 'flowbite-svelte';
import { GetTeamProfilesStore, UpdateProjectStore } from '$houdini';
import { sleep } from '$lib/utils/date';
import { fromGlobalId } from '$lib/utils/relay';
import { SvelteMap } from 'svelte/reactivity';
import { offCanvas } from '$lib/stores/offCanvas';
type TeamMember = { pk: string };
type ProjectProp = {
id: string;
name: string;
amount: number;
labor: number;
date: string | null;
status: 'SCHEDULED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED' | string;
notes: string | null;
teamMembers?: TeamMember[] | null;
};
let { project, onSuccess } = $props<{
project: ProjectProp;
onSuccess?: () => void;
}>();
// Local state initialized from props
let name = $state(project?.name ?? '');
let amount = $state<number>(Number(project?.amount ?? 0));
let labor = $state<number>(Number(project?.labor ?? 0));
let date = $state<string>(project?.date ?? '');
let status = $state<string>(project?.status ?? 'SCHEDULED');
let notes = $state<string>(project?.notes ?? '');
// Team profiles for optional selection
let profiles = $state(new GetTeamProfilesStore());
// Checkbox model: keep global IDs as values (Relay IDs)
let teamMembers = $state<string[]>([]);
// Reset form when the incoming project changes
let lastId = $state<string | null>(null);
$effect(() => {
if (!project) return;
if (project.id !== lastId) {
name = project.name ?? '';
amount = Number(project.amount ?? 0);
labor = Number(project.labor ?? 0);
date = project.date ?? '';
status = String(project.status ?? 'SCHEDULED');
notes = project.notes ?? '';
// teamMembers will be set by the mapping effect below once profiles are loaded
lastId = project.id;
}
});
// Load team profiles (ACTIVE only filtering is applied in UI)
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
});
// Translate existing team member PKs (UUIDs) to Relay IDs for checkbox pre-selection
$effect(() => {
const projectPks = new Set((project?.teamMembers ?? []).map((m: TeamMember) => String(m.pk)));
const allProfiles = $profiles.data?.teamProfiles ?? [];
if (!allProfiles.length) return;
teamMembers = allProfiles
.filter((p) => projectPks.has(String(fromGlobalId(p.id))))
.map((p) => String(p.id));
});
// Build a reactive map of profile global ID -> profile to render names instead of IDs
const profileByGlobalId = $derived(() => {
const map = new SvelteMap<
string,
{ id: string; firstName?: string; lastName?: string; fullName?: string }
>();
for (const p of $profiles.data?.teamProfiles ?? []) {
map.set(String(p.id), p);
}
return map;
});
// Presentable names for currently selected team members
const selectedTeamNames = $derived(() => {
return teamMembers
.map((gid) => profileByGlobalId().get(String(gid)))
.filter(Boolean)
.map((p) => p!.fullName || `${p!.firstName ?? ''} ${p!.lastName ?? ''}`.trim())
.filter(Boolean);
});
let loading = $state(false);
let error = $state('');
let success = $state(false);
const updateStore = new UpdateProjectStore();
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
const result = await updateStore.mutate({
input: {
id: project.id,
name,
amount,
labor,
date: date || null,
status,
notes: (notes ?? '') === '' ? '' : notes,
// Submit global Relay IDs that match the checkbox values
// Use empty array to clear all team members (null means "don't change")
teamMemberIds: teamMembers
}
});
if (result.errors?.length) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(1500);
offCanvas.closeRight();
if (onSuccess) onSuccess();
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = (event: Event) => {
event.preventDefault();
offCanvas.closeRight();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Project updated successfully! Redirecting back...
</div>
{/if}
<div class="mb-6 grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="name" class="mb-2">Project Name</Label>
<Input
id="name"
type="text"
placeholder="Project name"
bind:value={name}
required
disabled={loading}
/>
</div>
<div>
<Label for="amount" class="mb-2">Amount</Label>
<Input id="amount" type="number" min="0" step="0.01" bind:value={amount} disabled={loading} />
</div>
<div>
<Label for="labor" class="mb-2">Labor</Label>
<Input id="labor" type="number" min="0" step="0.1" bind:value={labor} disabled={loading} />
</div>
<div>
<Label for="date" class="mb-2">Date</Label>
<Input id="date" type="date" bind:value={date} disabled={loading} />
</div>
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" bind:value={status} disabled={loading}>
<option value="SCHEDULED">Scheduled</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
</Select>
</div>
<div class="md:col-span-2">
<Label for="notes" class="mb-2">Notes</Label>
<Textarea id="notes" rows={4} bind:value={notes} disabled={loading} />
</div>
</div>
<!-- Team Members (optional) -->
<div class="mb-6">
<Label class="mb-2">Team Members (optional)</Label>
{#if $profiles.fetching}
<div class="text-sm text-gray-500">Loading team profiles...</div>
{:else if ($profiles.data?.teamProfiles?.length ?? 0) === 0}
<div class="text-sm text-gray-500">No team profiles found.</div>
{:else}
<div class="grid gap-2 md:grid-cols-2">
{#each ($profiles.data?.teamProfiles ?? []).filter((p) => String(p.status) === 'ACTIVE') as p (p.id)}
<label class="flex items-center gap-2">
<input
type="checkbox"
value={String(p.id)}
bind:group={teamMembers}
disabled={loading}
/>
<span>{p.fullName || `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim()}</span>
</label>
{/each}
</div>
{#if selectedTeamNames().length}
<div class="mt-3 text-sm text-gray-700 dark:text-gray-300">
<strong>Selected:</strong>
{selectedTeamNames().join(', ')}
</div>
{/if}
{/if}
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{loading ? 'Saving...' : 'Save'}
</Button>
<Button type="button" color="red" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,347 @@
<script lang="ts">
import { Input, Label, Button, Textarea, Select } from 'flowbite-svelte';
import {
GetAccountsStore,
GetTeamProfilesStore,
CreateProjectStore,
type GetCustomersStore
} from '$houdini';
import { sleep } from '$lib/utils/date';
import { fromGlobalId } from '$lib/utils/relay';
import { offCanvas } from '$lib/stores/offCanvas';
let { customers, onSuccess } = $props<{
customers: GetCustomersStore;
onSuccess?: () => void;
}>();
let accounts = new GetAccountsStore();
let profiles = new GetTeamProfilesStore();
let project = new CreateProjectStore();
let customerId = $state('');
let accountId = $state('');
let accountAddressId = $state('');
let streetAddress = $state('');
let city = $state('');
let addrState = $state('');
let zipCode = $state('');
let amount = $state(0);
let date = $state('');
let labor = $state(0);
let name = $state('');
let notes = $state('');
let status = $state('SCHEDULED');
let teamMembers = $state<string[]>([]);
let loading = $state(false);
let error = $state('');
let success = $state(false);
const fetchAccounts = async (id: string) => {
await accounts.fetch({
variables: { filters: { customerId: fromGlobalId(id) } },
policy: 'NetworkOnly'
});
};
$effect(() => {
if (customerId) {
fetchAccounts(customerId).catch(
(e) => (error = e instanceof Error ? e.message : 'Failed to load accounts')
);
accountId = '';
accountAddressId = '';
} else {
accountId = '';
accountAddressId = '';
}
});
$effect(() => {
// Reset address selections/fields when account selection changes
accountAddressId = '';
if (!accountId) {
// No account selected; ensure freeform fields are available
// keep any typed values; no reset to avoid losing user input unnecessarily
} else {
// Account selected; clear freeform fields to avoid accidental submission
streetAddress = '';
city = '';
addrState = '';
zipCode = '';
}
});
// Load team profiles (for optional team member selection)
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch((e) => {
console.error('Failed to load team profiles', e);
});
});
const addressesForSelectedAccount = () => {
const list = $accounts.data?.accounts ?? [];
const acct = list.find((a) => String(a.id) === accountId);
return acct?.addresses?.filter((addr) => addr.isActive) ?? [];
};
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
if (!customerId) {
error = 'Please select a customer';
loading = false;
return;
}
// Validation: require address based on account selection
if (accountId) {
if (!accountAddressId) {
error = 'Please select an address for the selected account';
loading = false;
return;
}
} else {
if (!streetAddress || !city || !addrState || !zipCode) {
error =
'Please provide the project address (street, city, state, zip) or select an account address';
loading = false;
return;
}
}
const result = await project.mutate({
input: {
customerId: customerId,
accountAddressId: accountAddressId || null,
streetAddress: accountId ? null : streetAddress || null,
city: accountId ? null : city || null,
state: accountId ? null : addrState || null,
zipCode: accountId ? null : zipCode || null,
amount: amount,
date: date || null,
labor: labor,
name: name,
notes: notes || null,
status: status,
teamMemberIds: teamMembers
}
});
if (result.errors) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(1500);
offCanvas.closeRight();
if (onSuccess) onSuccess();
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = (event: Event) => {
event.preventDefault();
offCanvas.closeRight();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">
{error}
</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Project created successfully! Redirecting back...
</div>
{/if}
<div class="mb-6 grid gap-6 md:grid-cols-2">
<!-- Customer -->
<div class="md:col-span-2">
<Label for="customer" class="mb-2">Customer</Label>
<Select
id="customer"
bind:value={customerId}
required
disabled={loading || $customers.fetching}
>
<option value="" disabled selected
>{($customers.data?.customers?.length ?? 0) === 0
? 'No customers available'
: 'Select a customer'}</option
>
{#if $customers.data?.customers}
{#each $customers.data.customers as c (c.id)}
<option value={String(c.id)}>{c.name}</option>
{/each}
{/if}
</Select>
</div>
<!-- Account -->
<div class="md:col-span-2">
<Label for="account" class="mb-2">Account (optional)</Label>
<Select
id="account"
bind:value={accountId}
disabled={loading || !customerId || $accounts.fetching}
>
<option value="">None</option>
{#if $accounts.data?.accounts}
{#each $accounts.data.accounts as a (a.id)}
<option value={String(a.id)}>{a.name}</option>
{/each}
{/if}
</Select>
</div>
{#if accountId}
<!-- Account Address Selection -->
<div class="md:col-span-2">
<Label for="accountAddress" class="mb-2">Account Address</Label>
<Select id="accountAddress" bind:value={accountAddressId} disabled={loading}>
<option value="" disabled selected
>{addressesForSelectedAccount().length === 0
? 'No active addresses available'
: 'Select an address'}</option
>
{#each addressesForSelectedAccount() as addr (addr.id)}
<option value={String(addr.id)}>
{addr.name ? `${addr.name} ` : ''}{addr.streetAddress}, {addr.city}, {addr.state}
{addr.zipCode}
</option>
{/each}
</Select>
</div>
{:else}
<!-- Freeform Address Inputs -->
<div>
<Label for="streetAddress" class="mb-2">Street Address</Label>
<Input
id="streetAddress"
type="text"
placeholder="123 Main St"
bind:value={streetAddress}
disabled={loading}
/>
</div>
<div>
<Label for="city" class="mb-2">City</Label>
<Input id="city" type="text" placeholder="City" bind:value={city} disabled={loading} />
</div>
<div>
<Label for="state" class="mb-2">State</Label>
<Input
id="state"
type="text"
placeholder="State"
bind:value={addrState}
disabled={loading}
/>
</div>
<div>
<Label for="zip" class="mb-2">ZIP Code</Label>
<Input id="zip" type="text" placeholder="ZIP" bind:value={zipCode} disabled={loading} />
</div>
{/if}
<!-- Name -->
<div>
<Label for="name" class="mb-2">Project Name</Label>
<Input
id="name"
type="text"
placeholder="Project name"
bind:value={name}
required
disabled={loading}
/>
</div>
<!-- Amount -->
<div>
<Label for="amount" class="mb-2">Amount</Label>
<Input id="amount" type="number" min="0" step="0.01" bind:value={amount} disabled={loading} />
</div>
<!-- Date -->
<div>
<Label for="date" class="mb-2">Date</Label>
<Input id="date" type="date" bind:value={date} disabled={loading} />
</div>
<!-- Labor -->
<div>
<Label for="labor" class="mb-2">Labor</Label>
<Input id="labor" type="number" min="0" step="0.1" bind:value={labor} disabled={loading} />
</div>
<!-- Status -->
<div>
<Label for="status" class="mb-2">Status</Label>
<Select id="status" bind:value={status} disabled={loading}>
<option value="SCHEDULED">Scheduled</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
</Select>
</div>
<!-- Notes -->
<div class="md:col-span-2">
<Label for="notes" class="mb-2">Notes</Label>
<Textarea
id="notes"
bind:value={notes}
placeholder="Additional details..."
disabled={loading}
/>
</div>
</div>
<!-- Team Members (optional) -->
<div class="mb-6">
<Label class="mb-2">Team Members (optional)</Label>
{#if $profiles.fetching}
<div class="text-sm text-gray-500">Loading team profiles...</div>
{:else if ($profiles.data?.teamProfiles?.length ?? 0) === 0}
<div class="text-sm text-gray-500">No team profiles found.</div>
{:else}
<!-- show only active profiles -->
<div class="grid gap-2 md:grid-cols-2">
{#each ($profiles.data?.teamProfiles ?? []).filter((p) => String(p.status) === 'ACTIVE') as p (p.id)}
<label class="flex items-center gap-2">
<input
type="checkbox"
value={String(p.id)}
bind:group={teamMembers}
disabled={loading}
class="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 focus:ring-2 dark:border-gray-600"
/>
<span class="text-sm text-gray-800 dark:text-gray-200"
>{p.fullName || `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim()}</span
>
</label>
{/each}
</div>
{/if}
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{loading ? 'Creating...' : 'Submit'}
</Button>
<Button type="button" color="red" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,125 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { GetTeamProfilesStore, UpdateReportStore, DeleteReportStore } from '$houdini';
import { Alert, Button, Input, Label } from 'flowbite-svelte';
import ConfirmDeleteModal from '$lib/components/modals/common/ConfirmDeleteModal.svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
report: {
id: string;
date: string;
};
teamMemberGlobalId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
let { report, teamMemberGlobalId, onSuccess, onCancel }: Props = $props();
let profiles = $state(new GetTeamProfilesStore());
let update = $state(new UpdateReportStore());
let del = $state(new DeleteReportStore());
let error = $state('');
let submitting = $state(false);
let date = $state(String(report.date || ''));
let teamMemberId = $state(teamMemberGlobalId);
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!date || !teamMemberId) {
error = 'Please provide date and team member';
return;
}
submitting = true;
error = '';
try {
const res = await update.mutate({
input: {
id: report.id,
date,
teamMemberId
}
});
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 update report';
} finally {
submitting = false;
}
};
const handleDelete = async () => {
try {
const res = await del.mutate({ id: report.id });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
return;
}
offCanvas.closeRight();
await goto('/reports');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete report';
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="date" class="mb-2">Date</Label>
<Input id="date" type="date" bind:value={date} disabled={submitting} />
</div>
<div>
<Label for="member" class="mb-2">Team Member</Label>
<select
id="member"
class="block w-full rounded-lg border border-gray-300 p-2.5 text-gray-900 focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
bind:value={teamMemberId}
disabled={$profiles.fetching || submitting}
>
<option value="">Select a team member</option>
{#if $profiles.data?.teamProfiles}
{#each $profiles.data.teamProfiles as p (p.id)}
<option value={String(p.id)}
>{p.fullName || `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim()}</option
>
{/each}
{/if}
</select>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={submitting}>
{submitting ? 'Saving…' : 'Save'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={submitting}
>Cancel</Button
>
<ConfirmDeleteModal
message="Are you sure you want to delete this report?"
onconfirm={handleDelete}
/>
</div>
</form>
</div>

View File

@ -0,0 +1,96 @@
<script lang="ts">
import { CreateReportStore, GetTeamProfilesStore } from '$houdini';
import { Alert, Button, Input, Label } from 'flowbite-svelte';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
onSuccess?: () => void;
onCancel?: () => void;
}
let { onSuccess, onCancel }: Props = $props();
let creating = $state(false);
let error = $state('');
let date = $state('');
let teamMemberId = $state('');
let profiles = $state(new GetTeamProfilesStore());
let create = $state(new CreateReportStore());
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
});
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
creating = true;
error = '';
try {
if (!date || !teamMemberId) {
error = 'Please provide date and team member';
return;
}
const res = await create.mutate({
input: {
date,
teamMemberId
}
});
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 create report';
} finally {
creating = false;
}
};
const handleCancel = () => {
offCanvas.closeRight();
onCancel?.();
};
</script>
<div class="space-y-6">
{#if error}
<Alert color="red">{error}</Alert>
{/if}
<form class="space-y-6" onsubmit={handleSubmit}>
<div>
<Label for="date" class="mb-2">Date</Label>
<Input id="date" type="date" bind:value={date} disabled={creating} />
</div>
<div>
<Label for="member" class="mb-2">Team Member</Label>
<select
id="member"
class="block w-full rounded-lg border border-gray-300 p-2.5 text-gray-900 focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
bind:value={teamMemberId}
disabled={creating || $profiles.fetching}
>
<option value="">Select a team member</option>
{#if $profiles.data?.teamProfiles}
{#each $profiles.data.teamProfiles as p (p.id)}
<option value={String(p.id)}
>{p.fullName || `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim()}</option
>
{/each}
{/if}
</select>
</div>
<div class="flex items-center gap-2">
<Button type="submit" color="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create'}
</Button>
<Button type="button" color="light" onclick={handleCancel} disabled={creating}>Cancel</Button>
</div>
</form>
</div>

View File

@ -0,0 +1,89 @@
<script lang="ts">
import { Input, Label, Button } from 'flowbite-svelte';
import { UpdateAreaStore } from '$houdini';
import { sleep } from '$lib/utils/date';
type AreaProp = {
id: string;
name: string;
order: number | null;
scopeId: string;
};
let { area } = $props<{ area: AreaProp }>();
let name = $state(area?.name ?? '');
let orderText = $state<string>(String(area?.order ?? 0));
let loading = $state(false);
let error = $state('');
let success = $state(false);
$effect(() => {
if (!area) return;
name = area.name ?? '';
orderText = String(area.order ?? 0);
});
const updateArea = new UpdateAreaStore();
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
const orderNum = Number(orderText ?? '0');
const result = await updateArea.mutate({
input: {
id: area.id,
name,
order: Number.isFinite(orderNum) ? (orderNum as number) : 0
}
});
if (result.errors?.length) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(800);
history.back();
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = (event: Event) => {
event.preventDefault();
history.back();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Area updated!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div>
<Label for="name" class="mb-2">Name</Label>
<Input id="name" type="text" bind:value={name} required disabled={loading} />
</div>
<div>
<Label for="order" class="mb-2">Order</Label>
<Input id="order" type="number" bind:value={orderText} min={0} disabled={loading} />
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}
>{loading ? 'Saving...' : 'Save'}</Button
>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { Input, Label, Button } from 'flowbite-svelte';
import { CreateAreaStore } from '$houdini';
import { sleep } from '$lib/utils/date';
let { scopeId } = $props(); // global ID
let name = $state('');
let order = $state(0);
let loading = $state(false);
let error = $state('');
let success = $state(false);
const createArea = new CreateAreaStore();
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
const result = await createArea.mutate({
input: { scopeId, name, order }
});
if (result.errors?.length) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(1000);
history.back();
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = async (event: Event) => {
event.preventDefault();
history.back();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Area created!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div>
<Label for="name" class="mb-2">Name</Label>
<Input
id="name"
type="text"
bind:value={name}
required
disabled={loading}
placeholder="Kitchen"
/>
</div>
<div>
<Label for="order" class="mb-2">Order</Label>
<Input id="order" type="number" bind:value={order} min={0} disabled={loading} />
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}
>{loading ? 'Creating...' : 'Create Area'}</Button
>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,97 @@
<script lang="ts">
import { Input, Label, Button, Checkbox } from 'flowbite-svelte';
import { UpdateScopeStore } from '$houdini';
import { sleep } from '$lib/utils/date';
type ScopeProp = {
id: string;
accountId: string;
accountAddressId: string | null;
name: string;
description: string | null;
isActive: boolean;
};
let { scope } = $props<{ scope: ScopeProp }>();
let name = $state(scope?.name ?? '');
let description = $state<string>(scope?.description ?? '');
let isActive = $state<boolean>(!!scope?.isActive);
let loading = $state(false);
let error = $state('');
let success = $state(false);
$effect(() => {
if (!scope) return;
name = scope.name ?? '';
description = scope.description ?? '';
isActive = !!scope.isActive;
});
const updateScope = new UpdateScopeStore();
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
const result = await updateScope.mutate({
input: {
id: scope.id,
name,
description: (description ?? '').toString(),
isActive
}
});
if (result.errors?.length) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(1000);
history.back();
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = (event: Event) => {
event.preventDefault();
history.back();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Scope updated!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div>
<Label for="name" class="mb-2">Name</Label>
<Input id="name" type="text" bind:value={name} required disabled={loading} />
</div>
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input id="description" type="text" bind:value={description} disabled={loading} />
</div>
<div class="flex items-center gap-2">
<Checkbox id="active" bind:checked={isActive} disabled={loading} />
<Label for="active">Active</Label>
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}
>{loading ? 'Saving...' : 'Save'}</Button
>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,163 @@
<script lang="ts">
import { Input, Label, Button, Checkbox, Select } from 'flowbite-svelte';
import { CreateScopeStore, CreateScopeFromTemplateStore } from '$houdini';
import { sleep } from '$lib/utils/date';
import { toGlobalId, fromGlobalId } from '$lib/utils/relay';
let { accountId, accountAddressId, templates = null } = $props();
let name = $state('');
let description = $state('');
let isActive = $state(true);
let loading = $state(false);
let error = $state('');
let success = $state(false);
// creation mode: 'manual' or 'template'
let mode: 'manual' | 'template' = $state('manual');
let selectedTemplateId = $state('');
const createScope = new CreateScopeStore();
const createScopeFromTemplate = new CreateScopeFromTemplateStore();
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
if (mode === 'template') {
if (!selectedTemplateId) {
error = 'Please select a template';
return;
}
const result = await createScopeFromTemplate.mutate({
input: {
// decode Relay IDs to raw UUIDs (fallback to original if already UUID)
templateId: fromGlobalId(selectedTemplateId) || selectedTemplateId,
accountId: fromGlobalId(accountId) || accountId,
accountAddressId: accountAddressId
? fromGlobalId(accountAddressId) || accountAddressId
: undefined
}
});
if (result.errors?.length) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(1200);
history.back();
}
} else {
const result = await createScope.mutate({
input: {
accountId: toGlobalId('AccountType', accountId),
accountAddressId: accountAddressId ?? undefined,
name,
description,
isActive
}
});
if (result.errors?.length) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(1200);
history.back();
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = async (event: Event) => {
event.preventDefault();
history.back();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Scope created!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div class="flex items-center gap-4 md:col-span-2">
<label class="flex items-center gap-2 text-sm">
<input type="radio" name="mode" value="manual" bind:group={mode} disabled={loading} />
<span>Create manually</span>
</label>
<label class="flex items-center gap-2 text-sm">
<input
type="radio"
name="mode"
value="template"
bind:group={mode}
disabled={loading || !templates?.scopeTemplates?.length}
/>
<span>Create from template</span>
</label>
</div>
{#if mode === 'template'}
<div class="md:col-span-2">
<Label for="template" class="mb-2">Template</Label>
<Select id="template" bind:value={selectedTemplateId} disabled={loading}>
<option value="">Select a template…</option>
{#if templates?.scopeTemplates?.length}
{#each templates.scopeTemplates as t (t.id)}
<option value={t.id}>{t.name}</option>
{/each}
{:else}
<option disabled>No templates available</option>
{/if}
</Select>
</div>
{:else}
<div>
<Label for="name" class="mb-2">Name</Label>
<Input
id="name"
type="text"
bind:value={name}
required
disabled={loading}
placeholder="Nightly Cleaning"
/>
</div>
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input
id="description"
type="text"
bind:value={description}
disabled={loading}
placeholder="Scope description"
/>
</div>
<div class="flex items-center gap-2">
<Checkbox id="active" bind:checked={isActive} disabled={loading} />
<Label for="active">Active</Label>
</div>
{/if}
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{#if loading}
Creating...
{:else if mode === 'template'}
Create From Template
{:else}
Create Scope
{/if}
</Button>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,140 @@
<script lang="ts">
import { Input, Label, Button, Select } from 'flowbite-svelte';
import { UpdateTaskStore } from '$houdini';
import { sleep } from '$lib/utils/date';
type TaskProp = {
id: string;
areaId: string;
description: string;
checklistDescription?: string | null;
estimatedMinutes: number | null;
frequency: string;
order: number | null;
};
let { task } = $props<{ task: TaskProp }>();
let description = $state(task?.description ?? '');
let checklistDescription = $state<string>(task?.checklistDescription ?? '');
let frequency = $state<string>(String(task?.frequency ?? 'daily').toLowerCase());
let orderText = $state<string>(String(task?.order ?? 0));
let estimatedMinutesText = $state<string>(
task?.estimatedMinutes === null || task?.estimatedMinutes === undefined
? ''
: String(task?.estimatedMinutes)
);
let loading = $state(false);
let error = $state('');
let success = $state(false);
$effect(() => {
if (!task) return;
description = task.description ?? '';
checklistDescription = task.checklistDescription ?? '';
frequency = String(task.frequency ?? 'daily').toLowerCase();
orderText = String(task.order ?? 0);
estimatedMinutesText = task.estimatedMinutes == null ? '' : String(task.estimatedMinutes);
});
const updateTask = new UpdateTaskStore();
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
const orderNum = Number(orderText ?? '0');
const minutesText = (estimatedMinutesText ?? '').toString().trim();
const minutesNumber = minutesText === '' ? null : Number(minutesText);
const result = await updateTask.mutate({
input: {
id: task.id,
description,
checklistDescription,
frequency,
order: Number.isFinite(orderNum) ? (orderNum as number) : 0,
estimatedMinutes: Number.isFinite(minutesNumber as number)
? (minutesNumber as number | null)
: null
}
});
if (result.errors?.length) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(800);
history.back();
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = (event: Event) => {
event.preventDefault();
history.back();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Task updated!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input id="description" type="text" bind:value={description} required disabled={loading} />
</div>
<div class="md:col-span-2">
<Label for="checklistDescription" class="mb-2">Checklist Description</Label>
<Input
id="checklistDescription"
type="text"
bind:value={checklistDescription}
disabled={loading}
/>
</div>
<div>
<Label for="frequency" class="mb-2">Frequency</Label>
<Select id="frequency" bind:value={frequency} disabled={loading}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="triannual">Triannual</option>
<option value="annual">Annual</option>
<option value="as_needed">As needed</option>
</Select>
</div>
<div>
<Label for="order" class="mb-2">Order</Label>
<Input id="order" type="number" bind:value={orderText} min={0} disabled={loading} />
</div>
<div>
<Label for="estimated" class="mb-2">Estimated Minutes</Label>
<Input
id="estimated"
type="number"
bind:value={estimatedMinutesText}
min={0}
disabled={loading}
/>
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}
>{loading ? 'Saving...' : 'Save'}</Button
>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,124 @@
<script lang="ts">
import { Input, Label, Button, Select } from 'flowbite-svelte';
import { CreateTaskStore } from '$houdini';
import { sleep } from '$lib/utils/date';
let { areaId } = $props(); // global ID
let description = $state('');
let checklistDescription = $state('');
let frequency = $state('daily');
let order = $state(0);
let estimatedMinutesText = $state('');
let loading = $state(false);
let error = $state('');
let success = $state(false);
const createTask = new CreateTaskStore();
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
const minutesText = (estimatedMinutesText ?? '').toString().trim();
const minutesNumber = minutesText === '' ? null : Number(minutesText);
const result = await createTask.mutate({
input: {
areaId,
description,
checklistDescription,
frequency,
isConditional: false,
order,
estimatedMinutes: Number.isFinite(minutesNumber as number)
? (minutesNumber as number | null)
: null
}
});
if (result.errors?.length) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
await sleep(1000);
history.back();
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = async (event: Event) => {
event.preventDefault();
history.back();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Task created!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input
id="description"
type="text"
bind:value={description}
required
disabled={loading}
placeholder="Empty trash and replace liners"
/>
</div>
<div class="md:col-span-2">
<Label for="checklistDescription" class="mb-2">Checklist Description</Label>
<Input
id="checklistDescription"
type="text"
bind:value={checklistDescription}
disabled={loading}
placeholder="Optional checklist text shown in app"
/>
</div>
<div>
<Label for="frequency" class="mb-2">Frequency</Label>
<Select id="frequency" bind:value={frequency} disabled={loading} required>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="triannual">Triannual</option>
<option value="annual">Annual</option>
<option value="as_needed">As needed</option>
</Select>
</div>
<div>
<Label for="order" class="mb-2">Order</Label>
<Input id="order" type="number" bind:value={order} min={0} disabled={loading} />
</div>
<div>
<Label for="estimated" class="mb-2">Estimated Minutes</Label>
<Input
id="estimated"
type="number"
bind:value={estimatedMinutesText}
min={0}
disabled={loading}
/>
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}
>{loading ? 'Creating...' : 'Create Task'}</Button
>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,50 @@
<script lang="ts">
import { Button, Input, Label } from 'flowbite-svelte';
import { CreateAreaTemplateStore } from '$houdini';
let { scopeTemplateId, onadded }: { scopeTemplateId: string; onadded?: () => void } = $props();
let name = $state('');
let order = $state(0);
let loading = $state(false);
let error = $state('');
const createArea = new CreateAreaTemplateStore();
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
try {
const res = await createArea.mutate({ input: { name, order, scopeTemplateId } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else {
name = '';
order = 0;
onadded?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to add area';
} finally {
loading = false;
}
};
</script>
<form class="mb-4 flex flex-wrap items-end gap-3" onsubmit={handleSubmit}>
<div>
<Label for="areaName" class="mb-1">Name</Label>
<Input id="areaName" bind:value={name} required />
</div>
<div>
<Label for="areaOrder" class="mb-1">Order</Label>
<Input id="areaOrder" type="number" bind:value={order} min={0} />
</div>
<Button type="submit" color="primary" disabled={loading}
>{loading ? 'Adding…' : 'Add Area'}</Button
>
{#if error}
<span class="text-xs text-red-600">{error}</span>
{/if}
</form>

View File

@ -0,0 +1,61 @@
<script lang="ts">
import { Input, Label, Button, Checkbox } from 'flowbite-svelte';
import { UpdateScopeTemplateStore } from '$houdini';
let {
id,
name,
description,
isActive,
onupdated
}: { id: string; name: string; description: string; isActive: boolean; onupdated?: () => void } =
$props();
let saving = $state(false);
let error = $state('');
let success = $state(false);
const updateStore = new UpdateScopeTemplateStore();
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
saving = true;
error = '';
success = false;
try {
const res = await updateStore.mutate({ input: { id, name, description, isActive } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else {
success = true;
onupdated?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update template';
} finally {
saving = false;
}
};
</script>
<form class="mb-6 grid gap-4 md:grid-cols-2" onsubmit={handleSubmit}>
<div>
<Label for="name" class="mb-2">Name</Label>
<Input id="name" type="text" bind:value={name} required />
</div>
<div class="md:col-span-2">
<Label for="desc" class="mb-2">Description</Label>
<Input id="desc" type="text" bind:value={description} />
</div>
<div class="flex items-center gap-2">
<Checkbox id="active" bind:checked={isActive} />
<Label for="active">Active</Label>
</div>
<div class="flex items-center gap-2 md:col-span-2">
<Button type="submit" color="primary" disabled={saving}
>{saving ? 'Saving…' : 'Save Changes'}</Button
>
{#if error}<span class="text-sm text-red-600">{error}</span>{/if}
{#if success}<span class="text-sm text-green-600">Saved!</span>{/if}
</div>
</form>

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { Input, Label, Button, Checkbox } from 'flowbite-svelte';
import { CreateScopeTemplateStore } from '$houdini';
import { sleep } from '$lib/utils/date';
import { goto } from '$app/navigation';
import { offCanvas } from '$lib/utils/offCanvas';
let { oncreated }: { oncreated?: () => void } = $props();
let name = $state('');
let description = $state('');
let isActive = $state(true);
let loading = $state(false);
let error = $state('');
let success = $state(false);
const createStore = new CreateScopeTemplateStore();
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
success = false;
try {
const res = await createStore.mutate({ input: { name, description, isActive } });
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else if (res.data?.createScopeTemplate?.id) {
success = true;
await sleep(800);
oncreated?.();
offCanvas.closeRight();
await goto(`/scopes/templates/edit/${res.data.createScopeTemplate.id}`);
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create template';
} finally {
loading = false;
}
};
const handleCancel = (e: Event) => {
e.preventDefault();
offCanvas.closeRight();
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Template created!
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2">
<div>
<Label for="name" class="mb-2">Name</Label>
<Input
id="name"
type="text"
bind:value={name}
required
disabled={loading}
placeholder="Nightly Cleaning Template"
/>
</div>
<div class="md:col-span-2">
<Label for="description" class="mb-2">Description</Label>
<Input
id="description"
type="text"
bind:value={description}
disabled={loading}
placeholder="Template description"
/>
</div>
<div class="flex items-center gap-2">
<Checkbox id="active" bind:checked={isActive} disabled={loading} />
<Label for="active">Active</Label>
</div>
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}
>{loading ? 'Creating…' : 'Create Template'}</Button
>
<Button type="button" color="light" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,89 @@
<script lang="ts">
import { Button, Input, Label, Select } from 'flowbite-svelte';
import { CreateTaskTemplateStore } from '$houdini';
let { areaId, onadded }: { areaId: string; onadded?: () => void } = $props();
let description = $state('');
let checklistDescription = $state('');
let order = $state(0);
let frequency = $state('daily');
let estimatedMinutes = $state<string>('');
let loading = $state(false);
let error = $state('');
const createStore = new CreateTaskTemplateStore();
const frequencyOptions = [
{ value: '', name: 'Select frequency...' },
{ value: 'daily', name: 'Daily' },
{ value: 'weekly', name: 'Weekly' },
{ value: 'monthly', name: 'Monthly' },
{ value: 'quarterly', name: 'Quarterly' },
{ value: 'triannual', name: 'Tri-annual' },
{ value: 'annual', name: 'Annual' },
{ value: 'as_needed', name: 'As Needed' }
];
const submit = async (e: SubmitEvent) => {
e.preventDefault();
loading = true;
error = '';
try {
const res = await createStore.mutate({
input: {
areaTemplateId: areaId,
description,
checklistDescription,
order,
frequency,
estimatedMinutes: estimatedMinutes ? parseInt(estimatedMinutes, 10) : undefined
}
});
if (res.errors?.length) {
error = res.errors.map((e) => e.message).join(', ');
} else {
description = '';
checklistDescription = '';
order = 0;
frequency = 'daily';
estimatedMinutes = '';
onadded?.();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create task';
} finally {
loading = false;
}
};
</script>
<form class="mb-3 flex flex-wrap items-end gap-2" onsubmit={submit}>
<div>
<Label class="mb-1">Description</Label>
<Input bind:value={description} required class="w-64" />
</div>
<div>
<Label class="mb-1">Checklist Description</Label>
<Input bind:value={checklistDescription} class="w-64" placeholder="Optional checklist text" />
</div>
<div>
<Label class="mb-1">Order</Label>
<Input type="number" bind:value={order} class="w-24" />
</div>
<div>
<Label class="mb-1">Frequency</Label>
<Select bind:value={frequency} items={frequencyOptions} class="w-40" />
</div>
<div>
<Label class="mb-1">Minutes</Label>
<Input type="number" bind:value={estimatedMinutes} class="w-24" />
</div>
<Button type="submit" color="primary" size="sm" disabled={loading}
>{loading ? 'Adding…' : 'Add Task'}</Button
>
{#if error}
<span class="text-xs text-red-600">{error}</span>
{/if}
</form>

View File

@ -0,0 +1,172 @@
<script lang="ts">
import { Button, Input, Label, Textarea } from 'flowbite-svelte';
import { GetTeamProfilesStore, UpdateServiceStore } from '$houdini';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { sleep } from '$lib/utils/date';
import { fromGlobalId } from '$lib/utils/relay';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
service: {
id: string;
date?: string;
notes?: string | null;
teamMembers?: { pk: string }[] | null;
};
onSuccess?: () => void;
}
let { service, onSuccess }: Props = $props();
let date = $state(service?.date ?? '');
let notes = $state(service?.notes ?? '');
let profiles = $state(new GetTeamProfilesStore());
let teamMembers = $state<string[]>(
(service?.teamMembers ?? []).map((m: { pk: string }) => String(m.pk))
);
let lastId = $state<string | null>(null);
$effect(() => {
if (!service) return;
if (service.id !== lastId) {
date = service.date ?? '';
notes = service.notes ?? '';
teamMembers = (service.teamMembers ?? []).map((m: { pk: string }) => String(m.pk));
lastId = service.id;
}
});
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
});
$effect(() => {
const servicePks = new Set(
(service?.teamMembers ?? []).map((m: { pk: string }) => String(m.pk))
);
// If we don't have profiles yet, skip
const allProfiles = $profiles.data?.teamProfiles ?? [];
if (!allProfiles.length) return;
teamMembers = allProfiles
.filter((p) => servicePks.has(String(fromGlobalId(p.id))))
.map((p) => String(p.id));
});
let loading = $state(false);
let error = $state('');
let success = $state(false);
const updateStore = new UpdateServiceStore();
function goBack(fallbackHref = '/services') {
if (browser && history.length > 1) {
history.back();
} else {
goto(fallbackHref);
}
}
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
const result = await updateStore.mutate({
input: {
id: service.id,
date: date || null,
notes: (notes ?? '') === '' ? '' : notes,
// Use empty array to clear all team members (null means "don't change")
teamMemberIds: teamMembers
}
});
if (result.errors) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
if (onSuccess) {
onSuccess();
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
if (success) {
await sleep(1500);
if (onSuccess) {
offCanvas.closeRight();
} else {
goBack('/services');
}
}
}
};
const handleCancel = (event: Event) => {
event.preventDefault();
if (onSuccess) {
offCanvas.closeRight();
} else {
goBack('/services');
}
};
</script>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">{error}</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Service updated successfully! Redirecting back...
</div>
{/if}
<div class="mb-6 grid gap-6 md:grid-cols-2">
<div>
<Label for="date" class="mb-2">Date</Label>
<Input id="date" type="date" bind:value={date} disabled={loading} />
</div>
<div class="md:col-span-2">
<Label for="notes" class="mb-2">Notes</Label>
<Textarea id="notes" rows={4} bind:value={notes} disabled={loading} />
</div>
</div>
<!-- Team Members (optional) -->
<div class="mb-6">
<Label class="mb-2">Team Members (optional)</Label>
{#if $profiles.fetching}
<div class="text-sm text-gray-500">Loading team profiles...</div>
{:else if ($profiles.data?.teamProfiles?.length ?? 0) === 0}
<div class="text-sm text-gray-500">No team profiles found.</div>
{:else}
<div class="grid gap-2 md:grid-cols-2">
{#each ($profiles.data?.teamProfiles ?? []).filter((p) => String(p.status) === 'ACTIVE') as p (p.id)}
<label class="flex items-center gap-2">
<input
type="checkbox"
value={String(p.id)}
bind:group={teamMembers}
disabled={loading}
/>
<span>{p.fullName || `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim()}</span>
</label>
{/each}
</div>
{/if}
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{loading ? 'Saving...' : 'Save'}
</Button>
<Button type="button" color="red" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,156 @@
<script lang="ts">
import { CreateServiceStore, GetTeamProfilesStore } from '$houdini';
import { Button, Input, Label } from 'flowbite-svelte';
import { goto } from '$app/navigation';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
account?: { id: string; name: string } | null;
address?: {
id: string;
name?: string;
streetAddress: string;
city: string;
state: string;
zipCode: string;
} | null;
onSuccess?: () => void;
}
let { account, address, onSuccess }: Props = $props();
let createService = $derived(new CreateServiceStore());
let profiles = $state(new GetTeamProfilesStore());
let date = $state('');
let notes = $state('');
let teamMembers = $state<string[]>([]);
let loading = $state(false);
let error = $state('');
let success = $state(false);
$effect(() => {
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
});
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
loading = true;
error = '';
success = false;
try {
const result = await createService.mutate({
input: {
accountAddressId: address?.id ?? '',
date: date,
status: 'SCHEDULED',
teamMemberIds: teamMembers
}
});
if (result.errors) {
error = result.errors.map((e) => e.message).join(', ');
} else {
success = true;
teamMembers = [];
if (onSuccess) {
onSuccess();
offCanvas.closeRight();
} else {
await goto('/services');
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = (event: Event) => {
event.preventDefault();
date = '';
error = '';
success = false;
loading = false;
if (onSuccess) {
offCanvas.closeRight();
} else {
goto('/services');
}
};
</script>
<div class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Schedule service for {account?.name}:
</div>
<div class="mb-4 text-sm text-gray-700 dark:text-gray-300">
<div class="font-medium">Location</div>
<div>{address?.name || address?.streetAddress}</div>
<div>{address?.city}, {address?.state} {address?.zipCode}</div>
</div>
<form class="flex flex-col gap-4" onsubmit={handleSubmit}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">
{error}
</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
Service visit created successfully!
</div>
{/if}
<div class="mb-6 grid gap-6 md:grid-cols-2">
<!-- Date -->
<div>
<Label for="start-date" class="mb-2">Service Date</Label>
<Input id="start-date" type="date" bind:value={date} required disabled={loading} />
</div>
<!-- Notes -->
<div>
<Label for="name" class="mb-2">Notes</Label>
<Input
id="name"
type="text"
placeholder="Anything important..."
bind:value={notes}
disabled={loading}
/>
</div>
</div>
<!-- Team Members (optional) -->
<div class="mb-6">
<Label class="mb-2">Team Members (optional)</Label>
{#if $profiles.fetching}
<div class="text-sm text-gray-500">Loading team profiles...</div>
{:else if ($profiles.data?.teamProfiles?.length ?? 0) === 0}
<div class="text-sm text-gray-500">No team profiles found.</div>
{:else}
<div class="grid gap-2 md:grid-cols-2">
{#each ($profiles.data?.teamProfiles ?? []).filter((p) => String(p.status) === 'ACTIVE') as p (p.id)}
<label class="flex items-center gap-2">
<input
type="checkbox"
value={String(p.id)}
bind:group={teamMembers}
disabled={loading}
/>
<span>{p.fullName || `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim()}</span>
</label>
{/each}
</div>
{/if}
</div>
<Button type="submit" color="primary" class="mb-2" disabled={loading}>
{loading ? 'Creating...' : 'Submit'}
</Button>
<Button type="button" color="red" class="mb-2" onclick={handleCancel}>Cancel</Button>
</form>

View File

@ -0,0 +1,263 @@
<script lang="ts">
import { GetSchedulesStore, GenerateServicesByMonthStore } from '$houdini';
import { goto } from '$app/navigation';
import { fromGlobalId } from '$lib/utils/relay';
import { Spinner, Badge, Button, Label, Input } from 'flowbite-svelte';
import { SvelteDate } from 'svelte/reactivity';
import { offCanvas } from '$lib/utils/offCanvas';
interface Props {
account?: { id: string; name: string } | null;
address?: {
id: string;
name?: string;
streetAddress: string;
city: string;
state: string;
zipCode: string;
} | null;
onSuccess?: () => void;
}
let { account, address, onSuccess }: Props = $props();
let schedules = $derived(new GetSchedulesStore());
let generateServices = $derived(new GenerateServicesByMonthStore());
// Default month/year to current
const now = new SvelteDate();
let month = $state(now.getMonth() + 1); // 1-12
let year = $state(now.getFullYear());
let loading = $state(false);
let error = $state('');
let confirmStep = $state(false);
let selectedScheduleId = $state('');
// Fetch schedules for this address (active schedules will be filtered client-side)
const fetchSchedules = () =>
schedules.fetch({
variables: { filters: { accountAddressId: fromGlobalId(address?.id ?? '') } },
policy: 'NetworkOnly'
});
$effect(() => {
if (!address?.id) return;
loading = true;
error = '';
fetchSchedules()
.then((res) => {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
})
.catch((e) => (error = e instanceof Error ? e.message : 'Failed to load schedules'))
.finally(() => (loading = false));
});
// Active schedules: startDate exists and (no endDate or endDate in the future)
let activeSchedules = $derived(() => {
const list = $schedules.data?.schedules ?? [];
const today = new SvelteDate();
return list.filter((s) => {
const start = new SvelteDate(s.startDate);
const end = s.endDate ? new SvelteDate(s.endDate) : null;
const notEnded = !end || end.getTime() >= today.setHours(0, 0, 0, 0);
return start.getTime() <= today.getTime() && notEnded;
});
});
const validate = () => {
if (!selectedScheduleId) return 'Please select a schedule';
if (!month || month < 1 || month > 12) return 'Please select a valid month (1-12)';
if (!year || year < 1900) return 'Please enter a valid year';
return '';
};
const beginConfirm = (event: Event) => {
event.preventDefault();
error = validate();
if (error) return;
confirmStep = true;
};
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
const v = validate();
if (v) {
error = v;
return;
}
loading = true;
error = '';
try {
// Coerce month/year to numbers in case inputs returned strings
const m = Number(month);
const y = Number(year);
const result = await generateServices.mutate({
input: {
accountAddressId: address?.id ?? '',
scheduleId: selectedScheduleId,
month: m,
year: y
}
});
if (result.errors) {
error = result.errors.map((e) => e.message).join(', ');
} else {
// success
if (onSuccess) {
onSuccess();
offCanvas.closeRight();
} else {
await goto('/services');
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'An unexpected error occurred';
} finally {
loading = false;
}
};
const handleCancel = (event: Event) => {
event.preventDefault();
error = '';
loading = false;
if (onSuccess) {
offCanvas.closeRight();
} else {
goto('/services');
}
};
</script>
<div class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Generate services for {account?.name}:
</div>
<div class="mb-4 text-sm text-gray-700 dark:text-gray-300">
<div class="font-medium">Location</div>
<div>{address?.name || address?.streetAddress}</div>
<div>{address?.city}, {address?.state} {address?.zipCode}</div>
</div>
<form class="flex flex-col gap-4" onsubmit={confirmStep ? handleSubmit : beginConfirm}>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">
{error}
</div>
{/if}
<div>
<Label for="schedule" class="mb-2">Select a schedule:</Label>
{#if $schedules.fetching}
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<Spinner size="4" />
Loading schedules...
</div>
{:else if activeSchedules().length === 0}
<p class="mt-1 text-sm text-gray-500">No active schedules found for this address.</p>
{:else}
<div role="radiogroup" aria-label="Select a schedule" class="space-y-3">
{#each activeSchedules() as s (s.id)}
{@const selected = selectedScheduleId === s.id}
<div
role="radio"
aria-checked={selected}
tabindex="0"
onclick={() => (selectedScheduleId = s.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectedScheduleId = s.id;
}
}}
class={'cursor-pointer rounded border bg-gray-50 p-3 focus:outline-none dark:border-gray-700 dark:bg-gray-900 ' +
(selected ? 'border-blue-500 ring-2 ring-blue-500' : 'border-gray-200')}
>
<div class="mb-2 flex items-start justify-between">
<div class="min-w-0">
<div class="font-medium text-gray-900 dark:text-gray-100">
{s.name || 'Service Schedule'}
</div>
<div class="mt-1 flex flex-wrap items-center gap-1">
<Badge size="small" color={s.sundayService ? 'blue' : 'gray'}>Sun</Badge>
<Badge size="small" color={s.mondayService ? 'blue' : 'gray'}>Mon</Badge>
<Badge size="small" color={s.tuesdayService ? 'blue' : 'gray'}>Tue</Badge>
<Badge size="small" color={s.wednesdayService ? 'blue' : 'gray'}>Wed</Badge>
<Badge size="small" color={s.thursdayService ? 'blue' : 'gray'}>Thu</Badge>
<Badge size="small" color={s.fridayService ? 'blue' : 'gray'}>Fri</Badge>
<Badge size="small" color={s.saturdayService ? 'blue' : 'gray'}>Sat</Badge>
{#if s.weekendService}
<Badge size="small" color="purple">Weekend</Badge>
{/if}
</div>
</div>
{#if selected}
<span
class="rounded border border-blue-300 bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
>Selected</span
>
{/if}
</div>
<div class="text-sm text-gray-700 dark:text-gray-300">
{new SvelteDate(s.startDate).toLocaleDateString()}
- {s.endDate ? new SvelteDate(s.endDate).toLocaleDateString() : 'Present'}
</div>
{#if s.scheduleException}
<div
class="mt-2 border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
{s.scheduleException}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<div class="mb-6 grid gap-6 md:grid-cols-2">
<div>
<Label for="month" class="mb-2">Month</Label>
<Input id="month" type="number" min="1" max="12" bind:value={month} required />
</div>
<div>
<Label for="year" class="mb-2">Year</Label>
<Input id="year" type="number" min="1900" max="3000" bind:value={year} required />
</div>
</div>
{#if confirmStep}
<div class="rounded border bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
<p class="mb-2 text-sm">Please confirm you want to generate services with the following:</p>
<ul class="ml-5 list-disc space-y-1 text-sm">
<li>Account: {account?.name ?? 'N/A'}</li>
<li>Address: {address?.streetAddress ?? 'N/A'}</li>
<li>
Schedule: {activeSchedules().find((s) => s.id === selectedScheduleId)?.name ||
'Service Schedule'}
</li>
<li>Month/Year: {month}/{year}</li>
</ul>
</div>
{/if}
<Button color="primary" type="submit" class="mb-2" disabled={loading || $schedules.fetching}>
{#if loading}
<Spinner size="4" />
{/if}
{confirmStep ? 'Confirm and Generate' : 'Continue'}
</Button>
<Button
color="red"
type="button"
class="mb-2"
onclick={confirmStep ? () => (confirmStep = false) : handleCancel}
>
{confirmStep ? 'Back' : 'Cancel'}
</Button>
</form>

View File

@ -0,0 +1,102 @@
<script lang="ts">
import { Button, Badge, Spinner } from 'flowbite-svelte';
import { goto } from '$app/navigation';
import { UpdateProjectScopeTemplateStore } from '$houdini';
type Template = {
id: string;
name: string;
description: string;
isActive: boolean;
categoryTemplates?: { id: string; name: string; order: number }[];
};
let { template, onUpdated }: { template: Template; onUpdated?: () => void } = $props();
let expanded = $state(false);
let toggling = $state(false);
let toggleError = $state('');
const updateStore = new UpdateProjectScopeTemplateStore();
const toggleActive = async () => {
toggling = true;
toggleError = '';
try {
const res = await updateStore.mutate({
input: {
id: template.id,
isActive: !template.isActive
}
});
if (res.errors?.length) {
toggleError = res.errors.map((e) => e.message).join(', ');
} else {
template.isActive = !template.isActive;
onUpdated?.();
}
} catch (err) {
toggleError = err instanceof Error ? err.message : 'Failed to toggle status';
} finally {
toggling = false;
}
};
</script>
<li
class="rounded-xl border border-gray-200 bg-white shadow-sm transition hover:shadow dark:border-gray-700 dark:bg-gray-800"
>
<div class="p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{template.name}
</h2>
{#if template.isActive}
<Badge color="green">Active</Badge>
{:else}
<Badge color="gray">Inactive</Badge>
{/if}
{#if toggling}
<Spinner size="4" />
{/if}
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">{template.description}</p>
{#if template.categoryTemplates?.length}
<p class="mt-1 text-xs text-gray-500">{template.categoryTemplates.length} categories</p>
{/if}
{#if toggleError}
<p class="mt-1 text-xs text-red-600">{toggleError}</p>
{/if}
</div>
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
<Button color="light" size="sm" onclick={toggleActive} disabled={toggling}>
{template.isActive ? 'Deactivate' : 'Activate'}
</Button>
<Button
color="primary"
size="sm"
onclick={() => goto(`/project-scopes/templates/${template.id}`)}
>
View
</Button>
</div>
</div>
</div>
{#if expanded && template.categoryTemplates?.length}
<div
class="border-t border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40"
>
<h4 class="mb-2 text-sm font-medium text-gray-900 dark:text-gray-100">Categories:</h4>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each template.categoryTemplates as category (category.id)}
<div class="rounded bg-white p-2 text-sm dark:bg-gray-800">
<span class="font-medium">{category.order}</span> - {category.name}
</div>
{/each}
</div>
</div>
{/if}
</li>

View File

@ -0,0 +1,163 @@
<script lang="ts">
import { Input, Button, Alert, Label } from 'flowbite-svelte';
import {
GetProjectTaskTemplatesStore,
UpdateProjectTaskTemplateStore,
DeleteProjectTaskTemplateStore
} from '$houdini';
import ProjectTaskTemplateForm from '$lib/components/forms/projectScopes/templates/ProjectTaskTemplateForm.svelte';
import { fromGlobalId } from '$lib/utils/relay';
let { areaId, onchanged }: { areaId: string; onchanged?: () => void } = $props();
let tasks = $derived(new GetProjectTaskTemplatesStore());
let loading = $state(false);
let error = $state('');
let opError = $state('');
const updateTask = new UpdateProjectTaskTemplateStore();
const deleteTask = new DeleteProjectTaskTemplateStore();
const load = async () => {
loading = true;
error = '';
try {
await tasks.fetch({
variables: { areaTemplateId: fromGlobalId(areaId) },
policy: 'NetworkOnly'
});
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load tasks';
} finally {
loading = false;
}
};
$effect(() => {
load();
});
const onTaskDescChange = (taskId: string, e: Event) =>
handleUpdateTask(taskId, { description: (e.target as HTMLInputElement).value });
const onTaskChecklistChange = (taskId: string, e: Event) =>
handleUpdateTask(taskId, { checklistDescription: (e.target as HTMLInputElement).value });
const onTaskOrderChange = (taskId: string, e: Event) => {
const v = (e.target as HTMLInputElement).value;
const num = parseInt(v);
return handleUpdateTask(taskId, { order: isNaN(num) ? 0 : num });
};
const onTaskMinsChange = (taskId: string, e: Event) => {
const v = (e.target as HTMLInputElement).value;
const num = parseInt(v);
return handleUpdateTask(taskId, { estimatedMinutes: v ? (isNaN(num) ? null : num) : null });
};
const handleUpdateTask = async (
taskId: string,
fields: {
description?: string;
checklistDescription?: string;
order?: number;
estimatedMinutes?: number | null;
}
) => {
opError = '';
try {
const res = await updateTask.mutate({
input: {
id: taskId,
description: fields.description,
checklistDescription: fields.checklistDescription,
order: fields.order,
estimatedMinutes: fields.estimatedMinutes ?? undefined
}
});
if (res.errors?.length) {
opError = res.errors.map((e) => e.message).join(', ');
} else {
await load();
onchanged?.();
}
} catch (err) {
opError = err instanceof Error ? err.message : 'Failed to update task';
}
};
const handleDeleteTask = async (taskId: string) => {
opError = '';
try {
const res = await deleteTask.mutate({ id: taskId });
if (res.errors?.length) {
opError = res.errors.map((e) => e.message).join(', ');
} else {
await load();
onchanged?.();
}
} catch (err) {
opError = err instanceof Error ? err.message : 'Failed to delete task';
}
};
</script>
<div class="mt-3 rounded border border-gray-200 p-3 dark:border-gray-700">
<h3 class="mb-2 font-medium">Tasks</h3>
<ProjectTaskTemplateForm {areaId} onadded={load} />
{#if opError}
<Alert color="red" class="mb-2">{opError}</Alert>
{/if}
{#if loading}
<div class="text-sm text-gray-500">Loading tasks…</div>
{:else if error}
<Alert color="red">{error}</Alert>
{:else if $tasks.data?.getProjectTaskTemplates?.edges?.length}
<ul class="space-y-2">
{#each $tasks.data.getProjectTaskTemplates.edges as tEdge (tEdge.node.id)}
{#if tEdge?.node}
<li
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40"
>
<div class="space-y-2">
<div>
<Label for="${tEdge.node.id}-description">Description</Label>
<Input
id="${tEdge.node.id}-description"
value={tEdge.node.description}
onchange={(e) => onTaskDescChange(tEdge.node.id, e)}
/>
</div>
<div>
<Label for="${tEdge.node.id}-checklist-description">Checklist Description</Label>
<Input
id="${tEdge.node.id}-checklist-description"
placeholder="Checklist Description"
value={tEdge.node.checklistDescription ?? ''}
onchange={(e) => onTaskChecklistChange(tEdge.node.id, e)}
/>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-4">
<Input
class="w-24"
type="number"
value={tEdge.node.order}
onchange={(e) => onTaskOrderChange(tEdge.node.id, e)}
/>
<Input
class="w-28"
type="number"
placeholder="Minutes"
value={tEdge.node.estimatedMinutes ?? ''}
onchange={(e) => onTaskMinsChange(tEdge.node.id, e)}
/>
<div class="ml-auto">
<Button size="xs" color="red" onclick={() => handleDeleteTask(tEdge.node.id)}
>Delete</Button
>
</div>
</div>
</li>
{/if}
{/each}
</ul>
{:else}
<p class="text-sm text-gray-500">No tasks for this category.</p>
{/if}
</div>

View File

@ -0,0 +1,55 @@
<script lang="ts">
import { GetProjectTaskTemplatesStore } from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
let { areaId }: { areaId: string } = $props();
let tasks = $derived(new GetProjectTaskTemplatesStore());
let loading = $state(false);
let error = $state('');
const load = async () => {
loading = true;
error = '';
try {
await tasks.fetch({
variables: { areaTemplateId: fromGlobalId(areaId) },
policy: 'NetworkOnly'
});
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load tasks';
} finally {
loading = false;
}
};
$effect(() => {
load();
});
</script>
{#if loading}
<div class="text-sm text-gray-500">Loading tasks…</div>
{:else if error}
<div class="text-sm text-red-600">{error}</div>
{:else if $tasks.data?.getProjectTaskTemplates?.edges?.length}
<ul class="mt-2 space-y-2">
{#each $tasks.data.getProjectTaskTemplates.edges as tEdge (tEdge.node.id)}
{#if tEdge?.node}
<li
class="rounded border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-800"
>
<div class="text-sm text-gray-800 dark:text-gray-200">
<span class="font-medium">{tEdge.node.order} - {tEdge.node.description}</span>
{#if tEdge.node.estimatedMinutes}
<span class="ml-2 text-[11px]">{tEdge.node.estimatedMinutes} min</span>
{/if}
{#if tEdge.node.checklistDescription}
<span class="ml-2 text-[11px]">{tEdge.node.checklistDescription}</span>
{/if}
</div>
</li>
{/if}
{/each}
</ul>
{:else}
<p class="mt-1 text-sm text-gray-500">No tasks.</p>
{/if}

View File

@ -0,0 +1,98 @@
<script lang="ts">
import { Button, Badge, Spinner } from 'flowbite-svelte';
import { goto } from '$app/navigation';
import { UpdateScopeTemplateStore } from '$houdini';
type Template = {
id: string;
name: string;
description: string;
isActive: boolean;
areaTemplates?: { id: string; name: string; order: number }[];
};
let { template, onUpdated }: { template: Template; onUpdated?: () => void } = $props();
let expanded = $state(false);
let toggling = $state(false);
let toggleError = $state('');
const updateStore = new UpdateScopeTemplateStore();
const toggleActive = async () => {
toggling = true;
toggleError = '';
try {
const res = await updateStore.mutate({
input: {
id: template.id,
isActive: !template.isActive
}
});
if (res.errors?.length) {
toggleError = res.errors.map((e) => e.message).join(', ');
} else {
template.isActive = !template.isActive;
onUpdated?.();
}
} catch (err) {
toggleError = err instanceof Error ? err.message : 'Failed to toggle status';
} finally {
toggling = false;
}
};
</script>
<li
class="rounded-xl border border-gray-200 bg-white shadow-sm transition hover:shadow dark:border-gray-700 dark:bg-gray-800"
>
<div class="p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{template.name}
</h2>
{#if template.isActive}
<Badge color="green">Active</Badge>
{:else}
<Badge color="gray">Inactive</Badge>
{/if}
{#if toggling}
<Spinner size="4" />
{/if}
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">{template.description}</p>
{#if template.areaTemplates?.length}
<p class="mt-1 text-xs text-gray-500">{template.areaTemplates.length} areas</p>
{/if}
{#if toggleError}
<p class="mt-1 text-xs text-red-600">{toggleError}</p>
{/if}
</div>
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
<Button color="light" size="sm" onclick={toggleActive} disabled={toggling}>
{template.isActive ? 'Deactivate' : 'Activate'}
</Button>
<Button color="primary" size="sm" onclick={() => goto(`/scopes/templates/${template.id}`)}>
View
</Button>
</div>
</div>
</div>
{#if expanded && template.areaTemplates?.length}
<div
class="border-t border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40"
>
<h4 class="mb-2 text-sm font-medium text-gray-900 dark:text-gray-100">Areas:</h4>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each template.areaTemplates as area (area.id)}
<div class="rounded bg-white p-2 text-sm dark:bg-gray-800">
<span class="font-medium">{area.order}</span> - {area.name}
</div>
{/each}
</div>
</div>
{/if}
</li>

View File

@ -0,0 +1,580 @@
<script lang="ts">
import { Button, Spinner, Alert } from 'flowbite-svelte';
import {
GetServiceSessionImagesStore,
GetProjectSessionImagesStore,
DeleteServiceSessionImageStore,
DeleteProjectSessionImageStore,
GetServiceSessionVideosStore,
GetProjectSessionVideosStore,
DeleteServiceSessionVideoStore,
DeleteProjectSessionVideoStore
} from '$houdini';
import type {
GetServiceSessionImages$result,
GetProjectSessionImages$result,
GetServiceSessionVideos$result,
GetProjectSessionVideos$result
} from '$houdini';
import { fromGlobalId } from '$lib/utils/relay';
import {
uploadServiceSessionPhoto,
uploadProjectSessionPhoto,
uploadServiceSessionVideo,
uploadProjectSessionVideo
} from '$lib/media';
import MediaUploadZone from './MediaUploadZone.svelte';
import PhotoGalleryItem from './PhotoGalleryItem.svelte';
import VideoGalleryItem from './VideoGalleryItem.svelte';
let {
sessionId,
sessionType
}: {
sessionId: string;
sessionType: 'service' | 'project';
} = $props();
type MediaTab = 'photos' | 'videos';
type StagedPhoto = {
file: File;
preview: string;
title: string;
notes: string;
internal: boolean;
};
type StagedVideo = {
file: File;
preview: string;
title: string;
notes: string;
internal: boolean;
};
let activeMediaTab = $state<MediaTab>('photos');
let stagedPhotos = $state<StagedPhoto[]>([]);
let stagedVideos = $state<StagedVideo[]>([]);
let uploading = $state(false);
let error = $state('');
// Service stores
const servicePhotosStore = new GetServiceSessionImagesStore();
const servicePhotoDeleter = new DeleteServiceSessionImageStore();
const serviceVideosStore = new GetServiceSessionVideosStore();
const serviceVideoDeleter = new DeleteServiceSessionVideoStore();
// Project stores
const projectPhotosStore = new GetProjectSessionImagesStore();
const projectPhotoDeleter = new DeleteProjectSessionImageStore();
const projectVideosStore = new GetProjectSessionVideosStore();
const projectVideoDeleter = new DeleteProjectSessionVideoStore();
// Derived store references based on session type
const photosStore = $derived(sessionType === 'service' ? servicePhotosStore : projectPhotosStore);
const photoDeleter = $derived(
sessionType === 'service' ? servicePhotoDeleter : projectPhotoDeleter
);
const videoDeleter = $derived(
sessionType === 'service' ? serviceVideoDeleter : projectVideoDeleter
);
// Fetch photos and videos on mount and when session changes
$effect(() => {
const rawId = fromGlobalId(sessionId) || sessionId;
if (sessionType === 'service') {
servicePhotosStore.fetch({
variables: { filters: { serviceSessionId: rawId } },
policy: 'NetworkOnly'
});
serviceVideosStore.fetch({
variables: { filters: { serviceSessionId: rawId } },
policy: 'NetworkOnly'
});
} else {
projectPhotosStore.fetch({
variables: { filters: { projectSessionId: rawId } },
policy: 'NetworkOnly'
});
projectVideosStore.fetch({
variables: { filters: { projectSessionId: rawId } },
policy: 'NetworkOnly'
});
}
});
function onPhotosSelected(files: FileList) {
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
stagedPhotos.push({
file,
preview: URL.createObjectURL(file),
title: file.name,
notes: '',
internal: true
});
}
stagedPhotos = [...stagedPhotos];
}
function onVideosSelected(files: FileList) {
for (const file of files) {
if (!file.type.startsWith('video/')) continue;
stagedVideos.push({
file,
preview: URL.createObjectURL(file),
title: file.name,
notes: '',
internal: true
});
}
stagedVideos = [...stagedVideos];
}
function removePhoto(index: number) {
URL.revokeObjectURL(stagedPhotos[index].preview);
stagedPhotos.splice(index, 1);
stagedPhotos = [...stagedPhotos];
}
function removeVideo(index: number) {
URL.revokeObjectURL(stagedVideos[index].preview);
stagedVideos.splice(index, 1);
stagedVideos = [...stagedVideos];
}
function clearAllPhotos() {
stagedPhotos.forEach((p) => URL.revokeObjectURL(p.preview));
stagedPhotos = [];
}
function clearAllVideos() {
stagedVideos.forEach((v) => URL.revokeObjectURL(v.preview));
stagedVideos = [];
}
async function uploadPhotos() {
if (!stagedPhotos.length) return;
if (!confirm(`Upload ${stagedPhotos.length} photo(s)?`)) return;
uploading = true;
error = '';
const failures: string[] = [];
const uploadFn =
sessionType === 'service' ? uploadServiceSessionPhoto : uploadProjectSessionPhoto;
try {
// Upload all photos in parallel for better performance
const uploadPromises = stagedPhotos.map(async (item) => {
try {
await uploadFn({
file: item.file,
sessionId: sessionId,
title: item.title,
notes: item.notes,
internal: item.internal
});
return { success: true, title: item.title };
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
return { success: false, title: item.title, error: message };
}
});
const results = await Promise.all(uploadPromises);
// Collect failures
for (const result of results) {
if (!result.success) {
failures.push(`${result.title}: ${result.error}`);
}
}
// Refresh photos
const rawId = fromGlobalId(sessionId) || sessionId;
if (sessionType === 'service') {
await servicePhotosStore.fetch({
variables: { filters: { serviceSessionId: rawId } },
policy: 'NetworkOnly'
});
} else {
await projectPhotosStore.fetch({
variables: { filters: { projectSessionId: rawId } },
policy: 'NetworkOnly'
});
}
if (failures.length) {
error = `Some uploads failed:\n- ${failures.join('\n- ')}`;
} else {
clearAllPhotos();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to upload photos';
} finally {
uploading = false;
}
}
async function uploadVideos() {
if (!stagedVideos.length) return;
if (!confirm(`Upload ${stagedVideos.length} video(s)?`)) return;
uploading = true;
error = '';
const failures: string[] = [];
const uploadFn =
sessionType === 'service' ? uploadServiceSessionVideo : uploadProjectSessionVideo;
try {
// Upload all videos in parallel for better performance
const uploadPromises = stagedVideos.map(async (item) => {
try {
await uploadFn({
file: item.file,
sessionId: sessionId,
title: item.title,
notes: item.notes,
internal: item.internal
});
return { success: true, title: item.title };
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
return { success: false, title: item.title, error: message };
}
});
const results = await Promise.all(uploadPromises);
// Collect failures
for (const result of results) {
if (!result.success) {
failures.push(`${result.title}: ${result.error}`);
}
}
// Refresh videos
const rawId = fromGlobalId(sessionId) || sessionId;
if (sessionType === 'service') {
await serviceVideosStore.fetch({
variables: { filters: { serviceSessionId: rawId } },
policy: 'NetworkOnly'
});
} else {
await projectVideosStore.fetch({
variables: { filters: { projectSessionId: rawId } },
policy: 'NetworkOnly'
});
}
if (failures.length) {
error = `Some uploads failed:\n- ${failures.join('\n- ')}`;
} else {
clearAllVideos();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to upload videos';
} finally {
uploading = false;
}
}
async function deletePhoto(id: string) {
if (!confirm('Delete this photo? This cannot be undone.')) return;
error = '';
try {
const res = await photoDeleter.mutate({ id });
if (res?.errors?.length) {
error = (res.errors as { message: string }[]).map((e) => e.message).join(', ');
return;
}
// Refresh photos
const rawId = fromGlobalId(sessionId) || sessionId;
if (sessionType === 'service') {
await servicePhotosStore.fetch({
variables: { filters: { serviceSessionId: rawId } },
policy: 'NetworkOnly'
});
} else {
await projectPhotosStore.fetch({
variables: { filters: { projectSessionId: rawId } },
policy: 'NetworkOnly'
});
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete photo';
}
}
async function deleteVideo(id: string) {
if (!confirm('Delete this video? This cannot be undone.')) return;
error = '';
try {
const res = await videoDeleter.mutate({ id });
if (res?.errors?.length) {
error = (res.errors as { message: string }[]).map((e) => e.message).join(', ');
return;
}
// Refresh videos
const rawId = fromGlobalId(sessionId) || sessionId;
if (sessionType === 'service') {
await serviceVideosStore.fetch({
variables: { filters: { serviceSessionId: rawId } },
policy: 'NetworkOnly'
});
} else {
await projectVideosStore.fetch({
variables: { filters: { projectSessionId: rawId } },
policy: 'NetworkOnly'
});
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete video';
}
}
const photos = $derived(
sessionType === 'service'
? ($servicePhotosStore.data as GetServiceSessionImages$result)?.serviceSessionImages || []
: ($projectPhotosStore.data as GetProjectSessionImages$result)?.projectSessionImages || []
);
const videos = $derived(
sessionType === 'service'
? ($serviceVideosStore.data as GetServiceSessionVideos$result)?.serviceSessionVideos || []
: ($projectVideosStore.data as GetProjectSessionVideos$result)?.projectSessionVideos || []
);
const mediaTabs: { id: MediaTab; label: string }[] = [
{ id: 'photos', label: 'Photos' },
{ id: 'videos', label: 'Videos' }
];
</script>
<div class="space-y-4">
{#if error}
<Alert color="red" class="whitespace-pre-line">{error}</Alert>
{/if}
<!-- Custom tabs -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8" aria-label="Media tabs">
{#each mediaTabs as tab (tab.id)}
<button
type="button"
class="border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap transition-colors {activeMediaTab ===
tab.id
? 'border-primary-600 text-primary-600 dark:border-primary-500 dark:text-primary-500'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
onclick={() => (activeMediaTab = tab.id)}
>
{tab.label}
</button>
{/each}
</nav>
</div>
<!-- Photos Tab -->
{#if activeMediaTab === 'photos'}
<div class="space-y-6">
<!-- Upload Zone -->
<div>
<h3 class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">Upload Photos</h3>
<MediaUploadZone mediaType="photo" onFilesSelected={onPhotosSelected} />
</div>
<!-- Staged Photos -->
{#if stagedPhotos.length}
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Staged Photos ({stagedPhotos.length})
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Review and confirm to upload</p>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each stagedPhotos as photo, i (photo.preview)}
<div
class="rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-600 dark:bg-gray-700"
>
<img
src={photo.preview}
alt={photo.title}
class="mb-3 aspect-square w-full rounded-md object-cover"
/>
<label
for="photo-title"
class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>
Title (edit to rename)
</label>
<input
name="photo-title"
class="focus:border-primary-500 focus:ring-primary-500 mb-2 w-full rounded-md border border-gray-300 px-3 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
bind:value={photo.title}
placeholder="Photo title"
/>
<textarea
class="focus:border-primary-500 focus:ring-primary-500 mb-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
bind:value={photo.notes}
placeholder="Notes (optional)"
rows="2"
></textarea>
<label
class="mb-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
type="checkbox"
bind:checked={photo.internal}
class="focus:ring-primary-500 text-primary-600 h-4 w-4 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700"
/>
<span>Internal only</span>
</label>
<button
class="text-xs text-red-600 hover:text-red-800 hover:underline dark:text-red-400 dark:hover:text-red-300"
onclick={() => removePhoto(i)}>Remove</button
>
</div>
{/each}
</div>
<div class="mt-4 flex gap-3">
<Button color="primary" onclick={uploadPhotos} disabled={uploading}>
{#if uploading}
<Spinner size="4" class="mr-2" />
{/if}
Confirm Upload
</Button>
<Button color="light" onclick={clearAllPhotos} disabled={uploading}>Clear All</Button>
</div>
</div>
{/if}
<!-- Uploaded Photos Gallery -->
<div>
<h3 class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">Uploaded Photos</h3>
{#if $photosStore.fetching}
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Spinner />
Loading photos...
</div>
{:else if photos.length}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each photos as photo (photo.id)}
<PhotoGalleryItem {photo} onDelete={deletePhoto} />
{/each}
</div>
{:else}
<div
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center dark:border-gray-600"
>
<p class="text-gray-600 dark:text-gray-400">No photos uploaded yet.</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- Videos Tab -->
{#if activeMediaTab === 'videos'}
<div class="space-y-6">
<!-- Upload Zone -->
<div>
<h3 class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">Upload Videos</h3>
<MediaUploadZone mediaType="video" onFilesSelected={onVideosSelected} />
</div>
<!-- Staged Videos -->
{#if stagedVideos.length}
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Staged Videos ({stagedVideos.length})
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Review and confirm to upload</p>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{#each stagedVideos as video, i (video.preview)}
<div
class="rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-600 dark:bg-gray-700"
>
<!-- svelte-ignore a11y_media_has_caption -->
<video
src={video.preview}
class="mb-3 aspect-video w-full rounded-md bg-gray-900 object-cover"
controls
>
Your browser does not support the video tag.
</video>
<label
for="video-title"
class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>
Title (edit to rename)
</label>
<input
name="video-title"
class="focus:border-primary-500 focus:ring-primary-500 mb-2 w-full rounded-md border border-gray-300 px-3 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
bind:value={video.title}
placeholder="Video title"
/>
<textarea
class="focus:border-primary-500 focus:ring-primary-500 mb-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
bind:value={video.notes}
placeholder="Notes (optional)"
rows="2"
></textarea>
<label
class="mb-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
type="checkbox"
bind:checked={video.internal}
class="focus:ring-primary-500 text-primary-600 h-4 w-4 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700"
/>
<span>Internal only</span>
</label>
<button
class="text-xs text-red-600 hover:text-red-800 hover:underline dark:text-red-400 dark:hover:text-red-300"
onclick={() => removeVideo(i)}>Remove</button
>
</div>
{/each}
</div>
<div class="mt-4 flex gap-3">
<Button color="primary" onclick={uploadVideos} disabled={uploading}>
{#if uploading}
<Spinner size="4" class="mr-2" />
{/if}
Confirm Upload
</Button>
<Button color="light" onclick={clearAllVideos} disabled={uploading}>Clear All</Button>
</div>
</div>
{/if}
<!-- Uploaded Videos Gallery -->
<div>
<h3 class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">Uploaded Videos</h3>
{#if videos.length}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{#each videos as video (video.id)}
<VideoGalleryItem {video} onDelete={deleteVideo} />
{/each}
</div>
{:else}
<div
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center dark:border-gray-600"
>
<p class="text-gray-600 dark:text-gray-400">No videos uploaded yet.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-500">
Upload videos above to get started.
</p>
</div>
{/if}
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,100 @@
<script lang="ts">
import { CloudArrowUpOutline } from 'flowbite-svelte-icons';
let {
mediaType = 'photo',
multiple = true,
onFilesSelected
}: {
mediaType?: 'photo' | 'video';
multiple?: boolean;
onFilesSelected: (files: FileList) => void;
} = $props();
let isDragging = $state(false);
let fileInput: HTMLInputElement;
const acceptTypes = mediaType === 'photo' ? 'image/*' : 'video/*';
const maxSize = mediaType === 'photo' ? '10MB' : '100MB';
const formats = mediaType === 'photo' ? 'JPG, PNG, GIF, WEBP' : 'MP4, MOV, WEBM';
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
validateAndEmit(files);
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
isDragging = false;
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
validateAndEmit(input.files);
}
}
function validateAndEmit(files: FileList) {
const validFiles: File[] = [];
const typePrefix = mediaType === 'photo' ? 'image/' : 'video/';
for (const file of files) {
if (file.type.startsWith(typePrefix)) {
validFiles.push(file);
}
}
if (validFiles.length > 0) {
const dt = new DataTransfer();
validFiles.forEach((f) => dt.items.add(f));
onFilesSelected(dt.files);
}
}
function openFileDialog() {
fileInput?.click();
}
</script>
<div
class="relative rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center transition-colors dark:border-gray-600 dark:bg-gray-800 {isDragging
? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20'
: 'hover:border-gray-400 dark:hover:border-gray-500'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
role="button"
tabindex="0"
onclick={openFileDialog}
onkeydown={(e) => e.key === 'Enter' && openFileDialog()}
>
<input
bind:this={fileInput}
type="file"
accept={acceptTypes}
{multiple}
onchange={handleFileSelect}
class="hidden"
/>
<CloudArrowUpOutline class="mx-auto mb-4 h-12 w-12 text-gray-400 dark:text-gray-500" />
<p class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{isDragging ? `Drop ${mediaType}s here` : `Drag & drop ${mediaType}s or click to browse`}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Max size: {maxSize} • Formats: {formats}
</p>
</div>

View File

@ -0,0 +1,177 @@
<script lang="ts">
import AuthenticatedImage from '$lib/components/AuthenticatedImage.svelte';
import { TrashBinOutline } from 'flowbite-svelte-icons';
import { getImageSrc } from '$lib/media';
import { Button, Modal } from 'flowbite-svelte';
import { ExclamationCircleOutline } from 'flowbite-svelte-icons';
import { slide } from 'svelte/transition';
let {
photo,
onDelete
}: {
photo: {
id: string;
title: string;
notes: string;
image: { url: string };
thumbnail?: { url: string } | null;
};
onDelete: (id: string) => void;
} = $props();
let showDeleteModal = $state(false);
let deleting = $state(false);
async function handleDelete() {
deleting = true;
try {
onDelete(photo.id);
showDeleteModal = false;
} catch (error) {
console.error('Failed to delete photo:', error);
} finally {
deleting = false;
}
}
async function handleImageClick() {
console.log('Attempting to open image:', photo.image.url);
// Open a blank window immediately (synchronously) to avoid popup blockers
const newWindow = window.open('', '_blank');
if (!newWindow) {
console.error('Popup blocked - please allow popups for this site');
return;
}
// Create HTML structure using DOM methods (modern approach)
const doc = newWindow.document;
doc.documentElement.lang = 'en-US';
doc.documentElement.className = 'dark';
// Set title
doc.title = photo.title;
// Create and append style element
const style = doc.createElement('style');
style.textContent = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
color: #fff;
font-family: sans-serif;
}
#loading {
font-size: 18px;
}
img {
max-width: 100%;
max-height: 100vh;
object-fit: contain;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
display: none;
}
img.loaded {
display: block;
}
#error {
color: #ff4444;
display: none;
}
`;
doc.head.appendChild(style);
// Create body elements
const loading = doc.createElement('div');
loading.id = 'loading';
loading.textContent = 'Loading image...';
const errorDiv = doc.createElement('div');
errorDiv.id = 'error';
const img = doc.createElement('img');
img.id = 'image';
img.alt = photo.title;
doc.body.appendChild(loading);
doc.body.appendChild(errorDiv);
doc.body.appendChild(img);
try {
// Fetch the image blob
const blobUrl = await getImageSrc(photo.image.url);
console.log('Got blob URL:', blobUrl);
// Update the image and hide loading
img.src = blobUrl;
img.onload = () => {
loading.style.display = 'none';
img.classList.add('loaded');
};
console.log('Image opened successfully');
} catch (error) {
console.error('Failed to open image:', error);
loading.style.display = 'none';
errorDiv.style.display = 'block';
errorDiv.textContent = `Error loading image: ${error}`;
}
}
</script>
<div
class="flex flex-col rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-600 dark:bg-gray-800"
>
<!-- Image -->
<button
type="button"
onclick={handleImageClick}
class="cursor-pointer overflow-hidden rounded-t-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<AuthenticatedImage
path={photo.thumbnail?.url || photo.image?.url}
fullPath={photo.image?.url}
alt={photo.title}
clazz="aspect-square w-full object-cover transition-transform hover:scale-105"
/>
</button>
<!-- Info and Delete Button -->
<div class="flex flex-col gap-2 p-3">
<div class="min-h-0 flex-1">
<h4 class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{photo.title}</h4>
{#if photo.notes}
<p class="mt-1 line-clamp-2 text-xs text-gray-600 dark:text-gray-400">{photo.notes}</p>
{/if}
</div>
<Button size="xs" color="red" class="w-full" onclick={() => (showDeleteModal = true)}>
<TrashBinOutline class="mr-1 h-3 w-3" />
Delete
</Button>
</div>
</div>
<!-- Delete Confirmation Modal -->
<Modal bind:open={showDeleteModal} size="xs" transition={slide} autoclose={false}>
<div class="text-center">
<ExclamationCircleOutline class="mx-auto mb-4 h-12 w-12 text-gray-400 dark:text-gray-200" />
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
Are you sure you want to delete "{photo.title}"?
</h3>
<p class="mb-5 text-sm text-gray-400 dark:text-gray-500">This action cannot be undone.</p>
<div class="flex justify-center gap-4">
<Button color="red" disabled={deleting} onclick={handleDelete}>
{deleting ? 'Deleting...' : 'Yes, delete'}
</Button>
<Button color="alternative" disabled={deleting} onclick={() => (showDeleteModal = false)}>
Cancel
</Button>
</div>
</div>
</Modal>

View File

@ -0,0 +1,92 @@
<script lang="ts">
import AuthenticatedVideo from '$lib/components/AuthenticatedVideo.svelte';
import { PlayOutline, TrashBinOutline } from 'flowbite-svelte-icons';
let {
video,
onDelete
}: {
video: {
id: string;
title: string;
notes?: string | null;
durationSeconds: number;
fileSizeBytes: number;
video: { url: string } | null;
thumbnail?: { url: string } | null;
};
onDelete: (id: string) => void;
} = $props();
let showVideo = $state(false);
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatFileSize(bytes: number): string {
const mb = bytes / (1024 * 1024);
return mb < 1 ? `${(bytes / 1024).toFixed(0)}KB` : `${mb.toFixed(1)}MB`;
}
</script>
<div
class="group relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm transition-all hover:shadow-md dark:border-gray-600 dark:bg-gray-700"
>
{#if showVideo && video.video}
<div class="aspect-video w-full">
<AuthenticatedVideo
path={video.video.url}
thumbnailPath={video.thumbnail?.url}
alt={video.title}
clazz="w-full h-full rounded-t-lg"
/>
</div>
{:else}
<button
class="relative aspect-video w-full overflow-hidden bg-gray-900"
onclick={() => (showVideo = true)}
>
{#if video.thumbnail?.url}
<img src={video.thumbnail.url} alt={video.title} class="h-full w-full object-cover" />
{/if}
<span
class="absolute inset-0 flex items-center justify-center bg-black/30 transition-opacity group-hover:bg-black/50"
>
<span
class="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition-transform group-hover:scale-110"
>
<PlayOutline class="ml-1 h-8 w-8 text-gray-800" />
</span>
</span>
<span
class="absolute right-2 bottom-2 rounded bg-black/75 px-2 py-1 text-xs font-medium text-white"
>
{formatDuration(video.durationSeconds)}
</span>
</button>
{/if}
<div class="p-3">
<h4 class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{video.title}
</h4>
{#if video.notes}
<p class="mt-1 line-clamp-2 text-xs text-gray-600 dark:text-gray-400">
{video.notes}
</p>
{/if}
<div class="mt-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{formatFileSize(video.fileSizeBytes)}</span>
<button
class="flex items-center gap-1 text-red-600 transition hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
onclick={() => onDelete(video.id)}
>
<TrashBinOutline class="h-4 w-4" />
Delete
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
<script lang="ts">
import { GetUnreadMessageCountStore } from '$houdini';
import { EnvelopeSolid } from 'flowbite-svelte-icons';
interface Props {
onclick?: () => void;
}
let { onclick }: Props = $props();
const unreadCountStore = new GetUnreadMessageCountStore();
// Fetch unread count on mount and poll every 30 seconds
$effect(() => {
unreadCountStore.fetch({ policy: 'NetworkOnly' }).catch(() => {});
const interval = setInterval(() => {
unreadCountStore.fetch({ policy: 'NetworkOnly' }).catch(() => {});
}, 30000);
return () => clearInterval(interval);
});
let unreadCount = $derived($unreadCountStore.data?.unreadMessageCount ?? 0);
</script>
<button
type="button"
{onclick}
class="relative inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 focus:ring-2 focus:ring-gray-200 focus:outline-none dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-label="Messages"
>
<EnvelopeSolid class="h-5 w-5" />
{#if unreadCount > 0}
<span
class="absolute -top-1 -right-1 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-red-600 px-1.5 text-[10px] leading-none font-semibold text-white shadow ring-1 ring-white/80 dark:ring-gray-900/60"
title="{unreadCount} unread messages"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
{/if}
</button>

View File

@ -0,0 +1,297 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { formatDate } from '$lib/utils/date';
import { fromGlobalId } from '$lib/utils/relay';
import { GetAccountStore, GetLaborsStore, GetCustomersStore } from '$houdini';
import { Button, Modal, Spinner, Alert, Badge } from 'flowbite-svelte';
let {
accountId,
triggerLabel = 'View Account Details',
triggerSize = 'sm',
triggerColor = 'secondary'
}: {
accountId: string;
triggerLabel?: string;
triggerSize?: 'xs' | 'sm' | 'md' | 'lg';
triggerColor?: 'red' | 'primary' | 'secondary' | 'alternative';
} = $props();
let popupModal = $state(false);
let loading = $state(false);
let error = $state('');
let account = $derived(new GetAccountStore());
let labors = $derived(new GetLaborsStore());
let customers = $derived(new GetCustomersStore());
// Sort addresses: primary first, then alphabetically by name
let sortedAddresses = $derived(() => {
if (!$account.data?.account?.addresses?.length) return [];
const addressList = [...$account.data.account.addresses];
return addressList.sort((a, b) => {
// Primary addresses come first
if (a.isPrimary && !b.isPrimary) return -1;
if (!a.isPrimary && b.isPrimary) return 1;
// Then sort alphabetically by name
const aName = (a.name || 'Primary Service Address').toLowerCase();
const bName = (b.name || 'Primary Service Address').toLowerCase();
return aName.localeCompare(bName);
});
});
let sortedLabors = $derived(() => {
if (!$labors.data?.labors?.length) return [];
const laborList = [...$labors.data.labors];
return laborList.sort((a, b) => {
const aHasEndDate = !!a.endDate;
const bHasEndDate = !!b.endDate;
if (!aHasEndDate && bHasEndDate) return -1;
if (aHasEndDate && !bHasEndDate) return 1;
const aStartDate = new Date(a.startDate).getTime();
const bStartDate = new Date(b.startDate).getTime();
return bStartDate - aStartDate;
});
});
const loadAccountData = async () => {
loading = true;
error = '';
try {
// First fetch account data
const accountRes = await account.fetch({
variables: { id: accountId },
policy: 'NetworkOnly'
});
if (accountRes?.errors?.length) {
const errs = accountRes.errors as { message: string }[];
error = errs.map((e) => e.message).join(', ');
}
// Then fetch labor data for all addresses associated with this account
const addressIds =
$account.data?.account?.addresses?.map((addr) => fromGlobalId(addr.id)) || [];
if (addressIds.length > 0) {
// Fetch labor data for all addresses
const laborPromises = addressIds.map((addressId) =>
labors.fetch({
variables: { filters: { accountAddressId: addressId } },
policy: 'NetworkOnly'
})
);
const laborResponses = await Promise.all(laborPromises);
for (const res of laborResponses) {
if (res?.errors?.length) {
const errs = res.errors as { message: string }[];
error = [error, errs.map((e) => e.message).join(', ')].filter(Boolean).join(', ');
}
}
}
// After the account loads, fetch the related customer by UUID
const custId = $account.data?.account?.customerId;
if (custId) {
const custRes = await customers.fetch({
variables: { filters: { id: custId } },
policy: 'NetworkOnly'
});
if (custRes?.errors?.length) {
const errs = custRes.errors as { message: string }[];
error = [error, errs.map((e) => e.message).join(', ')].filter(Boolean).join(', ');
}
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load account data';
} finally {
loading = false;
}
};
const handleOpen = () => {
popupModal = true;
loadAccountData();
};
function statusColor(status: string): 'green' | 'red' | 'yellow' | 'purple' | 'gray' | 'blue' {
switch (status) {
case 'ACTIVE':
return 'green';
case 'PAUSED':
return 'yellow';
case 'ENDED':
return 'gray';
default:
return 'blue';
}
}
</script>
<!-- Trigger -->
<Button size={triggerSize} color={triggerColor} onclick={handleOpen}>
{triggerLabel}
</Button>
<Modal bind:open={popupModal} size="xl" transition={slide}>
<div class="max-h-[80vh] overflow-y-auto">
{#if error}
<Alert color="red" class="mb-4">{error}</Alert>
{/if}
{#if loading || $account.fetching || $labors.fetching || $customers.fetching}
<div class="flex items-center justify-center gap-2 py-8 text-gray-600 dark:text-gray-300">
<Spinner />
Loading account...
</div>
{:else if $account.data?.account}
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{$account.data.account.name}
</h1>
<div class="text-sm text-gray-600 dark:text-gray-300">
Customer: {$customers.data?.customers?.[0]?.name || '-'}
</div>
</div>
<!-- Status Badge -->
<div class="mb-6">
<Badge color={statusColor(String($account.data.account.status))}>
{String($account.data.account.status)}
</Badge>
</div>
<!-- Account Details -->
<div
class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Account Information
</h2>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Start Date</div>
<div class="text-gray-900 dark:text-gray-100">
{formatDate($account.data.account.startDate)}
</div>
</div>
</div>
</div>
<!-- Service Addresses -->
<div
class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Service Addresses
</h2>
{#if sortedAddresses().length}
<div class="space-y-3">
{#each sortedAddresses() as address (address.id)}
<div
class="border-l-4 {address.isPrimary
? 'border-purple-500'
: 'border-blue-500'} bg-white p-3 dark:bg-gray-900"
>
<div class="mb-2 flex items-center gap-2">
<h3 class="font-medium text-gray-900 dark:text-gray-100">
{address.name || 'Primary Service Address'}
</h3>
{#if address.isPrimary}
<Badge color="purple" class="px-2 py-1 text-xs">Primary</Badge>
{/if}
{#if address.isActive}
<Badge color="green" class="px-2 py-1 text-xs">Active</Badge>
{:else}
<Badge color="gray" class="px-2 py-1 text-xs">Inactive</Badge>
{/if}
</div>
<div class="text-gray-700 dark:text-gray-200">
{address.streetAddress}
</div>
<div class="text-gray-700 dark:text-gray-200">
{address.city}, {address.state}
{address.zipCode}
</div>
{#if address.notes}
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{address.notes}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-gray-600 dark:text-gray-300">No service addresses found.</div>
{/if}
</div>
<!-- Labor -->
<div
class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">Labor</h2>
{#if sortedLabors().length}
<div class="space-y-3">
{#each sortedLabors() as labor (labor.id)}
<div class="border-l-4 border-blue-500 bg-white p-3 dark:bg-gray-900">
<div class="font-medium text-gray-900 dark:text-gray-100">
{labor.amount}
</div>
<div class="text-gray-700 dark:text-gray-200">
{formatDate(labor.startDate)} - {formatDate(labor.endDate)}
</div>
</div>
{/each}
</div>
{:else}
<div class="text-gray-600 dark:text-gray-300">No labor rates.</div>
{/if}
</div>
<!-- Contacts -->
<div
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">Contacts</h2>
{#if $account.data.account.contacts?.length}
<div class="space-y-3">
{#each $account.data.account.contacts as contact (contact.id)}
<div class="border-l-4 border-purple-500 bg-white p-3 dark:bg-gray-900">
<div class="mb-2 flex items-center gap-2">
<h3 class="font-medium text-gray-900 dark:text-gray-100">
{contact.fullName ||
`${contact.firstName || ''} ${contact.lastName || ''}`.trim() ||
'Contact'}
</h3>
{#if contact.isPrimary}
<Badge color="purple" class="px-2 py-1 text-xs">Primary</Badge>
{/if}
{#if contact.isActive}
<Badge color="green" class="px-2 py-1 text-xs">Active</Badge>
{:else}
<Badge color="gray" class="px-2 py-1 text-xs">Inactive</Badge>
{/if}
</div>
<div class="text-gray-700 dark:text-gray-200">{contact.email || '-'}</div>
<div class="text-gray-700 dark:text-gray-200">{contact.phone || '-'}</div>
{#if contact.notes}
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{contact.notes}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-gray-600 dark:text-gray-300">No contacts.</div>
{/if}
</div>
{:else}
<div class="py-8 text-center text-gray-600 dark:text-gray-300">Account not found.</div>
{/if}
</div>
</Modal>

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