public-ready-init
This commit is contained in:
commit
9d679cd029
35
.dockerignore
Normal file
35
.dockerignore
Normal 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
20
.env.example
Normal 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
26
.gitignore
vendored
Normal 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
9
.graphqlrc.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
projects:
|
||||||
|
default:
|
||||||
|
schema:
|
||||||
|
- ./schema.graphql
|
||||||
|
- ./.houdini/graphql/schema.graphql
|
||||||
|
documents:
|
||||||
|
- '**/*.gql'
|
||||||
|
- '**/*.svelte'
|
||||||
|
- ./.houdini/graphql/documents.gql
|
||||||
9
.prettierignore
Normal file
9
.prettierignore
Normal 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
15
.prettierrc
Normal 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
56
Dockerfile
Normal 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
154
README.md
Normal 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
19
docker-compose.yml
Normal 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
42
eslint.config.js
Normal 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
24
houdini.config.js
Normal 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
6928
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal 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
5
postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {}
|
||||||
|
}
|
||||||
|
};
|
||||||
4333
schema.graphql
Normal file
4333
schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
49
src/app.css
Normal file
49
src/app.css
Normal 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
13
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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>
|
||||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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
164
src/lib/auth.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
50
src/lib/components/AuthenticatedImage.svelte
Normal file
50
src/lib/components/AuthenticatedImage.svelte
Normal 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}
|
||||||
119
src/lib/components/AuthenticatedVideo.svelte
Normal file
119
src/lib/components/AuthenticatedVideo.svelte
Normal 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}
|
||||||
70
src/lib/components/OffCanvasLeft.svelte
Normal file
70
src/lib/components/OffCanvasLeft.svelte
Normal 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}
|
||||||
70
src/lib/components/OffCanvasRight.svelte
Normal file
70
src/lib/components/OffCanvasRight.svelte
Normal 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}
|
||||||
98
src/lib/components/accounts/AddressLabors.svelte
Normal file
98
src/lib/components/accounts/AddressLabors.svelte
Normal 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>
|
||||||
72
src/lib/components/accounts/AddressLaborsCompact.svelte
Normal file
72
src/lib/components/accounts/AddressLaborsCompact.svelte
Normal 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}
|
||||||
122
src/lib/components/accounts/AddressSchedules.svelte
Normal file
122
src/lib/components/accounts/AddressSchedules.svelte
Normal 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>
|
||||||
83
src/lib/components/accounts/AddressSchedulesCompact.svelte
Normal file
83
src/lib/components/accounts/AddressSchedulesCompact.svelte
Normal 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}
|
||||||
11
src/lib/components/accounts/CreateAccount.svelte
Normal file
11
src/lib/components/accounts/CreateAccount.svelte
Normal 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} />
|
||||||
151
src/lib/components/accounts/CreateAccountAddress.svelte
Normal file
151
src/lib/components/accounts/CreateAccountAddress.svelte
Normal 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>
|
||||||
142
src/lib/components/accounts/CreateAccountContact.svelte
Normal file
142
src/lib/components/accounts/CreateAccountContact.svelte
Normal 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>
|
||||||
100
src/lib/components/accounts/CreateAccountLabor.svelte
Normal file
100
src/lib/components/accounts/CreateAccountLabor.svelte
Normal 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>
|
||||||
104
src/lib/components/accounts/CreateAccountRevenue.svelte
Normal file
104
src/lib/components/accounts/CreateAccountRevenue.svelte
Normal 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>
|
||||||
157
src/lib/components/accounts/CreateAccountSchedule.svelte
Normal file
157
src/lib/components/accounts/CreateAccountSchedule.svelte
Normal 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>
|
||||||
46
src/lib/components/accounts/EditAccount.svelte
Normal file
46
src/lib/components/accounts/EditAccount.svelte
Normal 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}
|
||||||
136
src/lib/components/accounts/EditAccountAddress.svelte
Normal file
136
src/lib/components/accounts/EditAccountAddress.svelte
Normal 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>
|
||||||
139
src/lib/components/accounts/EditAccountContact.svelte
Normal file
139
src/lib/components/accounts/EditAccountContact.svelte
Normal 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>
|
||||||
141
src/lib/components/accounts/EditAccountLabor.svelte
Normal file
141
src/lib/components/accounts/EditAccountLabor.svelte
Normal 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>
|
||||||
134
src/lib/components/accounts/EditAccountRevenue.svelte
Normal file
134
src/lib/components/accounts/EditAccountRevenue.svelte
Normal 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>
|
||||||
182
src/lib/components/accounts/EditAccountSchedule.svelte
Normal file
182
src/lib/components/accounts/EditAccountSchedule.svelte
Normal 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>
|
||||||
10
src/lib/components/customers/CreateCustomer.svelte
Normal file
10
src/lib/components/customers/CreateCustomer.svelte
Normal 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} />
|
||||||
150
src/lib/components/customers/CreateCustomerAddress.svelte
Normal file
150
src/lib/components/customers/CreateCustomerAddress.svelte
Normal 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>
|
||||||
142
src/lib/components/customers/CreateCustomerContact.svelte
Normal file
142
src/lib/components/customers/CreateCustomerContact.svelte
Normal 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>
|
||||||
46
src/lib/components/customers/EditCustomer.svelte
Normal file
46
src/lib/components/customers/EditCustomer.svelte
Normal 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}
|
||||||
157
src/lib/components/customers/EditCustomerAddress.svelte
Normal file
157
src/lib/components/customers/EditCustomerAddress.svelte
Normal 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>
|
||||||
139
src/lib/components/customers/EditCustomerContact.svelte
Normal file
139
src/lib/components/customers/EditCustomerContact.svelte
Normal 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>
|
||||||
104
src/lib/components/customers/ViewCustomerAccounts.svelte
Normal file
104
src/lib/components/customers/ViewCustomerAccounts.svelte
Normal 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}
|
||||||
356
src/lib/components/factories/EntityListFactory.svelte
Normal file
356
src/lib/components/factories/EntityListFactory.svelte
Normal 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>
|
||||||
128
src/lib/components/forms/accounts/AccountEditForm.svelte
Normal file
128
src/lib/components/forms/accounts/AccountEditForm.svelte
Normal 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>
|
||||||
131
src/lib/components/forms/accounts/AccountForm.svelte
Normal file
131
src/lib/components/forms/accounts/AccountForm.svelte
Normal 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>
|
||||||
356
src/lib/components/forms/calendar/EventEditForm.svelte
Normal file
356
src/lib/components/forms/calendar/EventEditForm.svelte
Normal 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>
|
||||||
301
src/lib/components/forms/calendar/EventForm.svelte
Normal file
301
src/lib/components/forms/calendar/EventForm.svelte
Normal 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>
|
||||||
492
src/lib/components/forms/calendar/EventProjectForm.svelte
Normal file
492
src/lib/components/forms/calendar/EventProjectForm.svelte
Normal 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>
|
||||||
155
src/lib/components/forms/customers/CustomerEditForm.svelte
Normal file
155
src/lib/components/forms/customers/CustomerEditForm.svelte
Normal 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>
|
||||||
111
src/lib/components/forms/customers/CustomerForm.svelte
Normal file
111
src/lib/components/forms/customers/CustomerForm.svelte
Normal 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>
|
||||||
@ -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
|
||||||
|
{old_status} to {new_status}". 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 {entity_type} with ID
|
||||||
|
{entity_id} 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>
|
||||||
@ -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
|
||||||
|
{old_status} to {new_status}". 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 {entity_type} with ID
|
||||||
|
{entity_id} 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>
|
||||||
170
src/lib/components/forms/profile/ProfileEditForm.svelte
Normal file
170
src/lib/components/forms/profile/ProfileEditForm.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
123
src/lib/components/forms/profiles/CustomerProfileEditForm.svelte
Normal file
123
src/lib/components/forms/profiles/CustomerProfileEditForm.svelte
Normal 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>
|
||||||
126
src/lib/components/forms/profiles/TeamProfileCreateForm.svelte
Normal file
126
src/lib/components/forms/profiles/TeamProfileCreateForm.svelte
Normal 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>
|
||||||
137
src/lib/components/forms/profiles/TeamProfileEditForm.svelte
Normal file
137
src/lib/components/forms/profiles/TeamProfileEditForm.svelte
Normal 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>
|
||||||
@ -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}
|
||||||
@ -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>
|
||||||
@ -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}
|
||||||
198
src/lib/components/forms/projectScopes/ProjectScopeForm.svelte
Normal file
198
src/lib/components/forms/projectScopes/ProjectScopeForm.svelte
Normal 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>
|
||||||
@ -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}
|
||||||
121
src/lib/components/forms/projectScopes/ProjectTaskForm.svelte
Normal file
121
src/lib/components/forms/projectScopes/ProjectTaskForm.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
233
src/lib/components/forms/projects/ProjectEditForm.svelte
Normal file
233
src/lib/components/forms/projects/ProjectEditForm.svelte
Normal 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>
|
||||||
347
src/lib/components/forms/projects/ProjectForm.svelte
Normal file
347
src/lib/components/forms/projects/ProjectForm.svelte
Normal 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>
|
||||||
125
src/lib/components/forms/reports/ReportEditForm.svelte
Normal file
125
src/lib/components/forms/reports/ReportEditForm.svelte
Normal 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>
|
||||||
96
src/lib/components/forms/reports/ReportForm.svelte
Normal file
96
src/lib/components/forms/reports/ReportForm.svelte
Normal 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>
|
||||||
89
src/lib/components/forms/scopes/AreaEditForm.svelte
Normal file
89
src/lib/components/forms/scopes/AreaEditForm.svelte
Normal 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>
|
||||||
77
src/lib/components/forms/scopes/AreaForm.svelte
Normal file
77
src/lib/components/forms/scopes/AreaForm.svelte
Normal 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>
|
||||||
97
src/lib/components/forms/scopes/ScopeEditForm.svelte
Normal file
97
src/lib/components/forms/scopes/ScopeEditForm.svelte
Normal 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>
|
||||||
163
src/lib/components/forms/scopes/ScopeForm.svelte
Normal file
163
src/lib/components/forms/scopes/ScopeForm.svelte
Normal 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>
|
||||||
140
src/lib/components/forms/scopes/TaskEditForm.svelte
Normal file
140
src/lib/components/forms/scopes/TaskEditForm.svelte
Normal 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>
|
||||||
124
src/lib/components/forms/scopes/TaskForm.svelte
Normal file
124
src/lib/components/forms/scopes/TaskForm.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
172
src/lib/components/forms/services/ServiceEditForm.svelte
Normal file
172
src/lib/components/forms/services/ServiceEditForm.svelte
Normal 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>
|
||||||
156
src/lib/components/forms/services/ServiceForm.svelte
Normal file
156
src/lib/components/forms/services/ServiceForm.svelte
Normal 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>
|
||||||
263
src/lib/components/forms/services/ServiceGenerateForm.svelte
Normal file
263
src/lib/components/forms/services/ServiceGenerateForm.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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}
|
||||||
@ -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>
|
||||||
580
src/lib/components/media/MediaManager.svelte
Normal file
580
src/lib/components/media/MediaManager.svelte
Normal 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>
|
||||||
100
src/lib/components/media/MediaUploadZone.svelte
Normal file
100
src/lib/components/media/MediaUploadZone.svelte
Normal 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>
|
||||||
177
src/lib/components/media/PhotoGalleryItem.svelte
Normal file
177
src/lib/components/media/PhotoGalleryItem.svelte
Normal 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>
|
||||||
92
src/lib/components/media/VideoGalleryItem.svelte
Normal file
92
src/lib/components/media/VideoGalleryItem.svelte
Normal 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>
|
||||||
42
src/lib/components/messages/MessageBell.svelte
Normal file
42
src/lib/components/messages/MessageBell.svelte
Normal 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>
|
||||||
297
src/lib/components/modals/accounts/AccountViewModal.svelte
Normal file
297
src/lib/components/modals/accounts/AccountViewModal.svelte
Normal 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
Loading…
x
Reference in New Issue
Block a user