public-ready-init
13
.dockerignore
Normal file
@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.houdini
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
*.log
|
||||
*.md
|
||||
.env*
|
||||
!.env.example
|
||||
.vscode
|
||||
.idea
|
||||
23
.env.example
Normal file
@ -0,0 +1,23 @@
|
||||
# GraphQL API
|
||||
PUBLIC_GRAPHQL_URL=http://localhost:8000/graphql/
|
||||
|
||||
# Ory Kratos (Authentication)
|
||||
PUBLIC_KRATOS_URL=http://localhost:4433
|
||||
|
||||
# Calendar Service
|
||||
PUBLIC_CALENDAR_API_URL=http://localhost:8001
|
||||
PUBLIC_CALENDAR_API_KEY=your-calendar-api-key
|
||||
|
||||
# Email Service
|
||||
PUBLIC_EMAIL_API_URL=http://localhost:8002
|
||||
PUBLIC_EMAIL_API_KEY=your-email-api-key
|
||||
|
||||
# Wave API (Invoice Integration)
|
||||
WAVE_ACCESS_TOKEN=your-wave-access-token
|
||||
WAVE_BUSINESS_ID=your-wave-business-id
|
||||
|
||||
# Houdini Schema Introspection (development only)
|
||||
USER_ID=your-dev-user-id
|
||||
USER_PROFILE_TYPE=TeamProfileType
|
||||
OATHKEEPER_SECRET=your-oathkeeper-secret
|
||||
DJANGO_PROFILE_ID=your-dev-profile-id
|
||||
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
.houdini
|
||||
/context/
|
||||
.idea
|
||||
16
.graphqlrc.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
projects:
|
||||
default:
|
||||
schema:
|
||||
- ./schema.graphql
|
||||
- ./.houdini/graphql/schema.graphql
|
||||
documents:
|
||||
- '**/*.gql'
|
||||
- '**/*.graphql'
|
||||
- '**/*.svelte'
|
||||
- ./.houdini/graphql/documents.gql
|
||||
exclude:
|
||||
- '**/node_modules/**'
|
||||
- 'src/lib/graphql/wave/**'
|
||||
wave:
|
||||
schema: ./src/lib/graphql/wave/schema.graphql
|
||||
documents: src/lib/graphql/wave/**/*.gql
|
||||
14
.prettierignore
Normal file
@ -0,0 +1,14 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Generated files
|
||||
.houdini/
|
||||
src/lib/graphql/wave/generated.ts
|
||||
src/lib/graphql/wave/schema.graphql
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
16
.prettierrc
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/routes/layout.css"
|
||||
}
|
||||
177
CLAUDE.md
Normal file
@ -0,0 +1,177 @@
|
||||
# Claude Code Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a SvelteKit 2.x application using Svelte 5 with Tailwind CSS v4.
|
||||
|
||||
## Code Style Rules
|
||||
|
||||
### SVG Attributes
|
||||
|
||||
When using SVG elements, use `style` attribute instead of `fill` attribute to avoid IDE obsolete attribute warnings:
|
||||
|
||||
```svelte
|
||||
<!-- Correct -->
|
||||
<svg style="fill: currentColor" ...>
|
||||
<svg style="fill: none" ...>
|
||||
|
||||
<!-- Incorrect (triggers obsolete attribute warning) -->
|
||||
<svg fill="currentColor" ...>
|
||||
<svg fill="none" ...>
|
||||
```
|
||||
|
||||
### Svelte 5 Runes
|
||||
|
||||
- Use `$state()` for reactive state
|
||||
- Use `$derived()` for computed values
|
||||
- Use `$props()` for component props
|
||||
- Use `$effect()` for side effects
|
||||
|
||||
### Page Store
|
||||
|
||||
Use `$app/state` instead of `$app/stores` for the page object:
|
||||
|
||||
```svelte
|
||||
<!-- Correct -->
|
||||
import {page} from '$app/state'; const path = page.url.pathname;
|
||||
|
||||
<!-- Incorrect -->
|
||||
import {page} from '$app/stores'; const path = $page.url.pathname;
|
||||
```
|
||||
|
||||
### Theme Classes
|
||||
|
||||
Use the custom theme utility classes defined in `layout.css`:
|
||||
|
||||
- `text-theme`, `text-theme-secondary`, `text-theme-muted`
|
||||
- `bg-theme`, `bg-theme-card`
|
||||
- `border-theme`
|
||||
- `shadow-theme`, `shadow-theme-lg`
|
||||
|
||||
### Hover/Active States
|
||||
|
||||
Use consistent hover/active patterns:
|
||||
|
||||
```
|
||||
hover:bg-black/5 dark:hover:bg-white/10
|
||||
active:bg-black/10 dark:active:bg-white/15
|
||||
```
|
||||
|
||||
### Mobile Grid Layouts
|
||||
|
||||
For card grids that should display single-column on mobile and multi-column on larger screens, always:
|
||||
|
||||
1. Explicitly set `grid-cols-1` (don't rely on CSS grid's implicit single column)
|
||||
2. Add `min-w-0` on grid items with flex content to allow proper shrinking
|
||||
|
||||
```svelte
|
||||
<!-- Correct -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="card-example flex min-w-0 items-center justify-between">
|
||||
<div class="min-w-0 flex-1">...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incorrect (may overflow on mobile) -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="card-example flex items-center justify-between">...</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Button Element Content
|
||||
|
||||
Button elements can only contain phrasing content (inline elements). Use `<span>` instead of `<div>`, `<p>`, or `<h1>`-`<h6>` inside buttons:
|
||||
|
||||
```svelte
|
||||
<!-- Correct -->
|
||||
<button>
|
||||
<span class="flex items-center gap-2">
|
||||
<span>Click me</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Incorrect (triggers HTML validation warning) -->
|
||||
<button>
|
||||
<div class="flex items-center gap-2">
|
||||
<p>Click me</p>
|
||||
</div>
|
||||
</button>
|
||||
```
|
||||
|
||||
For multi-line button content, use `<span class="block">` to create line breaks:
|
||||
|
||||
```svelte
|
||||
<button>
|
||||
<span class="flex items-center gap-2">Title</span>
|
||||
<span class="text-muted block text-sm">Subtitle on new line</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### SvelteSet Reactivity
|
||||
|
||||
`SvelteSet` from `svelte/reactivity` is already reactive and does NOT need to be wrapped in `$state()`:
|
||||
|
||||
```svelte
|
||||
<!-- Correct -->
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
let expandedItems = new SvelteSet<string>();
|
||||
|
||||
<!-- Incorrect (unnecessary wrapper) -->
|
||||
let expandedItems = $state(new SvelteSet<string>());
|
||||
```
|
||||
|
||||
### Each Block Keys
|
||||
|
||||
Always provide a key for `{#each}` blocks to avoid ESLint warnings:
|
||||
|
||||
```svelte
|
||||
<!-- Correct -->
|
||||
{#each items as item (item.id)}
|
||||
{#each days as day (day)}
|
||||
|
||||
<!-- Incorrect -->
|
||||
{#each items as item}
|
||||
```
|
||||
|
||||
### Date Formatting
|
||||
|
||||
Use the date utilities from `$lib/utils/date` which use `date-fns` to properly handle ISO date strings without timezone offset issues:
|
||||
|
||||
```svelte
|
||||
import {formatDate} from '$lib/utils/date';
|
||||
|
||||
<!-- Returns empty string for null/undefined, so add fallback -->
|
||||
{formatDate(startDate) || '—'}
|
||||
```
|
||||
|
||||
### File Organization
|
||||
|
||||
- Place `.svelte.ts` files (stores, shared state) in `src/lib/stores/`, not in component folders
|
||||
- GraphQL mutations go in `src/lib/graphql/mutations/{entity}/`
|
||||
- GraphQL queries go in `src/lib/graphql/queries/{entity}/`
|
||||
|
||||
### Backend Integration Notes
|
||||
|
||||
- The backend uses a unique constraint allowing only ONE active scope per address
|
||||
- The `isConditional` field on tasks is legacy and should always be set to `false`
|
||||
- GraphQL inputs use camelCase (e.g., `isActive`, `accountId`) which maps to snake_case in Django
|
||||
|
||||
### Task Frequency Values
|
||||
|
||||
The backend `TaskFrequencyChoices` enum uses **lowercase** values. Handle frequency differently depending on the operation:
|
||||
|
||||
**Individual mutations** (create/update task or task template) - use lowercase:
|
||||
|
||||
```typescript
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'triannual' | 'annual' | 'as_needed';
|
||||
```
|
||||
|
||||
**JSON import** (`createScopeTemplateFromJson`) - uppercase is acceptable:
|
||||
|
||||
```json
|
||||
{ "frequency": "DAILY" }
|
||||
```
|
||||
|
||||
The backend's `build_scope_template` service normalizes uppercase to lowercase automatically.
|
||||
|
||||
Valid frequency values: `daily`, `weekly`, `monthly`, `quarterly`, `triannual`, `annual`, `as_needed`
|
||||
64
Dockerfile
Normal file
@ -0,0 +1,64 @@
|
||||
# Use the official Node.js runtime as base image
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Make PUBLIC_ vars available at build time
|
||||
ARG PUBLIC_CALENDAR_API_URL
|
||||
ARG PUBLIC_CALENDAR_API_KEY
|
||||
ARG PUBLIC_EMAIL_API_URL
|
||||
ARG PUBLIC_EMAIL_API_KEY
|
||||
ARG PUBLIC_WAVE_BUSINESS_ID
|
||||
ARG WAVE_ACCESS_TOKEN
|
||||
ENV PUBLIC_CALENDAR_API_URL=$PUBLIC_CALENDAR_API_URL
|
||||
ENV PUBLIC_CALENDAR_API_KEY=$PUBLIC_CALENDAR_API_KEY
|
||||
ENV PUBLIC_EMAIL_API_URL=$PUBLIC_EMAIL_API_URL
|
||||
ENV PUBLIC_EMAIL_API_KEY=$PUBLIC_EMAIL_API_KEY
|
||||
ENV PUBLIC_WAVE_BUSINESS_ID=$PUBLIC_WAVE_BUSINESS_ID
|
||||
ENV WAVE_ACCESS_TOKEN=$WAVE_ACCESS_TOKEN
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Ensure production mode in runtime
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies (modern flag)
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/build build/
|
||||
|
||||
# Create a non-root user
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S svelte -u 1001
|
||||
|
||||
# Change ownership of the app directory
|
||||
RUN chown -R svelte:nodejs /app
|
||||
USER svelte
|
||||
|
||||
# Expose the port your app runs on
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "build"]
|
||||
230
README.md
Normal file
@ -0,0 +1,230 @@
|
||||
# Nexus 5 Frontend 3 - Full Portal
|
||||
|
||||
Full-featured web portal for the Nexus 5 platform, combining public marketing pages, customer portal, team interface, and admin dashboard in a single unified application.
|
||||
|
||||
## Overview
|
||||
|
||||
This is the third and most complete iteration of the Nexus 5 frontend, designed as a comprehensive portal that serves all user types: public visitors, customers, team members, and administrators. It includes public-facing marketing pages alongside authenticated portals for different user roles.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **SvelteKit 5** - Latest SvelteKit with Svelte 5 runes
|
||||
- **Houdini** - Type-safe GraphQL client
|
||||
- **Tailwind CSS v4** - Next-generation Tailwind with CSS variables
|
||||
- **TypeScript** - Strict mode enabled
|
||||
- **Node adapter** - Production deployment
|
||||
- **Wave Integration** - Invoice management via Wave API
|
||||
|
||||
## Evolution
|
||||
|
||||
| Feature | Frontend 1 | Frontend 2 | Frontend 3 |
|
||||
|---------|------------|------------|------------|
|
||||
| **Purpose** | Admin only | Team only | All users |
|
||||
| **Public Pages** | No | No | Yes |
|
||||
| **Customer Portal** | No | No | Yes |
|
||||
| **Team Portal** | No | Yes | Yes |
|
||||
| **Admin Portal** | Yes | No | Yes |
|
||||
| **Theming** | Basic dark mode | Theme toggle | Full CSS variables |
|
||||
| **AI Chat** | No | No | Yes |
|
||||
| **Invoice Integration** | No | No | Wave API |
|
||||
|
||||
## Features
|
||||
|
||||
### Public Pages
|
||||
- **Home** - Landing page with service overview
|
||||
- **Services** - Service offerings and descriptions
|
||||
- **Pricing** - Pricing information
|
||||
- **About** - Company information
|
||||
- **Contact** - Contact form with email integration
|
||||
- **Privacy/Terms** - Legal pages
|
||||
|
||||
### Customer Portal
|
||||
- **Dashboard** - Overview of accounts and upcoming work
|
||||
- **Accounts** - View service accounts and locations
|
||||
- **Schedule** - Upcoming services and projects
|
||||
- **History** - Completed work history
|
||||
- **Invoices** - View and pay invoices
|
||||
- **Profile** - Customer profile management
|
||||
|
||||
### Team Portal
|
||||
- **Dashboard** - Today's assignments
|
||||
- **Services** - Assigned services with session management
|
||||
- **Projects** - Assigned projects
|
||||
- **Reports** - Submit and view reports
|
||||
- **Profile** - Team member profile
|
||||
|
||||
### Admin Portal
|
||||
- **Dashboard** - Business overview
|
||||
- **Customers** - Customer management
|
||||
- **Accounts** - Account management
|
||||
- **Services** - Service scheduling and assignment
|
||||
- **Projects** - Project management
|
||||
- **Scopes** - Scope template management
|
||||
- **Reports** - Report management
|
||||
- **Invoices** - Invoice creation and Wave integration
|
||||
- **Calendar** - Event management
|
||||
- **Profiles** - User profile management
|
||||
- **Notifications** - Notification rule configuration
|
||||
- **Event Log** - System event audit log
|
||||
|
||||
### Cross-cutting Features
|
||||
- **AI Chat** - Claude-powered assistant for all users
|
||||
- **Messages** - Inter-user messaging
|
||||
- **Notifications** - Real-time notifications
|
||||
- **Theme Toggle** - Light/dark mode support
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Access to Nexus 5 GraphQL API
|
||||
- (Optional) Wave API credentials for invoicing
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Type checking
|
||||
npm run check
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
docker-compose up --build
|
||||
|
||||
# Production deployment
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
See `.env.example` for required configuration:
|
||||
|
||||
- `PUBLIC_GRAPHQL_URL` - Nexus 5 API endpoint
|
||||
- `PUBLIC_KRATOS_URL` - Ory Kratos public URL
|
||||
- `WAVE_ACCESS_TOKEN` - Wave API token (for invoicing)
|
||||
- `WAVE_BUSINESS_ID` - Wave business ID
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── components/
|
||||
│ │ ├── admin/ # Admin-specific components
|
||||
│ │ ├── chat/ # AI chat interface
|
||||
│ │ ├── customer/ # Customer portal components
|
||||
│ │ ├── entity/ # Shared entity components
|
||||
│ │ ├── icons/ # SVG icon components
|
||||
│ │ ├── layout/ # Layout components
|
||||
│ │ ├── nav/ # Navigation (TopNav, AdminNav, etc.)
|
||||
│ │ ├── session/ # Work session components
|
||||
│ │ ├── shared/ # Shared UI components
|
||||
│ │ └── team/ # Team portal components
|
||||
│ ├── data/ # Static data (event types, etc.)
|
||||
│ ├── graphql/
|
||||
│ │ ├── mutations/ # GraphQL mutations
|
||||
│ │ ├── queries/ # GraphQL queries
|
||||
│ │ └── wave/ # Wave API integration
|
||||
│ ├── server/ # Server-side utilities
|
||||
│ ├── services/ # External service integrations
|
||||
│ ├── stores/ # Svelte stores
|
||||
│ └── utils/ # Utility functions
|
||||
└── routes/
|
||||
├── admin/ # Admin portal routes
|
||||
├── customer/ # Customer portal routes
|
||||
├── team/ # Team portal routes
|
||||
├── messages/ # Messaging
|
||||
├── notifications/ # Notifications
|
||||
├── about/ # Public pages
|
||||
├── contact/
|
||||
├── pricing/
|
||||
├── privacy/
|
||||
├── services/
|
||||
├── terms/
|
||||
└── login/logout/ # Auth flows
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### CSS Variables for Theming
|
||||
|
||||
Uses CSS custom properties for comprehensive theming:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-primary-500: #3b82f6;
|
||||
--bg-primary: 255 255 255;
|
||||
--text-primary: 15 23 42;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg-primary: 15 23 42;
|
||||
--text-primary: 248 250 252;
|
||||
}
|
||||
```
|
||||
|
||||
### Role-Based Navigation
|
||||
|
||||
Navigation adapts based on user role:
|
||||
|
||||
```svelte
|
||||
{#if isAdmin}
|
||||
<AdminNav />
|
||||
{:else if isTeamMember}
|
||||
<TeamNav />
|
||||
{:else if isCustomer}
|
||||
<CustomerNav />
|
||||
{:else}
|
||||
<PublicNav />
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Server-Side Data Loading
|
||||
|
||||
Uses SvelteKit's load functions for server-side data fetching:
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async ({ locals }) => {
|
||||
const data = await fetchData(locals.user);
|
||||
return { data };
|
||||
};
|
||||
```
|
||||
|
||||
### Wave API Integration
|
||||
|
||||
Separate GraphQL client for Wave invoice management:
|
||||
|
||||
```typescript
|
||||
import { waveClient } from '$lib/graphql/wave';
|
||||
|
||||
const invoices = await waveClient.query({
|
||||
query: ListInvoices,
|
||||
variables: { businessId }
|
||||
});
|
||||
```
|
||||
|
||||
## Related Repositories
|
||||
|
||||
- **nexus-5** - Django GraphQL API backend
|
||||
- **nexus-5-auth** - Ory Kratos/Oathkeeper authentication
|
||||
- **nexus-5-frontend-1** - Admin-only dashboard
|
||||
- **nexus-5-frontend-2** - Team-only mobile app
|
||||
- **nexus-5-emailer** - Email microservice
|
||||
- **nexus-5-scheduler** - Calendar integration microservice
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details.
|
||||
28
codegen.wave.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli';
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: [
|
||||
{
|
||||
'https://gql.waveapps.com/graphql/public': {
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + process.env.WAVE_ACCESS_TOKEN
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
documents: 'src/lib/graphql/wave/**/*.gql',
|
||||
generates: {
|
||||
'src/lib/graphql/wave/schema.graphql': {
|
||||
plugins: ['schema-ast']
|
||||
},
|
||||
'src/lib/graphql/wave/generated.ts': {
|
||||
plugins: ['typescript', 'typescript-operations', 'typescript-urql'],
|
||||
config: {
|
||||
withHooks: false,
|
||||
withComponent: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
23
docker-compose.yml
Normal file
@ -0,0 +1,23 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
PUBLIC_CALENDAR_API_URL: ${PUBLIC_CALENDAR_API_URL}
|
||||
PUBLIC_CALENDAR_API_KEY: ${PUBLIC_CALENDAR_API_KEY}
|
||||
PUBLIC_EMAIL_API_URL: ${PUBLIC_EMAIL_API_URL}
|
||||
PUBLIC_EMAIL_API_KEY: ${PUBLIC_EMAIL_API_KEY}
|
||||
PUBLIC_WAVE_BUSINESS_ID: ${PUBLIC_WAVE_BUSINESS_ID}
|
||||
WAVE_ACCESS_TOKEN: ${WAVE_ACCESS_TOKEN}
|
||||
image: nexus-5-frontend-3:latest
|
||||
container_name: nexus-5-frontend-3
|
||||
ports:
|
||||
- '7000:3000'
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- HOST=0.0.0.0
|
||||
restart: unless-stopped
|
||||
46
eslint.config.js
Normal file
@ -0,0 +1,46 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
{
|
||||
ignores: ['src/lib/graphql/wave/generated.ts']
|
||||
},
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off',
|
||||
// Disable the navigation resolve rule - we use standard href links
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
27
houdini.config.js
Normal file
@ -0,0 +1,27 @@
|
||||
/// <references types="houdini-svelte">
|
||||
|
||||
/** @type {import('houdini').ConfigFile} */
|
||||
const config = {
|
||||
watchSchema: {
|
||||
url: 'http://10.10.10.51:5500/graphql/',
|
||||
headers: {
|
||||
'X-USER-ID': (env) => env.USER_ID,
|
||||
'X-USER-PROFILE-TYPE': (env) => env.USER_PROFILE_TYPE,
|
||||
'X-OATHKEEPER-SECRET': (env) => env.OATHKEEPER_SECRET,
|
||||
'X-DJANGO-PROFILE-ID': (env) => env.DJANGO_PROFILE_ID
|
||||
}
|
||||
},
|
||||
schemaPath: './schema.graphql',
|
||||
runtimeDir: '.houdini',
|
||||
defaultCachePolicy: 'NetworkOnly',
|
||||
// Exclude Wave GraphQL files - they use urql with a separate schema
|
||||
exclude: ['src/lib/graphql/wave/**'],
|
||||
plugins: {
|
||||
'houdini-svelte': {
|
||||
client: './src/lib/graphql/client.ts',
|
||||
forceRunesMode: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
11225
package-lock.json
generated
Normal file
57
package.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "nexus-5-frontend-3",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"generate:wave": "graphql-codegen --config codegen.wave.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@graphql-codegen/cli": "^6.1.0",
|
||||
"@graphql-codegen/schema-ast": "^5.0.0",
|
||||
"@graphql-codegen/typescript": "^5.0.6",
|
||||
"@graphql-codegen/typescript-operations": "^5.0.6",
|
||||
"@graphql-codegen/typescript-urql": "^4.0.1",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/node": "^24",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.13.0",
|
||||
"globals": "^16.5.0",
|
||||
"houdini": "^2.0.0-next.11",
|
||||
"houdini-svelte": "^3.0.0-next.13",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"svelte": "^5.43.8",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vite-plugin-mkcert": "^1.17.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@urql/svelte": "^5.0.0",
|
||||
"graphql": "^16.12.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"urql": "^5.0.1"
|
||||
}
|
||||
}
|
||||
4411
schema.graphql
Normal file
9
src/app.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
cookie: string | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
26
src/app.html
Normal file
@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Nexus</title>
|
||||
<script>
|
||||
(function () {
|
||||
const stored = localStorage.getItem('theme-preference');
|
||||
const theme =
|
||||
stored === 'light' || stored === 'dark'
|
||||
? stored
|
||||
: stored === 'system' || !stored
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: 'light';
|
||||
document.documentElement.classList.add(theme);
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
8
src/hooks.server.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const cookie = event.cookies.get('ory_kratos_session');
|
||||
event.locals.cookie = cookie ? `ory_kratos_session=${cookie}` : null;
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
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 |
BIN
src/lib/assets/floors.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/lib/assets/hero.jpg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
src/lib/assets/hh-ceilings.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
src/lib/assets/hh-equipment.jpg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
src/lib/assets/hh-walls.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
src/lib/assets/kitchens.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
src/lib/assets/logo-icon.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
72
src/lib/components/BusinessFooter.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const footerLinks = {
|
||||
services: [
|
||||
{ label: 'Our Services', href: '/services' },
|
||||
{ label: 'Our Standard', href: '/standard' },
|
||||
{ label: 'Pricing', href: '/pricing' }
|
||||
],
|
||||
company: [
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Contact', href: '/contact' },
|
||||
{ label: 'Terms of Use', href: '/terms' },
|
||||
{ label: 'Privacy Policy', href: '/privacy' }
|
||||
]
|
||||
};
|
||||
</script>
|
||||
|
||||
<footer class="border-t border-theme bg-theme-card">
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-3">
|
||||
<!-- Brand -->
|
||||
<div>
|
||||
<a href="/" class="text-xl font-bold text-theme">Nexus Cleaning</a>
|
||||
<p class="mt-2 text-sm text-theme-muted">
|
||||
Dependable commercial cleaning services for businesses that demand excellence.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold tracking-wider text-theme uppercase">Services</h3>
|
||||
<ul class="mt-4 space-y-2">
|
||||
{#each footerLinks.services as link (link.href)}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm text-theme-secondary transition-colors hover:text-theme"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Company -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold tracking-wider text-theme uppercase">Company</h3>
|
||||
<ul class="mt-4 space-y-2">
|
||||
{#each footerLinks.company as link (link.href)}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm text-theme-secondary transition-colors hover:text-theme"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="mt-12 border-t border-theme pt-8 text-center">
|
||||
<p class="text-sm text-theme-muted">
|
||||
© {currentYear} Corellon Digital. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
19
src/lib/components/PublicBackLink.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
href?: string;
|
||||
label?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { href = '/', label = 'Home', class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="inline-flex items-center gap-1 text-sm text-theme-muted transition-colors hover:text-theme {className}"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{label}
|
||||
</a>
|
||||
56
src/lib/components/ThemeToggle.svelte
Normal file
@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => theme.toggle()}
|
||||
class="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-theme
|
||||
bg-theme-card text-theme-secondary transition-colors duration-200
|
||||
hover:bg-theme-secondary hover:text-theme {className}"
|
||||
aria-label={theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{#if theme.isDark}
|
||||
<!-- Sun icon for dark mode (click to go light) -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Moon icon for light mode (click to go dark) -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
228
src/lib/components/admin/AdminDashboardHeader.svelte
Normal file
@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto, beforeNavigate } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
subtitleSnippet?: Snippet<[{ toggleMenu: () => void }]>;
|
||||
showBackButton?: boolean;
|
||||
backHref?: string;
|
||||
invalidateOnBack?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
subtitleSnippet,
|
||||
showBackButton = true,
|
||||
backHref = '/admin',
|
||||
invalidateOnBack = false
|
||||
}: Props = $props();
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let isNavigatingBack = $state(false);
|
||||
|
||||
// Track when we're navigating to the back URL
|
||||
beforeNavigate(({ to }) => {
|
||||
if (to?.url.pathname === backHref) {
|
||||
isNavigatingBack = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handleBack(event: MouseEvent) {
|
||||
if (invalidateOnBack) {
|
||||
event.preventDefault();
|
||||
goto(backHref, { invalidateAll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: 'Customers',
|
||||
href: '/admin/customers',
|
||||
color: 'text-accent3-600 dark:text-accent3-400'
|
||||
},
|
||||
{ label: 'Accounts', href: '/admin/accounts', color: 'text-primary-600 dark:text-primary-400' },
|
||||
{
|
||||
label: 'Services',
|
||||
href: '/admin/services',
|
||||
color: 'text-secondary-600 dark:text-secondary-400'
|
||||
},
|
||||
{ label: 'Projects', href: '/admin/projects', color: 'text-accent-600 dark:text-accent-400' },
|
||||
{ label: 'Scopes', href: '/admin/scopes', color: 'text-accent3-600 dark:text-accent3-400' },
|
||||
{ label: 'Reports', href: '/admin/reports', color: 'text-accent2-600 dark:text-accent2-400' },
|
||||
{ label: 'Invoices', href: '/admin/invoices', color: 'text-accent6-600 dark:text-accent6-400' },
|
||||
{ label: 'Calendar', href: '/admin/calendar', color: 'text-accent7-600 dark:text-accent7-400' },
|
||||
{ label: 'Profiles', href: '/admin/profiles', color: 'text-accent4-600 dark:text-accent4-400' }
|
||||
];
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.nav-menu-container') && !target.closest('.menu-trigger')) {
|
||||
menuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
menuOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
|
||||
|
||||
<div class="mb-6">
|
||||
{#if showBackButton}
|
||||
<!-- 3-column grid: back button | content | spacer for centering -->
|
||||
<div class="grid grid-cols-[auto_1fr_auto] items-start gap-4">
|
||||
<a
|
||||
href={backHref}
|
||||
onclick={handleBack}
|
||||
class="rounded-lg p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Go back"
|
||||
>
|
||||
{#if isNavigatingBack}
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin text-primary-600 dark:text-primary-400"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="page-title text-primary-600 dark:text-primary-400">{title}</h1>
|
||||
<div class="nav-menu-container relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Navigation menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
|
||||
>
|
||||
{#each navItems as item (item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="block interactive px-4 py-2 text-sm {item.color} {page.url.pathname ===
|
||||
item.href
|
||||
? 'font-semibold'
|
||||
: ''}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if subtitleSnippet}
|
||||
<p class="page-subtitle">
|
||||
{@render subtitleSnippet({ toggleMenu })}
|
||||
</p>
|
||||
{:else if subtitle}
|
||||
<p class="page-subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Spacer to balance the back button and center the content -->
|
||||
<div class="w-7"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- No back button - simple layout -->
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="page-title text-primary-600 dark:text-primary-400">{title}</h1>
|
||||
<div class="nav-menu-container relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Navigation menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
|
||||
>
|
||||
{#each navItems as item (item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="block interactive px-4 py-2 text-sm {item.color} {page.url.pathname ===
|
||||
item.href
|
||||
? 'font-semibold'
|
||||
: ''}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if subtitleSnippet}
|
||||
<p class="page-subtitle">
|
||||
{@render subtitleSnippet({ toggleMenu })}
|
||||
</p>
|
||||
{:else if subtitle}
|
||||
<p class="page-subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
282
src/lib/components/admin/AdminListLayout.svelte
Normal file
@ -0,0 +1,282 @@
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { navigating } from '$app/state';
|
||||
import ContentContainer from '$lib/components/layout/ContentContainer.svelte';
|
||||
import MonthSelector from '$lib/components/team/MonthSelector.svelte';
|
||||
import StatusTabs from '$lib/components/team/StatusTabs.svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
items: T[];
|
||||
currentStatus: string;
|
||||
currentMonth: string;
|
||||
counts?: {
|
||||
scheduled: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
};
|
||||
item: Snippet<[T]>;
|
||||
emptyIcon?: Snippet;
|
||||
emptyTitle?: string;
|
||||
emptyText?: string;
|
||||
colorScheme?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'accent'
|
||||
| 'accent2'
|
||||
| 'accent3'
|
||||
| 'accent6'
|
||||
| 'accent7';
|
||||
// Admin-specific props
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
createButtonText?: string;
|
||||
onCreateClick?: () => void;
|
||||
emptyAction?: Snippet;
|
||||
headerActions?: Snippet;
|
||||
backHref?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
items,
|
||||
currentStatus,
|
||||
currentMonth,
|
||||
counts,
|
||||
item,
|
||||
emptyIcon,
|
||||
emptyTitle = 'No items found',
|
||||
emptyText = 'There are no items matching your current filters.',
|
||||
colorScheme = 'primary',
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Search...',
|
||||
createButtonText,
|
||||
onCreateClick,
|
||||
emptyAction,
|
||||
headerActions,
|
||||
backHref = '/admin'
|
||||
}: Props = $props();
|
||||
|
||||
let hasItems = $derived(items.length > 0);
|
||||
let isLoading = $derived(navigating.to !== null);
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
primary: 'text-primary-600 dark:text-primary-400',
|
||||
secondary: 'text-secondary-600 dark:text-secondary-400',
|
||||
accent: 'text-accent-600 dark:text-accent-400',
|
||||
accent2: 'text-accent2-600 dark:text-accent2-400',
|
||||
accent3: 'text-accent3-600 dark:text-accent3-400',
|
||||
accent6: 'text-accent6-600 dark:text-accent6-400',
|
||||
accent7: 'text-accent7-600 dark:text-accent7-400'
|
||||
};
|
||||
|
||||
function handleSearchInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
onSearchChange?.(target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title} - Admin - Nexus</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="py-8">
|
||||
<ContentContainer>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<a
|
||||
href={backHref}
|
||||
class="rounded-lg p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold md:text-3xl {colorClasses[colorScheme]}">{title}</h1>
|
||||
<p class="mt-2 text-theme-secondary">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 sm:flex">
|
||||
{#if headerActions}
|
||||
{@render headerActions()}
|
||||
{/if}
|
||||
{#if createButtonText && onCreateClick}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCreateClick}
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 whitespace-nowrap text-white transition-colors hover:bg-primary-600"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span>{createButtonText}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Row -->
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<MonthSelector value={currentMonth} />
|
||||
<div class="sm:hidden">
|
||||
<StatusTabs current={currentStatus} {counts} compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Header Actions -->
|
||||
{#if headerActions}
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2 sm:hidden">
|
||||
{@render headerActions()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Desktop Tabs -->
|
||||
<div class="mb-6 hidden sm:block">
|
||||
<StatusTabs current={currentStatus} {counts} />
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
{#if onSearchChange}
|
||||
<div class="mb-6">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
oninput={handleSearchInput}
|
||||
placeholder={searchPlaceholder}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme-card py-2 pr-4 pl-10 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div role="tabpanel" id="{currentStatus}-panel" class="relative">
|
||||
{#if isLoading}
|
||||
<!-- Loading Overlay -->
|
||||
<div
|
||||
class="bg-theme/50 absolute inset-0 z-10 flex min-h-[200px] items-start justify-center pt-12 backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<svg
|
||||
class="h-8 w-8 animate-spin text-theme-secondary"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm text-theme-secondary">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasItems}
|
||||
<div class="space-y-3">
|
||||
{#each items as itemData, index (index)}
|
||||
{@render item(itemData)}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !isLoading}
|
||||
<!-- Empty State (only show when not loading) -->
|
||||
<div class="empty-state">
|
||||
{#if emptyIcon}
|
||||
{@render emptyIcon()}
|
||||
{:else}
|
||||
<svg
|
||||
class="empty-state-icon"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<h2 class="empty-state-title">{emptyTitle}</h2>
|
||||
<p class="empty-state-text">{emptyText}</p>
|
||||
{#if emptyAction}
|
||||
{@render emptyAction()}
|
||||
{:else if createButtonText && onCreateClick && !searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCreateClick}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-white transition-colors hover:bg-primary-600"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span>{createButtonText}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
|
||||
<!-- Mobile FAB -->
|
||||
{#if createButtonText && onCreateClick}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCreateClick}
|
||||
class="fixed right-4 bottom-20 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-primary-500 text-white shadow-lg transition-colors hover:bg-primary-600 sm:hidden"
|
||||
aria-label={createButtonText}
|
||||
>
|
||||
<svg class="h-6 w-6" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
294
src/lib/components/admin/AdminPageHeader.svelte
Normal file
@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto, beforeNavigate } from '$app/navigation';
|
||||
import { IconEdit } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
subtitleSnippet?: Snippet<[{ toggleMenu: () => void }]>;
|
||||
colorScheme?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'accent'
|
||||
| 'accent2'
|
||||
| 'accent3'
|
||||
| 'accent4'
|
||||
| 'accent5'
|
||||
| 'accent6'
|
||||
| 'accent7';
|
||||
showBackButton?: boolean;
|
||||
backHref?: string;
|
||||
showNavMenu?: boolean;
|
||||
invalidateOnBack?: boolean;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
subtitleSnippet,
|
||||
colorScheme = 'primary',
|
||||
showBackButton = true,
|
||||
backHref = '/admin',
|
||||
showNavMenu = false,
|
||||
invalidateOnBack = false,
|
||||
onEdit
|
||||
}: Props = $props();
|
||||
|
||||
function handleBack(event: MouseEvent) {
|
||||
if (invalidateOnBack) {
|
||||
event.preventDefault();
|
||||
goto(backHref, { invalidateAll: true });
|
||||
}
|
||||
}
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let isNavigatingBack = $state(false);
|
||||
|
||||
// Track when we're navigating to the back URL
|
||||
beforeNavigate(({ to }) => {
|
||||
if (to?.url.pathname === backHref) {
|
||||
isNavigatingBack = true;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen;
|
||||
}
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
primary: 'text-primary-600 dark:text-primary-400',
|
||||
secondary: 'text-secondary-600 dark:text-secondary-400',
|
||||
accent: 'text-accent-600 dark:text-accent-400',
|
||||
accent2: 'text-accent2-600 dark:text-accent2-400',
|
||||
accent3: 'text-accent3-600 dark:text-accent3-400',
|
||||
accent4: 'text-accent4-600 dark:text-accent4-400',
|
||||
accent5: 'text-accent5-600 dark:text-accent5-400',
|
||||
accent6: 'text-accent6-600 dark:text-accent6-400',
|
||||
accent7: 'text-accent7-600 dark:text-accent7-400'
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: 'Customers',
|
||||
href: '/admin/customers',
|
||||
color: 'text-accent3-600 dark:text-accent3-400'
|
||||
},
|
||||
{
|
||||
label: 'Accounts',
|
||||
href: '/admin/accounts',
|
||||
color: 'text-primary-600 dark:text-primary-400'
|
||||
},
|
||||
{
|
||||
label: 'Services',
|
||||
href: '/admin/services',
|
||||
color: 'text-secondary-600 dark:text-secondary-400'
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
href: '/admin/projects',
|
||||
color: 'text-accent-600 dark:text-accent-400'
|
||||
},
|
||||
{
|
||||
label: 'Reports',
|
||||
href: '/admin/reports',
|
||||
color: 'text-accent2-600 dark:text-accent2-400'
|
||||
},
|
||||
{
|
||||
label: 'Invoices',
|
||||
href: '/admin/invoices',
|
||||
color: 'text-accent6-600 dark:text-accent6-400'
|
||||
},
|
||||
{
|
||||
label: 'Calendar',
|
||||
href: '/admin/calendar',
|
||||
color: 'text-accent7-600 dark:text-accent7-400'
|
||||
},
|
||||
{
|
||||
label: 'Profiles',
|
||||
href: '/admin/profiles',
|
||||
color: 'text-accent4-600 dark:text-accent4-400'
|
||||
}
|
||||
];
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.nav-menu-container') && !target.closest('.menu-trigger')) {
|
||||
menuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
menuOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
|
||||
|
||||
<div class="mb-6">
|
||||
{#if showBackButton}
|
||||
<!-- 3-column grid: back button | content | spacer for centering -->
|
||||
<div class="grid grid-cols-[auto_1fr_auto] items-start gap-4">
|
||||
<a
|
||||
href={backHref}
|
||||
onclick={handleBack}
|
||||
class="rounded-lg p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Go back"
|
||||
>
|
||||
{#if isNavigatingBack}
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin {colorClasses[colorScheme]}"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="page-title {colorClasses[colorScheme]}">{title}</h1>
|
||||
{#if showNavMenu}
|
||||
<div class="nav-menu-container relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Navigation menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
|
||||
>
|
||||
{#each navItems as item (item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="block interactive px-4 py-2 text-sm {item.color} {page.url.pathname ===
|
||||
item.href
|
||||
? 'font-semibold'
|
||||
: ''}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if subtitleSnippet}
|
||||
<p class="page-subtitle">
|
||||
{@render subtitleSnippet({ toggleMenu })}
|
||||
</p>
|
||||
{:else if subtitle}
|
||||
<p class="page-subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Edit button or spacer to balance the back button -->
|
||||
{#if onEdit}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onEdit}
|
||||
class="rounded-lg p-2 text-theme-secondary transition-colors hover:bg-black/5 active:bg-black/10 dark:hover:bg-white/10 dark:active:bg-white/15"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<IconEdit class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="w-7"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- No back button - simple layout -->
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="page-title {colorClasses[colorScheme]}">{title}</h1>
|
||||
{#if showNavMenu}
|
||||
<div class="nav-menu-container relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Navigation menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
|
||||
>
|
||||
{#each navItems as item (item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="block interactive px-4 py-2 text-sm {item.color} {page.url.pathname ===
|
||||
item.href
|
||||
? 'font-semibold'
|
||||
: ''}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if subtitleSnippet}
|
||||
<p class="page-subtitle">
|
||||
{@render subtitleSnippet({ toggleMenu })}
|
||||
</p>
|
||||
{:else if subtitle}
|
||||
<p class="page-subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
142
src/lib/components/admin/DeleteConfirmModal.svelte
Normal file
@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
loading?: boolean;
|
||||
onconfirm: () => Promise<void> | void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = 'Confirm Delete',
|
||||
message = 'Are you sure you want to delete this item? This action cannot be undone.',
|
||||
confirmLabel = 'Delete',
|
||||
cancelLabel = 'Cancel',
|
||||
loading = false,
|
||||
onconfirm,
|
||||
oncancel
|
||||
}: Props = $props();
|
||||
|
||||
let error = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!onconfirm) return;
|
||||
isSubmitting = true;
|
||||
error = '';
|
||||
try {
|
||||
await onconfirm();
|
||||
open = false;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to perform action';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isSubmitting) return;
|
||||
error = '';
|
||||
open = false;
|
||||
oncancel?.();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open && !isSubmitting) {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget && !isSubmitting) {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onclick={handleBackdropClick}
|
||||
role="presentation"
|
||||
>
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="w-full max-w-sm rounded-xl border border-theme bg-theme p-6 shadow-theme-lg"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
>
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-lg border border-danger bg-danger p-3 text-sm text-danger">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="text-center">
|
||||
<!-- Warning Icon -->
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-danger">
|
||||
<svg
|
||||
class="h-6 w-6 text-danger"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 id="modal-title" class="mb-2 text-lg font-semibold text-theme">
|
||||
{title}
|
||||
</h3>
|
||||
<p id="modal-description" class="mb-6 text-sm text-theme-secondary">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={isSubmitting || loading}
|
||||
class="flex-1 rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleConfirm}
|
||||
disabled={isSubmitting || loading}
|
||||
class="btn-danger flex-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting || loading}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>Deleting...</span>
|
||||
</span>
|
||||
{:else}
|
||||
{confirmLabel}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
41
src/lib/components/admin/ItemActions.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { IconEdit, IconTrash } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
editLabel?: string;
|
||||
deleteLabel?: string;
|
||||
size?: 'sm' | 'md';
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
editLabel = 'Edit',
|
||||
deleteLabel = 'Delete',
|
||||
size = 'md',
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
|
||||
const buttonClass = size === 'sm' ? 'rounded p-1' : 'rounded p-1.5';
|
||||
const iconClass = size === 'sm' ? 'h-3 w-3' : '';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onEdit}
|
||||
class="{buttonClass} text-theme-secondary hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconEdit class={iconClass} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDelete}
|
||||
class="{buttonClass} text-theme-secondary hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label={deleteLabel}
|
||||
>
|
||||
<IconTrash class={iconClass} />
|
||||
</button>
|
||||
</div>
|
||||
30
src/lib/components/admin/SectionHeader.svelte
Normal file
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { IconPlus, IconEdit } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
buttonText?: string;
|
||||
buttonIcon?: 'plus' | 'edit' | 'none';
|
||||
onButtonClick?: () => void;
|
||||
}
|
||||
|
||||
let { title, buttonText, buttonIcon = 'plus', onButtonClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-theme">{title}</h2>
|
||||
{#if onButtonClick && buttonText}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onButtonClick}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-500/10"
|
||||
>
|
||||
{#if buttonIcon === 'plus'}
|
||||
<IconPlus />
|
||||
{:else if buttonIcon === 'edit'}
|
||||
<IconEdit />
|
||||
{/if}
|
||||
<span>{buttonText}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
218
src/lib/components/admin/accounts/AccountForm.svelte
Normal file
@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { CreateAccountStore, UpdateAccountStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { GetAccount$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import { toGlobalId } from '$lib/utils/relay';
|
||||
|
||||
type Account = NonNullable<GetAccount$result['account']>;
|
||||
|
||||
interface Props {
|
||||
account?: Account;
|
||||
customers: { id: string; name: string }[];
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { account, customers, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!account);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Convert customerId from UUID to GlobalID to match dropdown option values
|
||||
function getCustomerGlobalId(customerId: string | null | undefined): string {
|
||||
if (!customerId) return '';
|
||||
return toGlobalId('CustomerType', customerId);
|
||||
}
|
||||
|
||||
let name = $state(account?.name ?? '');
|
||||
let customerId = $state(getCustomerGlobalId(account?.customerId));
|
||||
let startDate = $state(account?.startDate ?? '');
|
||||
let endDate = $state(account?.endDate ?? '');
|
||||
let status = $state(account?.status ?? 'ACTIVE');
|
||||
|
||||
const createStore = new CreateAccountStore();
|
||||
const updateStore = new UpdateAccountStore();
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!name || !customerId || !startDate) {
|
||||
error = 'Please provide name, customer, and start date';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && account) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: account.id,
|
||||
name,
|
||||
customerId,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
status
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
name,
|
||||
customerId,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
status
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save account';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Account Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="Enter account name"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="customerId" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Customer <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="customerId"
|
||||
bind:value={customerId}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select a customer</option>
|
||||
{#each customers as customer (customer.id)}
|
||||
<option value={customer.id}>{customer.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Start Date <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="startDate"
|
||||
type="date"
|
||||
bind:value={startDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
|
||||
<input
|
||||
id="endDate"
|
||||
type="date"
|
||||
bind:value={endDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isEdit}
|
||||
<div>
|
||||
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
|
||||
<select
|
||||
id="status"
|
||||
bind:value={status}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Account'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
362
src/lib/components/admin/accounts/AddressCard.svelte
Normal file
@ -0,0 +1,362 @@
|
||||
<script lang="ts">
|
||||
import ItemActions from '$lib/components/admin/ItemActions.svelte';
|
||||
import LaborSection from './LaborSection.svelte';
|
||||
import ScopeSection from './ScopeSection.svelte';
|
||||
import { IconChevronRight, IconEdit, IconTrash } from '$lib/components/icons';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { SvelteDate, type SvelteSet } from 'svelte/reactivity';
|
||||
import type { GetAccount$result, GetScopeTemplates$result } from '$houdini';
|
||||
import type { ScopeType, AreaType, TaskType } from '$lib/stores/scopeEditor.svelte';
|
||||
|
||||
// Extract types from Houdini generated result
|
||||
type AddressType = NonNullable<GetAccount$result['account']>['addresses'][number];
|
||||
type LaborType = AddressType['labors'][number];
|
||||
type ScheduleType = AddressType['schedules'][number];
|
||||
type ScopeTemplateType = GetScopeTemplates$result['scopeTemplates'][number];
|
||||
|
||||
interface Props {
|
||||
address: AddressType;
|
||||
isExpanded: boolean;
|
||||
showLaborHistory: boolean;
|
||||
showScheduleHistory: boolean;
|
||||
expandedScopes: SvelteSet<string>;
|
||||
expandedAreas: SvelteSet<string>;
|
||||
scopeTemplates: ScopeTemplateType[];
|
||||
scopeError?: string;
|
||||
onToggleExpanded: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddSchedule: () => void;
|
||||
onEditSchedule: (schedule: ScheduleType) => void;
|
||||
onDeleteSchedule: (scheduleId: string) => void;
|
||||
onToggleScheduleHistory: () => void;
|
||||
onAddLabor: () => void;
|
||||
onEditLabor: (labor: LaborType) => void;
|
||||
onDeleteLabor: (laborId: string) => void;
|
||||
onToggleLaborHistory: () => void;
|
||||
onToggleScope: (scopeId: string) => void;
|
||||
onToggleArea: (areaId: string) => void;
|
||||
onAddScope: () => void;
|
||||
onAddScopeFromTemplate: (templateId: string) => void;
|
||||
onEditScope: (scope: ScopeType) => void;
|
||||
onDeleteScope: (scopeId: string) => void;
|
||||
onAddArea: (scopeId: string, nextOrder: number) => void;
|
||||
onEditArea: (scopeId: string, area: AreaType) => void;
|
||||
onDeleteArea: (areaId: string) => void;
|
||||
onAddTask: (areaId: string, nextOrder: number) => void;
|
||||
onEditTask: (areaId: string, task: TaskType) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onGenerateServices?: (addressId: string, scheduleId: string) => void;
|
||||
onClearScopeError?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
address,
|
||||
isExpanded,
|
||||
showLaborHistory,
|
||||
showScheduleHistory,
|
||||
expandedScopes,
|
||||
expandedAreas,
|
||||
scopeTemplates,
|
||||
scopeError = '',
|
||||
onToggleExpanded,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddSchedule,
|
||||
onEditSchedule,
|
||||
onDeleteSchedule,
|
||||
onToggleScheduleHistory,
|
||||
onAddLabor,
|
||||
onEditLabor,
|
||||
onDeleteLabor,
|
||||
onToggleLaborHistory,
|
||||
onToggleScope,
|
||||
onToggleArea,
|
||||
onAddScope,
|
||||
onAddScopeFromTemplate,
|
||||
onEditScope,
|
||||
onDeleteScope,
|
||||
onAddArea,
|
||||
onEditArea,
|
||||
onDeleteArea,
|
||||
onAddTask,
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
onGenerateServices,
|
||||
onClearScopeError
|
||||
}: Props = $props();
|
||||
|
||||
// Separate current and past schedules
|
||||
let currentSchedules = $derived(
|
||||
address.schedules?.filter((s) => {
|
||||
if (!s.endDate) return true;
|
||||
const endDate = new Date(s.endDate);
|
||||
const today = new SvelteDate();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return endDate >= today;
|
||||
}) ?? []
|
||||
);
|
||||
|
||||
let pastSchedules = $derived(
|
||||
address.schedules?.filter((s) => {
|
||||
if (!s.endDate) return false;
|
||||
const endDate = new Date(s.endDate);
|
||||
const today = new SvelteDate();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return endDate < today;
|
||||
}) ?? []
|
||||
);
|
||||
|
||||
function getScheduleDays(schedule: ScheduleType): string[] {
|
||||
const days: string[] = [];
|
||||
if (schedule.mondayService) days.push('Mon');
|
||||
if (schedule.tuesdayService) days.push('Tue');
|
||||
if (schedule.wednesdayService) days.push('Wed');
|
||||
if (schedule.thursdayService) days.push('Thu');
|
||||
if (schedule.fridayService) days.push('Fri');
|
||||
if (schedule.saturdayService) days.push('Sat');
|
||||
if (schedule.sundayService) days.push('Sun');
|
||||
return days;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-theme bg-theme {address.isPrimary
|
||||
? 'border-l-2 border-l-primary-500'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Address Header -->
|
||||
<div class="flex items-start justify-between gap-1 p-3">
|
||||
<button type="button" onclick={onToggleExpanded} class="min-w-0 flex-1 text-left">
|
||||
<span class="flex items-center gap-2">
|
||||
<IconChevronRight
|
||||
class="h-4 w-4 flex-shrink-0 text-theme-muted transition-transform {isExpanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
<span class="font-medium text-theme"
|
||||
>{address.name ||
|
||||
(address.isPrimary ? 'Primary Service Address' : 'Service Address')}</span
|
||||
>
|
||||
{#if !address.isActive}
|
||||
<span class="badge-neutral">Inactive</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="mt-0.5 block pl-6 text-sm text-theme-secondary">
|
||||
{address.streetAddress}, {address.city}, {address.state}
|
||||
{address.zipCode}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onEdit}
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-300"
|
||||
aria-label="Edit address"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDelete}
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-500 dark:text-gray-400 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete address"
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Expanded Content -->
|
||||
{#if isExpanded}
|
||||
<div class="border-t border-theme px-3 pb-3">
|
||||
<!-- Notes -->
|
||||
{#if address.notes}
|
||||
<p class="mt-3 text-sm text-theme-secondary italic">{address.notes}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Schedules for this address -->
|
||||
<div class="mt-3 border-t border-theme pt-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<p class="text-xs font-medium tracking-wide text-theme-muted uppercase">Schedules</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAddSchedule}
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
+ Add Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if currentSchedules.length > 0 || pastSchedules.length > 0}
|
||||
<div class="space-y-2">
|
||||
<!-- Current Schedules -->
|
||||
{#each currentSchedules as schedule (schedule.id)}
|
||||
{@const scheduleDays = getScheduleDays(schedule)}
|
||||
{@const isCurrentSchedule = !schedule.endDate}
|
||||
<div class="rounded border border-theme bg-theme-card p-2 text-sm">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-theme"
|
||||
>{schedule.name || 'Unnamed Schedule'}</span
|
||||
>
|
||||
<span class="text-xs text-theme-muted">
|
||||
{#if isCurrentSchedule}
|
||||
As of {formatDate(schedule.startDate) || '—'}
|
||||
{:else}
|
||||
{formatDate(schedule.startDate) || '—'} - {formatDate(schedule.endDate)}
|
||||
{/if}
|
||||
</span>
|
||||
{#if isCurrentSchedule && onGenerateServices}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onGenerateServices(address.id, schedule.id)}
|
||||
class="rounded bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-700 transition-colors hover:bg-secondary-200 dark:bg-secondary-900/40 dark:text-secondary-400 dark:hover:bg-secondary-900/60"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#if schedule.weekendService}
|
||||
<span
|
||||
class="rounded bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-400"
|
||||
>
|
||||
Weekend
|
||||
</span>
|
||||
{:else if scheduleDays.length > 0}
|
||||
{#each scheduleDays as day (day)}
|
||||
<span
|
||||
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-xs text-theme-muted">No days selected</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if schedule.scheduleException}
|
||||
<p class="mt-1 text-xs text-theme-muted italic">
|
||||
{schedule.scheduleException}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<ItemActions
|
||||
size="sm"
|
||||
editLabel="Edit schedule"
|
||||
deleteLabel="Delete schedule"
|
||||
onEdit={() => onEditSchedule(schedule)}
|
||||
onDelete={() => onDeleteSchedule(schedule.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Past Schedules (collapsed by default) -->
|
||||
{#if pastSchedules.length > 0}
|
||||
<div class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggleScheduleHistory}
|
||||
class="text-xs font-medium text-theme-muted hover:text-theme-secondary"
|
||||
>
|
||||
{showScheduleHistory ? 'Hide' : 'Show'} past schedules ({pastSchedules.length})
|
||||
</button>
|
||||
|
||||
{#if showScheduleHistory}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each pastSchedules as schedule (schedule.id)}
|
||||
{@const scheduleDays = getScheduleDays(schedule)}
|
||||
<div class="rounded border border-theme bg-theme-card p-2 text-sm opacity-60">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-theme"
|
||||
>{schedule.name || 'Unnamed Schedule'}</span
|
||||
>
|
||||
<span class="text-xs text-theme-muted">
|
||||
{formatDate(schedule.startDate) || '—'} - {formatDate(
|
||||
schedule.endDate
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#if schedule.weekendService}
|
||||
<span
|
||||
class="rounded bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-400"
|
||||
>
|
||||
Weekend
|
||||
</span>
|
||||
{:else if scheduleDays.length > 0}
|
||||
{#each scheduleDays as day (day)}
|
||||
<span
|
||||
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-xs text-theme-muted">No days selected</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<ItemActions
|
||||
size="sm"
|
||||
editLabel="Edit schedule"
|
||||
deleteLabel="Delete schedule"
|
||||
onEdit={() => onEditSchedule(schedule)}
|
||||
onDelete={() => onDeleteSchedule(schedule.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-theme-muted">No schedules configured</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Labor for this address -->
|
||||
<LaborSection
|
||||
labors={address.labors}
|
||||
showHistory={showLaborHistory}
|
||||
onAdd={onAddLabor}
|
||||
onEdit={onEditLabor}
|
||||
onDelete={onDeleteLabor}
|
||||
onToggleHistory={onToggleLaborHistory}
|
||||
/>
|
||||
|
||||
<!-- Scopes for this address -->
|
||||
<ScopeSection
|
||||
scopes={address.scopes ?? []}
|
||||
{scopeTemplates}
|
||||
{expandedScopes}
|
||||
{expandedAreas}
|
||||
{scopeError}
|
||||
{onToggleScope}
|
||||
{onToggleArea}
|
||||
{onAddScope}
|
||||
{onAddScopeFromTemplate}
|
||||
{onEditScope}
|
||||
{onDeleteScope}
|
||||
{onAddArea}
|
||||
{onEditArea}
|
||||
{onDeleteArea}
|
||||
{onAddTask}
|
||||
{onEditTask}
|
||||
{onDeleteTask}
|
||||
onClearError={onClearScopeError}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
128
src/lib/components/admin/accounts/AreaForm.svelte
Normal file
@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { createArea, updateArea } from '$lib/utils/scopes';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { GetAccount$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Area = NonNullable<
|
||||
GetAccount$result['account']
|
||||
>['addresses'][number]['scopes'][number]['areas'][number];
|
||||
|
||||
interface Props {
|
||||
scopeId: string;
|
||||
area?: Area;
|
||||
nextOrder?: number;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { scopeId, area, nextOrder = 0, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!area);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let name = $state(area?.name ?? '');
|
||||
let order = $state(area?.order ?? nextOrder);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!name.trim()) {
|
||||
error = 'Please provide an area name';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && area) {
|
||||
await updateArea({
|
||||
id: area.id,
|
||||
name: name.trim(),
|
||||
order
|
||||
});
|
||||
} else {
|
||||
await createArea({
|
||||
scopeId,
|
||||
name: name.trim(),
|
||||
order
|
||||
});
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save area';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Area Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="e.g., Kitchen, Lobby, Restrooms"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="order" class="mb-1.5 block text-sm font-medium text-theme"> Display Order </label>
|
||||
<input
|
||||
id="order"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={order}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-theme-muted">Lower numbers appear first</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Add Area'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
73
src/lib/components/admin/accounts/ContactListItem.svelte
Normal file
@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { IconEdit, IconTrash } from '$lib/components/icons';
|
||||
|
||||
interface Contact {
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
isPrimary: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
contact: Contact;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { contact, onEdit, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-theme bg-theme p-3">
|
||||
{#if contact.isPrimary || !contact.isActive}
|
||||
<div class="mb-1.5 flex flex-wrap gap-1.5 sm:hidden">
|
||||
{#if contact.isPrimary}
|
||||
<span class="badge-primary">Primary</span>
|
||||
{/if}
|
||||
{#if !contact.isActive}
|
||||
<span class="badge-neutral">Inactive</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-theme">{contact.fullName}</span>
|
||||
{#if contact.isPrimary}
|
||||
<span class="hidden badge-primary sm:inline-flex">Primary</span>
|
||||
{/if}
|
||||
{#if !contact.isActive}
|
||||
<span class="hidden badge-neutral sm:inline-flex">Inactive</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onEdit}
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-300"
|
||||
aria-label="Edit contact"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDelete}
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-500 dark:text-gray-400 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete contact"
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if contact.email || contact.phone}
|
||||
<div class="mt-2 flex flex-col gap-1 sm:flex-row sm:flex-wrap sm:gap-x-4">
|
||||
{#if contact.email}
|
||||
<p class="text-sm text-theme-secondary">{contact.email}</p>
|
||||
{/if}
|
||||
{#if contact.phone}
|
||||
<p class="text-sm text-theme-muted">{contact.phone}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
171
src/lib/components/admin/accounts/LaborForm.svelte
Normal file
@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { CreateLaborStore, UpdateLaborStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { GetAccount$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Labor = NonNullable<GetAccount$result['account']>['addresses'][number]['labors'][number];
|
||||
|
||||
interface Props {
|
||||
accountAddressId: string;
|
||||
labor?: Labor;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { accountAddressId, labor, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!labor);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let amount = $state(labor?.amount?.toString() ?? '');
|
||||
let startDate = $state(labor?.startDate ?? '');
|
||||
let endDate = $state(labor?.endDate ?? '');
|
||||
|
||||
const createStore = new CreateLaborStore();
|
||||
const updateStore = new UpdateLaborStore();
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const parsedAmount = parseFloat(amount);
|
||||
if (isNaN(parsedAmount) || parsedAmount < 0) {
|
||||
error = 'Please provide a valid amount';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startDate) {
|
||||
error = 'Please provide a start date';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && labor) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: labor.id,
|
||||
amount: parsedAmount,
|
||||
startDate,
|
||||
endDate: endDate || null
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
accountAddressId,
|
||||
amount: parsedAmount,
|
||||
startDate,
|
||||
endDate: endDate || null
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save labor';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="amount" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Labor Rate (per visit) <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={amount}
|
||||
disabled={submitting}
|
||||
placeholder="0.00"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Start Date <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="startDate"
|
||||
type="date"
|
||||
bind:value={startDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
|
||||
<input
|
||||
id="endDate"
|
||||
type="date"
|
||||
bind:value={endDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-theme-muted">
|
||||
Leave end date empty to mark this as the current labor rate.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Add Labor'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
128
src/lib/components/admin/accounts/LaborSection.svelte
Normal file
@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import ItemActions from '$lib/components/admin/ItemActions.svelte';
|
||||
import { IconEdit, IconTrash } from '$lib/components/icons';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
interface Labor {
|
||||
id: string;
|
||||
amount: number | null;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
labors: Labor[];
|
||||
showHistory: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (labor: Labor) => void;
|
||||
onDelete: (laborId: string) => void;
|
||||
onToggleHistory: () => void;
|
||||
}
|
||||
|
||||
let { labors, showHistory, onAdd, onEdit, onDelete, onToggleHistory }: Props = $props();
|
||||
|
||||
let currentLabor = $derived(labors.find((l) => !l.endDate));
|
||||
let historicalLabors = $derived(labors.filter((l) => l.endDate));
|
||||
|
||||
function formatCurrency(amount: number | null | undefined): string {
|
||||
if (amount === null || amount === undefined) return '$0.00';
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-3 border-t border-theme pt-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<p class="text-xs font-medium tracking-wide text-theme-muted uppercase">Labor</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAdd}
|
||||
class="text-xs font-medium text-primary-500 hover:text-primary-600"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if currentLabor}
|
||||
<div
|
||||
class="rounded border-2 border-blue-200 bg-blue-50 p-2 text-sm dark:border-blue-800 dark:bg-blue-900/20"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold text-blue-700 dark:text-blue-400">
|
||||
{formatCurrency(currentLabor.amount)}
|
||||
</span>
|
||||
<span
|
||||
class="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-400"
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEdit(currentLabor)}
|
||||
class="rounded p-1 text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
aria-label="Edit labor"
|
||||
>
|
||||
<IconEdit class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDelete(currentLabor.id)}
|
||||
class="rounded p-1 text-blue-600 hover:bg-red-50 hover:text-red-500 dark:text-blue-400 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete labor"
|
||||
>
|
||||
<IconTrash class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-blue-600 dark:text-blue-500">
|
||||
As of {formatDate(currentLabor.startDate) || '—'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-theme-muted">No current labor rate set.</p>
|
||||
{/if}
|
||||
|
||||
{#if historicalLabors.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggleHistory}
|
||||
class="mt-2 flex w-full items-center justify-between text-xs text-theme-secondary hover:text-theme"
|
||||
>
|
||||
<span>History ({historicalLabors.length})</span>
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform {showHistory ? 'rotate-180' : ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showHistory}
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each historicalLabors as labor (labor.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded border border-theme bg-theme-card p-2 text-xs"
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium text-theme">{formatCurrency(labor.amount)}</span>
|
||||
<span class="text-theme-muted">
|
||||
({formatDate(labor.startDate) || '—'} - {formatDate(labor.endDate) || 'Present'})
|
||||
</span>
|
||||
</div>
|
||||
<ItemActions
|
||||
size="sm"
|
||||
editLabel="Edit labor"
|
||||
deleteLabel="Delete labor"
|
||||
onEdit={() => onEdit(labor)}
|
||||
onDelete={() => onDelete(labor.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
216
src/lib/components/admin/accounts/RevenueForm.svelte
Normal file
@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { CreateRevenueStore, UpdateRevenueStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { GetAccount$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
|
||||
type Revenue = NonNullable<GetAccount$result['account']>['revenues'][number];
|
||||
|
||||
interface WaveProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unitPrice: number | string;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
accountId: string;
|
||||
revenue?: Revenue;
|
||||
waveProducts?: WaveProduct[];
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { accountId, revenue, waveProducts = [], onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!revenue);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let amount = $state(revenue?.amount?.toString() ?? '');
|
||||
let startDate = $state(revenue?.startDate ?? '');
|
||||
let endDate = $state(revenue?.endDate ?? '');
|
||||
let waveServiceId = $state(revenue?.waveServiceId ?? '');
|
||||
|
||||
// Find currently linked product for display
|
||||
let linkedProduct = $derived(
|
||||
waveServiceId ? waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) : null
|
||||
);
|
||||
|
||||
const createStore = new CreateRevenueStore();
|
||||
const updateStore = new UpdateRevenueStore();
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const parsedAmount = parseFloat(amount);
|
||||
if (isNaN(parsedAmount) || parsedAmount < 0) {
|
||||
error = 'Please provide a valid amount';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startDate) {
|
||||
error = 'Please provide a start date';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && revenue) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: revenue.id,
|
||||
amount: parsedAmount,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
waveServiceId: waveServiceId || ''
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
accountId,
|
||||
amount: parsedAmount,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
waveServiceId: waveServiceId || ''
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save revenue';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="amount" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Monthly Revenue <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={amount}
|
||||
disabled={submitting}
|
||||
placeholder="0.00"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Start Date <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="startDate"
|
||||
type="date"
|
||||
bind:value={startDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
|
||||
<input
|
||||
id="endDate"
|
||||
type="date"
|
||||
bind:value={endDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-theme-muted">
|
||||
Leave end date empty to mark this as the current revenue rate.
|
||||
</p>
|
||||
|
||||
{#if waveProducts.length > 0}
|
||||
<div class="border-t border-theme pt-4">
|
||||
<label for="waveProduct" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Wave Product
|
||||
</label>
|
||||
<select
|
||||
id="waveProduct"
|
||||
bind:value={waveServiceId}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">No linked product</option>
|
||||
{#each waveProducts as product (product.id)}
|
||||
{@const decodedId = fromGlobalId(product.id)}
|
||||
<option value={decodedId}>
|
||||
{product.name} (${Number(product.unitPrice).toFixed(2)})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if linkedProduct}
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
Currently linked to: {linkedProduct.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Add Revenue'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
170
src/lib/components/admin/accounts/RevenueSection.svelte
Normal file
@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import SectionHeader from '$lib/components/admin/SectionHeader.svelte';
|
||||
import ItemActions from '$lib/components/admin/ItemActions.svelte';
|
||||
import { IconEdit, IconTrash } from '$lib/components/icons';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
|
||||
interface Revenue {
|
||||
id: string;
|
||||
amount: number | null;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
waveServiceId: string | null;
|
||||
}
|
||||
|
||||
interface WaveProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unitPrice: number;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
revenues: Revenue[];
|
||||
waveProducts?: WaveProduct[];
|
||||
onAdd: () => void;
|
||||
onEdit: (revenue: Revenue) => void;
|
||||
onDelete: (revenueId: string) => void;
|
||||
}
|
||||
|
||||
let { revenues, waveProducts = [], onAdd, onEdit, onDelete }: Props = $props();
|
||||
|
||||
let currentRevenue = $derived(revenues.find((r) => !r.endDate));
|
||||
let historicalRevenues = $derived(revenues.filter((r) => r.endDate));
|
||||
let showHistoricalRevenues = $state(false);
|
||||
|
||||
function formatCurrency(amount: number | null | undefined): string {
|
||||
if (amount === null || amount === undefined) return '$0.00';
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||
}
|
||||
|
||||
function getLinkedProduct(waveServiceId: string | null | undefined): WaveProduct | null {
|
||||
if (!waveServiceId) return null;
|
||||
return waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) ?? null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card-padded">
|
||||
<SectionHeader title="Revenue" buttonText="Add" onButtonClick={onAdd} />
|
||||
|
||||
{#if currentRevenue}
|
||||
{@const linkedProduct = getLinkedProduct(currentRevenue.waveServiceId)}
|
||||
<!-- Current revenue shown prominently -->
|
||||
<div
|
||||
class="mb-4 rounded-lg border-2 border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl font-bold text-green-700 dark:text-green-400">
|
||||
{formatCurrency(currentRevenue.amount)}
|
||||
</span>
|
||||
<span class="badge-success">Current</span>
|
||||
{#if currentRevenue.waveServiceId}
|
||||
<span class="badge-primary">Linked</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-green-600 dark:text-green-500">
|
||||
As of {formatDate(currentRevenue.startDate) || '—'}
|
||||
</p>
|
||||
{#if linkedProduct}
|
||||
<p class="mt-1 text-sm text-green-600 dark:text-green-500">
|
||||
Wave: {linkedProduct.name}
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-xs text-green-500 dark:text-green-600">
|
||||
{currentRevenue.waveServiceId}
|
||||
</p>
|
||||
{:else if currentRevenue.waveServiceId}
|
||||
<p class="mt-1 font-mono text-xs text-green-500 dark:text-green-600">
|
||||
{currentRevenue.waveServiceId}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEdit(currentRevenue)}
|
||||
class="rounded p-1.5 text-green-600 hover:bg-green-100 dark:text-green-400 dark:hover:bg-green-900/40"
|
||||
aria-label="Edit revenue"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDelete(currentRevenue.id)}
|
||||
class="rounded p-1.5 text-green-600 hover:bg-red-50 hover:text-red-500 dark:text-green-400 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete revenue"
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-4 text-center text-theme-muted">No current revenue rate set.</p>
|
||||
{/if}
|
||||
|
||||
<!-- Historical revenues (collapsed by default) -->
|
||||
{#if historicalRevenues.length > 0}
|
||||
<div class="border-t border-theme pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showHistoricalRevenues = !showHistoricalRevenues)}
|
||||
class="flex w-full items-center justify-between text-sm font-medium text-theme-secondary hover:text-theme"
|
||||
>
|
||||
<span>Historical Revenue ({historicalRevenues.length})</span>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform {showHistoricalRevenues ? 'rotate-180' : ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showHistoricalRevenues}
|
||||
<div class="mt-3 space-y-2">
|
||||
{#each historicalRevenues as revenue (revenue.id)}
|
||||
{@const linkedProduct = getLinkedProduct(revenue.waveServiceId)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-theme bg-theme p-3"
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-theme">{formatCurrency(revenue.amount)}</span>
|
||||
{#if revenue.waveServiceId}
|
||||
<span class="badge-primary text-xs">Linked</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-theme-muted">
|
||||
{formatDate(revenue.startDate) || '—'} - {formatDate(revenue.endDate) ||
|
||||
'Present'}
|
||||
</p>
|
||||
{#if linkedProduct}
|
||||
<p class="text-xs text-theme-muted">Wave: {linkedProduct.name}</p>
|
||||
<p class="font-mono text-xs text-theme-muted">{revenue.waveServiceId}</p>
|
||||
{:else if revenue.waveServiceId}
|
||||
<p class="font-mono text-xs text-theme-muted">{revenue.waveServiceId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ItemActions
|
||||
editLabel="Edit revenue"
|
||||
deleteLabel="Delete revenue"
|
||||
onEdit={() => onEdit(revenue)}
|
||||
onDelete={() => onDelete(revenue.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
347
src/lib/components/admin/accounts/ScheduleForm.svelte
Normal file
@ -0,0 +1,347 @@
|
||||
<script lang="ts">
|
||||
import { CreateScheduleStore, UpdateScheduleStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { GetAccount$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Schedule = NonNullable<
|
||||
GetAccount$result['account']
|
||||
>['addresses'][number]['schedules'][number];
|
||||
|
||||
interface Props {
|
||||
accountAddressId: string;
|
||||
schedule?: Schedule;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { accountAddressId, schedule, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!schedule);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Form fields
|
||||
let name = $state(schedule?.name ?? '');
|
||||
let startDate = $state(schedule?.startDate ?? '');
|
||||
let endDate = $state(schedule?.endDate ?? '');
|
||||
let mondayService = $state(schedule?.mondayService ?? false);
|
||||
let tuesdayService = $state(schedule?.tuesdayService ?? false);
|
||||
let wednesdayService = $state(schedule?.wednesdayService ?? false);
|
||||
let thursdayService = $state(schedule?.thursdayService ?? false);
|
||||
let fridayService = $state(schedule?.fridayService ?? false);
|
||||
let saturdayService = $state(schedule?.saturdayService ?? false);
|
||||
let sundayService = $state(schedule?.sundayService ?? false);
|
||||
let weekendService = $state(schedule?.weekendService ?? false);
|
||||
let scheduleException = $state(schedule?.scheduleException ?? '');
|
||||
|
||||
const createStore = new CreateScheduleStore();
|
||||
const updateStore = new UpdateScheduleStore();
|
||||
|
||||
// When weekend service is toggled on, clear Fri/Sat/Sun
|
||||
function handleWeekendServiceChange(checked: boolean) {
|
||||
weekendService = checked;
|
||||
if (checked) {
|
||||
fridayService = false;
|
||||
saturdayService = false;
|
||||
sundayService = false;
|
||||
}
|
||||
}
|
||||
|
||||
// When any of Fri/Sat/Sun is toggled on, clear weekend service
|
||||
function handleWeekendDayChange(day: 'friday' | 'saturday' | 'sunday', checked: boolean) {
|
||||
if (day === 'friday') fridayService = checked;
|
||||
if (day === 'saturday') saturdayService = checked;
|
||||
if (day === 'sunday') sundayService = checked;
|
||||
if (checked) {
|
||||
weekendService = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!startDate) {
|
||||
error = 'Please provide a start date';
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAnyService =
|
||||
mondayService ||
|
||||
tuesdayService ||
|
||||
wednesdayService ||
|
||||
thursdayService ||
|
||||
fridayService ||
|
||||
saturdayService ||
|
||||
sundayService ||
|
||||
weekendService;
|
||||
|
||||
if (!hasAnyService) {
|
||||
error = 'Please select at least one service day or weekend service';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && schedule) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: schedule.id,
|
||||
accountAddressId,
|
||||
name: name || null,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
mondayService,
|
||||
tuesdayService,
|
||||
wednesdayService,
|
||||
thursdayService,
|
||||
fridayService,
|
||||
saturdayService,
|
||||
sundayService,
|
||||
weekendService,
|
||||
scheduleException: scheduleException || null
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
accountAddressId,
|
||||
name: name || null,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
mondayService,
|
||||
tuesdayService,
|
||||
wednesdayService,
|
||||
thursdayService,
|
||||
fridayService,
|
||||
saturdayService,
|
||||
sundayService,
|
||||
weekendService,
|
||||
scheduleException: scheduleException || null
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save schedule';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
|
||||
const dayCheckboxClass =
|
||||
'h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme"> Schedule Name </label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="e.g., Summer Schedule"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Start Date <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="startDate"
|
||||
type="date"
|
||||
bind:value={startDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
|
||||
<input
|
||||
id="endDate"
|
||||
type="date"
|
||||
bind:value={endDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-theme-muted">
|
||||
Leave end date empty to mark this as the current active schedule.
|
||||
</p>
|
||||
|
||||
<!-- Service Days -->
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-theme">
|
||||
Service Days <span class="text-red-500">*</span>
|
||||
</p>
|
||||
|
||||
<!-- Weekday checkboxes -->
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={mondayService}
|
||||
disabled={submitting}
|
||||
class={dayCheckboxClass}
|
||||
/>
|
||||
<span class="text-sm text-theme">Mon</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={tuesdayService}
|
||||
disabled={submitting}
|
||||
class={dayCheckboxClass}
|
||||
/>
|
||||
<span class="text-sm text-theme">Tue</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={wednesdayService}
|
||||
disabled={submitting}
|
||||
class={dayCheckboxClass}
|
||||
/>
|
||||
<span class="text-sm text-theme">Wed</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={thursdayService}
|
||||
disabled={submitting}
|
||||
class={dayCheckboxClass}
|
||||
/>
|
||||
<span class="text-sm text-theme">Thu</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fridayService}
|
||||
onchange={(e) => handleWeekendDayChange('friday', e.currentTarget.checked)}
|
||||
disabled={submitting || weekendService}
|
||||
class={dayCheckboxClass}
|
||||
/>
|
||||
<span class="text-sm text-theme {weekendService ? 'opacity-50' : ''}">Fri</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saturdayService}
|
||||
onchange={(e) => handleWeekendDayChange('saturday', e.currentTarget.checked)}
|
||||
disabled={submitting || weekendService}
|
||||
class={dayCheckboxClass}
|
||||
/>
|
||||
<span class="text-sm text-theme {weekendService ? 'opacity-50' : ''}">Sat</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sundayService}
|
||||
onchange={(e) => handleWeekendDayChange('sunday', e.currentTarget.checked)}
|
||||
disabled={submitting || weekendService}
|
||||
class={dayCheckboxClass}
|
||||
/>
|
||||
<span class="text-sm text-theme {weekendService ? 'opacity-50' : ''}">Sun</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Weekend Service toggle -->
|
||||
<div class="mt-3 border-t border-theme pt-3">
|
||||
<label class="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={weekendService}
|
||||
onchange={(e) => handleWeekendServiceChange(e.currentTarget.checked)}
|
||||
disabled={submitting}
|
||||
class="mt-0.5 {dayCheckboxClass}"
|
||||
/>
|
||||
<span class="flex flex-col">
|
||||
<span class="text-sm font-medium text-theme">Weekend Service</span>
|
||||
<span class="text-xs text-theme-muted">
|
||||
Single service visit on Friday that can be performed anytime Friday-Sunday
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Exception -->
|
||||
<div>
|
||||
<label for="scheduleException" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Schedule Notes / Exceptions
|
||||
</label>
|
||||
<textarea
|
||||
id="scheduleException"
|
||||
bind:value={scheduleException}
|
||||
disabled={submitting}
|
||||
rows={3}
|
||||
placeholder="Any special requirements or exceptions..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Add Schedule'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
164
src/lib/components/admin/accounts/ScopeForm.svelte
Normal file
@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { createScope, updateScope } from '$lib/utils/scopes';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { GetAccount$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Scope = NonNullable<GetAccount$result['account']>['addresses'][number]['scopes'][number];
|
||||
|
||||
interface Props {
|
||||
accountId: string;
|
||||
accountAddressId: string;
|
||||
scope?: Scope;
|
||||
existingScopes?: Scope[];
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
accountId,
|
||||
accountAddressId,
|
||||
scope,
|
||||
existingScopes = [],
|
||||
onSuccess,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!scope);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let name = $state(scope?.name ?? '');
|
||||
let description = $state(scope?.description ?? '');
|
||||
|
||||
// Check if there's already an active scope (other than the one being edited)
|
||||
let existingActiveScope = $derived(existingScopes.find((s) => s.isActive && s.id !== scope?.id));
|
||||
let hasExistingActiveScope = $derived(!!existingActiveScope);
|
||||
|
||||
// Default to active only if there's no existing active scope
|
||||
let isActive = $state(scope?.isActive ?? !existingScopes.some((s) => s.isActive));
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!name.trim()) {
|
||||
error = 'Please provide a scope name';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && scope) {
|
||||
await updateScope({
|
||||
id: scope.id,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
isActive
|
||||
});
|
||||
} else {
|
||||
await createScope({
|
||||
accountId,
|
||||
accountAddressId,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
isActive
|
||||
});
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save scope';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Scope Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="e.g., Standard Cleaning"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
disabled={submitting}
|
||||
rows="3"
|
||||
placeholder="Optional description of this scope"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="isActive"
|
||||
type="checkbox"
|
||||
bind:checked={isActive}
|
||||
disabled={submitting || (hasExistingActiveScope && !scope?.isActive)}
|
||||
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<label for="isActive" class="text-sm text-theme">Active scope</label>
|
||||
</div>
|
||||
{#if hasExistingActiveScope && !scope?.isActive}
|
||||
<p class="mt-1.5 text-xs text-warning">
|
||||
"{existingActiveScope?.name}" is currently the active scope. Deactivate it first to make
|
||||
this scope active.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Scope'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
700
src/lib/components/admin/accounts/ScopeSection.svelte
Normal file
@ -0,0 +1,700 @@
|
||||
<script lang="ts">
|
||||
import type { ScopeType, AreaType, TaskType } from '$lib/stores/scopeEditor.svelte';
|
||||
import { getFrequencyShortLabel } from '$lib/stores/scopeEditor.svelte';
|
||||
import type { GetScopeTemplates$result } from '$houdini';
|
||||
|
||||
type ScopeTemplateType = GetScopeTemplates$result['scopeTemplates'][number];
|
||||
|
||||
interface Props {
|
||||
scopes: ScopeType[];
|
||||
scopeTemplates?: ScopeTemplateType[];
|
||||
expandedScopes: Set<string>;
|
||||
expandedAreas: Set<string>;
|
||||
scopeError?: string;
|
||||
onToggleScope: (scopeId: string) => void;
|
||||
onToggleArea: (areaId: string) => void;
|
||||
onAddScope: () => void;
|
||||
onAddScopeFromTemplate?: (templateId: string) => void;
|
||||
onEditScope: (scope: ScopeType) => void;
|
||||
onDeleteScope: (scopeId: string) => void;
|
||||
onAddArea: (scopeId: string, nextOrder: number) => void;
|
||||
onEditArea: (scopeId: string, area: AreaType) => void;
|
||||
onDeleteArea: (areaId: string) => void;
|
||||
onAddTask: (areaId: string, nextOrder: number) => void;
|
||||
onEditTask: (areaId: string, task: TaskType) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onClearError?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
scopes,
|
||||
scopeTemplates = [],
|
||||
expandedScopes,
|
||||
expandedAreas,
|
||||
scopeError = '',
|
||||
onToggleScope,
|
||||
onToggleArea,
|
||||
onAddScope,
|
||||
onAddScopeFromTemplate,
|
||||
onEditScope,
|
||||
onDeleteScope,
|
||||
onAddArea,
|
||||
onEditArea,
|
||||
onDeleteArea,
|
||||
onAddTask,
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
onClearError
|
||||
}: Props = $props();
|
||||
|
||||
let showTemplateDropdown = $state(false);
|
||||
|
||||
// Separate active and inactive scopes
|
||||
let activeScopes = $derived(scopes.filter((s) => s.isActive));
|
||||
let inactiveScopes = $derived(scopes.filter((s) => !s.isActive));
|
||||
|
||||
// Toggle for showing scope history
|
||||
let showScopeHistory = $state(false);
|
||||
|
||||
function getTotalTasks(scope: ScopeType): number {
|
||||
return scope.areas?.reduce((acc, area) => acc + (area.tasks?.length ?? 0), 0) ?? 0;
|
||||
}
|
||||
|
||||
function sortedAreas(areas: AreaType[]): AreaType[] {
|
||||
return [...areas].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
function sortedTasks(tasks: TaskType[]): TaskType[] {
|
||||
return [...tasks].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-3 border-t border-theme pt-3">
|
||||
<!-- Scope Error Alert -->
|
||||
{#if scopeError}
|
||||
<div class="mb-2 flex items-start gap-2 rounded-lg border border-amber-400 bg-amber-50 p-2 text-xs text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="flex-1">{scopeError}</span>
|
||||
{#if onClearError}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClearError}
|
||||
class="shrink-0 font-medium underline hover:no-underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<p class="text-xs font-medium tracking-wide text-theme-muted uppercase">Scopes</p>
|
||||
<div class="relative">
|
||||
{#if scopeTemplates.length > 0 && onAddScopeFromTemplate}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showTemplateDropdown = !showTemplateDropdown)}
|
||||
class="flex items-center gap-1 text-xs font-medium text-secondary-500 hover:text-secondary-600"
|
||||
>
|
||||
+ Add Scope
|
||||
<svg class="h-3 w-3" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showTemplateDropdown}
|
||||
<!-- Backdrop to close dropdown -->
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-10"
|
||||
onclick={() => (showTemplateDropdown = false)}
|
||||
aria-label="Close dropdown"
|
||||
></button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
class="absolute right-0 z-20 mt-1 w-48 rounded-lg border border-theme bg-theme-card py-1 shadow-theme-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showTemplateDropdown = false;
|
||||
onAddScope();
|
||||
}}
|
||||
class="block w-full interactive px-3 py-1.5 text-left text-xs text-theme"
|
||||
>
|
||||
Blank Scope
|
||||
</button>
|
||||
<div class="my-1 border-t border-theme"></div>
|
||||
<p class="px-3 py-1 text-[10px] font-medium text-theme-muted uppercase">
|
||||
From Template
|
||||
</p>
|
||||
{#each scopeTemplates as template (template.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showTemplateDropdown = false;
|
||||
onAddScopeFromTemplate(template.id);
|
||||
}}
|
||||
class="block w-full interactive px-3 py-1.5 text-left text-xs text-theme"
|
||||
>
|
||||
{template.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAddScope}
|
||||
class="text-xs font-medium text-secondary-500 hover:text-secondary-600"
|
||||
>
|
||||
+ Add Scope
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if activeScopes.length > 0 || inactiveScopes.length > 0}
|
||||
<div class="space-y-2">
|
||||
<!-- Active Scopes (always visible) -->
|
||||
{#each activeScopes as scope (scope.id)}
|
||||
{@const isExpanded = expandedScopes.has(scope.id)}
|
||||
{@const totalAreas = scope.areas?.length ?? 0}
|
||||
{@const totalTasks = getTotalTasks(scope)}
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-theme bg-theme-card {scope.isActive
|
||||
? 'border-l-2 border-l-secondary-500'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Scope Header -->
|
||||
<div class="flex items-start justify-between gap-1 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onToggleScope(scope.id)}
|
||||
class="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 text-theme-muted transition-transform {isExpanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-theme">{scope.name}</span>
|
||||
{#if scope.isActive}
|
||||
<span
|
||||
class="rounded bg-secondary-100 px-1.5 py-0.5 text-xs font-medium text-secondary-700 dark:bg-secondary-900/40 dark:text-secondary-400"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="mt-0.5 block pl-6 text-xs text-theme-muted">
|
||||
{totalAreas}
|
||||
{totalAreas === 1 ? 'area' : 'areas'}, {totalTasks}
|
||||
{totalTasks === 1 ? 'task' : 'tasks'}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEditScope(scope)}
|
||||
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Edit scope"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteScope(scope.id)}
|
||||
class="rounded p-1 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete scope"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Content -->
|
||||
{#if isExpanded}
|
||||
<div class="border-t border-theme px-2 pb-2">
|
||||
{#if scope.description}
|
||||
<p class="mt-2 text-xs text-theme-muted italic">{scope.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Areas -->
|
||||
<div class="mt-2">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-theme-secondary">Areas</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onAddArea(scope.id, totalAreas)}
|
||||
class="text-xs text-secondary-500 hover:text-secondary-600"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if scope.areas && scope.areas.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each sortedAreas(scope.areas) as area (area.id)}
|
||||
{@const taskCount = area.tasks?.length ?? 0}
|
||||
{@const isAreaExpanded = expandedAreas.has(area.id)}
|
||||
<div
|
||||
class="rounded border border-l-2 border-theme border-l-secondary-400 bg-theme"
|
||||
>
|
||||
<!-- Area Header -->
|
||||
<div class="flex items-start justify-between gap-1 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onToggleArea(area.id)}
|
||||
class="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-theme-muted transition-transform {isAreaExpanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-theme">{area.name}</span>
|
||||
</span>
|
||||
<span class="mt-0.5 block pl-5 text-xs text-theme-muted">
|
||||
{taskCount}
|
||||
{taskCount === 1 ? 'task' : 'tasks'}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onAddTask(area.id, taskCount)}
|
||||
class="rounded p-1 text-secondary-500 hover:bg-secondary-500/10"
|
||||
aria-label="Add task"
|
||||
title="Add task"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEditArea(scope.id, area)}
|
||||
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Edit area"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteArea(area.id)}
|
||||
class="rounded p-1 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete area"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks (expanded content) -->
|
||||
{#if isAreaExpanded}
|
||||
<div class="border-t border-theme px-2 pb-2">
|
||||
{#if area.tasks && area.tasks.length > 0}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each sortedTasks(area.tasks) as task (task.id)}
|
||||
<div class="rounded bg-gray-50 p-2 text-xs dark:bg-gray-800/50">
|
||||
<!-- Task actions -->
|
||||
<div class="mb-2 flex items-center justify-end gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEditTask(area.id, task)}
|
||||
class="rounded p-0.5 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Edit task"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteTask(task.id)}
|
||||
class="rounded p-0.5 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete task"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Task fields stacked vertically -->
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
|
||||
>
|
||||
Scope Description
|
||||
<button
|
||||
type="button"
|
||||
class="group relative"
|
||||
aria-label="What is scope description?"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3 text-theme-muted hover:text-theme"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-[10px] text-white normal-case group-hover:block dark:bg-gray-700"
|
||||
>
|
||||
The customer-facing description of the task
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
<p class="text-theme">{task.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
|
||||
>
|
||||
Checklist Description
|
||||
<button
|
||||
type="button"
|
||||
class="group relative"
|
||||
aria-label="What is checklist description?"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3 text-theme-muted hover:text-theme"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-[10px] text-white normal-case group-hover:block dark:bg-gray-700"
|
||||
>
|
||||
What team members see during a session
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
<p class="text-theme">{task.checklistDescription || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="text-[10px] font-medium text-theme-muted uppercase"
|
||||
>Frequency</span
|
||||
>
|
||||
<p class="text-theme">
|
||||
{getFrequencyShortLabel(task.frequency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-center text-xs text-theme-muted">No tasks yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-xs text-theme-muted">No areas yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Inactive Scopes (collapsed by default) -->
|
||||
{#if inactiveScopes.length > 0}
|
||||
<div class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showScopeHistory = !showScopeHistory)}
|
||||
class="text-xs font-medium text-theme-muted hover:text-theme-secondary"
|
||||
>
|
||||
{showScopeHistory ? 'Hide' : 'Show'} scope history ({inactiveScopes.length})
|
||||
</button>
|
||||
|
||||
{#if showScopeHistory}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each inactiveScopes as scope (scope.id)}
|
||||
{@const isExpanded = expandedScopes.has(scope.id)}
|
||||
{@const totalAreas = scope.areas?.length ?? 0}
|
||||
{@const totalTasks = getTotalTasks(scope)}
|
||||
|
||||
<div class="rounded-lg border border-theme bg-theme-card opacity-60">
|
||||
<!-- Scope Header -->
|
||||
<div class="flex items-start justify-between gap-1 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onToggleScope(scope.id)}
|
||||
class="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 text-theme-muted transition-transform {isExpanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-theme">{scope.name}</span>
|
||||
<span class="badge-neutral">Inactive</span>
|
||||
</span>
|
||||
<span class="mt-0.5 block pl-6 text-xs text-theme-muted">
|
||||
{totalAreas}
|
||||
{totalAreas === 1 ? 'area' : 'areas'}, {totalTasks}
|
||||
{totalTasks === 1 ? 'task' : 'tasks'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Content (read-only for inactive scopes) -->
|
||||
{#if isExpanded}
|
||||
<div class="border-t border-theme px-2 pb-2">
|
||||
{#if scope.description}
|
||||
<p class="mt-2 text-xs text-theme-muted italic">{scope.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Areas (read-only) -->
|
||||
<div class="mt-2">
|
||||
<span class="mb-1 text-xs font-medium text-theme-secondary">Areas</span>
|
||||
|
||||
{#if scope.areas && scope.areas.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each sortedAreas(scope.areas) as area (area.id)}
|
||||
{@const taskCount = area.tasks?.length ?? 0}
|
||||
{@const isAreaExpanded = expandedAreas.has(area.id)}
|
||||
<div
|
||||
class="rounded border border-l-2 border-theme border-l-secondary-400 bg-theme"
|
||||
>
|
||||
<!-- Area Header -->
|
||||
<div class="flex items-start justify-between gap-1 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onToggleArea(area.id)}
|
||||
class="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-theme-muted transition-transform {isAreaExpanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-theme">{area.name}</span
|
||||
>
|
||||
</span>
|
||||
<span class="mt-0.5 block pl-5 text-xs text-theme-muted">
|
||||
{taskCount}
|
||||
{taskCount === 1 ? 'task' : 'tasks'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tasks (expanded content, read-only) -->
|
||||
{#if isAreaExpanded}
|
||||
<div class="border-t border-theme px-2 pb-2">
|
||||
{#if area.tasks && area.tasks.length > 0}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each sortedTasks(area.tasks) as task (task.id)}
|
||||
<div
|
||||
class="rounded bg-gray-50 p-2 text-xs dark:bg-gray-800/50"
|
||||
>
|
||||
<!-- Task fields stacked vertically (no actions) -->
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
|
||||
>
|
||||
Scope Description
|
||||
</span>
|
||||
<p class="text-theme">{task.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
|
||||
>
|
||||
Checklist Description
|
||||
</span>
|
||||
<p class="text-theme">
|
||||
{task.checklistDescription || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="text-[10px] font-medium text-theme-muted uppercase"
|
||||
>Frequency</span
|
||||
>
|
||||
<p class="text-theme">
|
||||
{getFrequencyShortLabel(task.frequency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-center text-xs text-theme-muted">
|
||||
No tasks
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-xs text-theme-muted">No areas</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-theme-muted">No scopes defined for this address.</p>
|
||||
{/if}
|
||||
</div>
|
||||
205
src/lib/components/admin/accounts/TaskForm.svelte
Normal file
@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { createTask, updateTask } from '$lib/utils/scopes';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { GetAccount$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Task = NonNullable<
|
||||
GetAccount$result['account']
|
||||
>['addresses'][number]['scopes'][number]['areas'][number]['tasks'][number];
|
||||
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'quarterly', label: 'Quarterly' },
|
||||
{ value: 'triannual', label: 'Triannual (3x/year)' },
|
||||
{ value: 'annual', label: 'Annual' },
|
||||
{ value: 'as_needed', label: 'As Needed' }
|
||||
];
|
||||
|
||||
const VALID_FREQUENCIES = new Set(FREQUENCY_OPTIONS.map((o) => o.value));
|
||||
|
||||
// Sanitize frequency to ensure it's a valid enum value (lowercase)
|
||||
function sanitizeFrequency(freq: string | null | undefined): string {
|
||||
if (!freq) return 'weekly';
|
||||
const lower = freq.toLowerCase();
|
||||
if (VALID_FREQUENCIES.has(lower)) {
|
||||
return lower;
|
||||
}
|
||||
return 'weekly'; // Default to weekly for invalid/legacy values
|
||||
}
|
||||
|
||||
interface Props {
|
||||
areaId: string;
|
||||
task?: Task;
|
||||
nextOrder?: number;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { areaId, task, nextOrder = 0, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!task);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let description = $state(task?.description ?? '');
|
||||
let checklistDescription = $state(task?.checklistDescription ?? '');
|
||||
let frequency = $state(sanitizeFrequency(task?.frequency));
|
||||
let order = $state(task?.order ?? nextOrder);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!description.trim()) {
|
||||
error = 'Please provide a task description';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && task) {
|
||||
await updateTask({
|
||||
id: task.id,
|
||||
description: description.trim(),
|
||||
checklistDescription: checklistDescription.trim() || null,
|
||||
frequency,
|
||||
isConditional: false,
|
||||
order
|
||||
});
|
||||
} else {
|
||||
await createTask({
|
||||
areaId,
|
||||
description: description.trim(),
|
||||
checklistDescription: checklistDescription.trim() || null,
|
||||
frequency,
|
||||
isConditional: false,
|
||||
order
|
||||
});
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save task';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Task Description <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
disabled={submitting}
|
||||
rows="2"
|
||||
placeholder="e.g., Empty trash cans and replace liners"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="checklistDescription" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Checklist Note
|
||||
</label>
|
||||
<input
|
||||
id="checklistDescription"
|
||||
type="text"
|
||||
bind:value={checklistDescription}
|
||||
disabled={submitting}
|
||||
placeholder="Optional short note for checklist"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-theme-muted">Shortened version shown on service checklists</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="frequency" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Frequency <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="frequency"
|
||||
bind:value={frequency}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#each FREQUENCY_OPTIONS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="order" class="mb-1.5 block text-sm font-medium text-theme"> Display Order </label>
|
||||
<input
|
||||
id="order"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={order}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Add Task'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
575
src/lib/components/admin/calendar/EventForm.svelte
Normal file
@ -0,0 +1,575 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
calendarService,
|
||||
type CalendarEvent,
|
||||
type Attendee,
|
||||
type CreateEventInput,
|
||||
type EventReminder,
|
||||
type EventReminders,
|
||||
EVENT_COLORS
|
||||
} from '$lib/services/calendar';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface TeamProfile {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface InitialValues {
|
||||
summary?: string;
|
||||
location?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
teamProfiles: TeamProfile[];
|
||||
event?: CalendarEvent | null;
|
||||
initialValues?: InitialValues;
|
||||
onSuccess?: (eventId: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { teamProfiles, event = null, initialValues, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Form fields - prioritize event (edit mode), then initialValues (pre-population), then defaults
|
||||
let summary = $state(event?.summary ?? initialValues?.summary ?? '');
|
||||
let description = $state(event?.description ?? '');
|
||||
let location = $state(event?.location ?? initialValues?.location ?? '');
|
||||
let isAllDay = $state(event ? !event.start.dateTime && !!event.start.date : false);
|
||||
|
||||
// After midnight toggle - for when work starts after midnight but belongs to previous business day
|
||||
let isAfterMidnight = $state(false);
|
||||
|
||||
// Helper to increment a date string by 1 day
|
||||
function incrementDate(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr + 'T12:00:00'); // noon to avoid DST issues
|
||||
date.setDate(date.getDate() + 1);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Parse existing event dates/times
|
||||
function parseDateTime(eventDateTime: { dateTime?: string; date?: string } | undefined): {
|
||||
date: string;
|
||||
time: string;
|
||||
} {
|
||||
if (!eventDateTime) {
|
||||
const now = new Date();
|
||||
return {
|
||||
date: now.toISOString().split('T')[0],
|
||||
time: '09:00'
|
||||
};
|
||||
}
|
||||
|
||||
if (eventDateTime.dateTime) {
|
||||
const dt = new Date(eventDateTime.dateTime);
|
||||
return {
|
||||
date: dt.toISOString().split('T')[0],
|
||||
time: dt.toTimeString().slice(0, 5)
|
||||
};
|
||||
}
|
||||
|
||||
if (eventDateTime.date) {
|
||||
return {
|
||||
date: eventDateTime.date,
|
||||
time: '09:00'
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return {
|
||||
date: now.toISOString().split('T')[0],
|
||||
time: '09:00'
|
||||
};
|
||||
}
|
||||
|
||||
const startParsed = parseDateTime(event?.start);
|
||||
const endParsed = parseDateTime(event?.end);
|
||||
|
||||
// Use initialValues for date if no event is provided
|
||||
let startDate = $state(event ? startParsed.date : (initialValues?.date ?? startParsed.date));
|
||||
let startTime = $state(startParsed.time);
|
||||
let endDate = $state(event ? endParsed.date : (initialValues?.date ?? endParsed.date));
|
||||
let endTime = $state(endParsed.time);
|
||||
|
||||
// Actual calendar dates (incremented by 1 day if after midnight is checked)
|
||||
let actualStartDate = $derived(
|
||||
isAfterMidnight && !isAllDay ? incrementDate(startDate) : startDate
|
||||
);
|
||||
let actualEndDate = $derived(isAfterMidnight && !isAllDay ? incrementDate(endDate) : endDate);
|
||||
|
||||
// Selected attendees (set of email addresses)
|
||||
let selectedAttendeeEmails = new SvelteSet<string>(event?.attendees?.map((a) => a.email) ?? []);
|
||||
|
||||
// Reminder settings
|
||||
let useDefaultReminders = $state(event?.reminders?.useDefault ?? true);
|
||||
let customReminders = $state<EventReminder[]>(event?.reminders?.overrides ?? []);
|
||||
|
||||
// Event color
|
||||
let selectedColorId = $state<string | undefined>(event?.colorId);
|
||||
|
||||
// Active team members with emails (ensure email is non-null)
|
||||
let availableAttendees = $derived(
|
||||
teamProfiles.filter(
|
||||
(p): p is typeof p & { email: string } => p.status === 'ACTIVE' && !!p.email
|
||||
)
|
||||
);
|
||||
|
||||
function toggleAttendee(email: string) {
|
||||
if (selectedAttendeeEmails.has(email)) {
|
||||
selectedAttendeeEmails.delete(email);
|
||||
} else {
|
||||
selectedAttendeeEmails.add(email);
|
||||
}
|
||||
}
|
||||
|
||||
function isAttendeeSelected(email: string): boolean {
|
||||
return selectedAttendeeEmails.has(email);
|
||||
}
|
||||
|
||||
// Reminder helper functions
|
||||
const reminderPresets = [
|
||||
{ label: '10 minutes before', minutes: 10 },
|
||||
{ label: '30 minutes before', minutes: 30 },
|
||||
{ label: '1 hour before', minutes: 60 },
|
||||
{ label: '1 day before', minutes: 1440 },
|
||||
{ label: '1 week before', minutes: 10080 }
|
||||
];
|
||||
|
||||
function addReminder() {
|
||||
customReminders = [...customReminders, { method: 'popup', minutes: 30 }];
|
||||
}
|
||||
|
||||
function removeReminder(index: number) {
|
||||
customReminders = customReminders.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function handleReminderMethodChange(index: number, e: Event) {
|
||||
const method = (e.currentTarget as HTMLSelectElement).value as 'email' | 'popup';
|
||||
customReminders = customReminders.map((r, i) => (i === index ? { ...r, method } : r));
|
||||
}
|
||||
|
||||
function handleReminderMinutesChange(index: number, e: Event) {
|
||||
const minutes = parseInt((e.currentTarget as HTMLSelectElement).value);
|
||||
customReminders = customReminders.map((r, i) => (i === index ? { ...r, minutes } : r));
|
||||
}
|
||||
|
||||
// Convert local date/time to UTC ISO string for the API
|
||||
function toUtcIsoString(date: string, time: string): string {
|
||||
// Parse as local time (browser's timezone - US/Eastern for our team)
|
||||
const localDateTime = new Date(`${date}T${time}:00`);
|
||||
// Convert to UTC ISO string (includes Z suffix)
|
||||
return localDateTime.toISOString();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!summary.trim()) {
|
||||
error = 'Please enter an event title';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build attendees from selected emails
|
||||
const attendees: Attendee[] = Array.from(selectedAttendeeEmails).map((email) => {
|
||||
const profile = teamProfiles.find((p) => p.email === email);
|
||||
return {
|
||||
email,
|
||||
displayName: profile?.fullName
|
||||
};
|
||||
});
|
||||
|
||||
// Build reminders
|
||||
const reminders: EventReminders | undefined = useDefaultReminders
|
||||
? { useDefault: true }
|
||||
: customReminders.length > 0
|
||||
? { useDefault: false, overrides: customReminders }
|
||||
: undefined;
|
||||
|
||||
// Build event input - use actualStartDate/actualEndDate for timed events (handles after-midnight shift)
|
||||
// For timed events, convert to UTC ISO string so the scheduler API can parse it
|
||||
const input: CreateEventInput = {
|
||||
summary: summary.trim(),
|
||||
description: description.trim() || undefined,
|
||||
location: location.trim() || undefined,
|
||||
start: isAllDay
|
||||
? { date: startDate }
|
||||
: { dateTime: toUtcIsoString(actualStartDate, startTime) },
|
||||
end: isAllDay
|
||||
? { date: endDate }
|
||||
: { dateTime: toUtcIsoString(actualEndDate, endTime) },
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
reminders,
|
||||
colorId: selectedColorId
|
||||
};
|
||||
|
||||
let eventId: string;
|
||||
|
||||
if (event) {
|
||||
// Update existing event
|
||||
const result = await calendarService.updateEvent(event.id, input);
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
eventId = event.id;
|
||||
} else {
|
||||
// Create new event
|
||||
const result = await calendarService.createEvent(input);
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
eventId = result.data!.id;
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.(eventId);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save event';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="flex h-full flex-col">
|
||||
<div class="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
<!-- Event Title -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1.5 block text-sm font-medium text-theme">Event Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="summary"
|
||||
bind:value={summary}
|
||||
placeholder="e.g., Team Meeting"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- All Day Toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allDay"
|
||||
bind:checked={isAllDay}
|
||||
class="h-4 w-4 rounded border-theme text-accent7-500 focus:ring-accent7-500"
|
||||
/>
|
||||
<label for="allDay" class="text-sm font-medium text-theme">All-day event</label>
|
||||
</div>
|
||||
|
||||
<!-- After Midnight Toggle (only for timed events) -->
|
||||
{#if !isAllDay}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-amber-400/50 bg-amber-50 p-3 dark:border-amber-500/30 dark:bg-amber-900/20"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="afterMidnight"
|
||||
bind:checked={isAfterMidnight}
|
||||
class="h-4 w-4 rounded border-amber-400 text-amber-500 focus:ring-amber-500"
|
||||
/>
|
||||
<label for="afterMidnight" class="flex-1">
|
||||
<span class="block text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
Work begins after midnight
|
||||
</span>
|
||||
{#if isAfterMidnight}
|
||||
<span class="block text-xs text-amber-600 dark:text-amber-400">
|
||||
Calendar event will be scheduled for {actualStartDate} (next calendar day)
|
||||
</span>
|
||||
{:else}
|
||||
<span class="block text-xs text-amber-600 dark:text-amber-400">
|
||||
Check if work starts after midnight but belongs to the previous business day
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Date/Time Fields -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<!-- Start -->
|
||||
<div>
|
||||
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">Start</label>
|
||||
<input
|
||||
type="date"
|
||||
id="startDate"
|
||||
bind:value={startDate}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
required
|
||||
/>
|
||||
{#if !isAllDay}
|
||||
<input
|
||||
type="time"
|
||||
id="startTime"
|
||||
bind:value={startTime}
|
||||
class="mt-2 w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
required
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div>
|
||||
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme">End</label>
|
||||
<input
|
||||
type="date"
|
||||
id="endDate"
|
||||
bind:value={endDate}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
required
|
||||
/>
|
||||
{#if !isAllDay}
|
||||
<input
|
||||
type="time"
|
||||
id="endTime"
|
||||
bind:value={endTime}
|
||||
class="mt-2 w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
required
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div>
|
||||
<label for="location" class="mb-1.5 block text-sm font-medium text-theme"
|
||||
>Location (optional)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
bind:value={location}
|
||||
placeholder="e.g., Conference Room A"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-1.5 block text-sm font-medium text-theme"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
rows="3"
|
||||
placeholder="Add event details..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Event Color -->
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-theme">Color (optional)</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- No color option -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedColorId = undefined)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full border-2 transition-all
|
||||
{selectedColorId === undefined
|
||||
? 'border-accent7-500 ring-2 ring-accent7-500/30'
|
||||
: 'border-theme hover:border-theme-secondary'}"
|
||||
aria-label="No color"
|
||||
title="No color"
|
||||
>
|
||||
<svg class="h-4 w-4 text-theme-muted" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{#each EVENT_COLORS as color (color.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedColorId = color.id)}
|
||||
class="h-8 w-8 rounded-full border-2 transition-all {color.bg}
|
||||
{selectedColorId === color.id
|
||||
? 'border-accent7-500 ring-2 ring-accent7-500/30'
|
||||
: 'border-transparent hover:scale-110'}"
|
||||
aria-label={color.name}
|
||||
title={color.name}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attendees -->
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-theme">Attendees</span>
|
||||
{#if availableAttendees.length > 0}
|
||||
<div
|
||||
class="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-theme bg-theme-secondary p-3"
|
||||
>
|
||||
{#each availableAttendees as profile (profile.id)}
|
||||
{@const selected = isAttendeeSelected(profile.email)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleAttendee(profile.email)}
|
||||
class="flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors {selected
|
||||
? 'bg-accent7-500/20 text-accent7-700 dark:text-accent7-300'
|
||||
: 'hover:bg-black/5 dark:hover:bg-white/10'}"
|
||||
>
|
||||
<span
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full {selected
|
||||
? 'bg-accent7-500 text-white'
|
||||
: 'border border-theme bg-theme'}"
|
||||
>
|
||||
{#if selected}
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-theme">{profile.fullName}</span
|
||||
>
|
||||
<span class="block truncate text-xs text-theme-muted">{profile.email}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedAttendeeEmails.size > 0}
|
||||
<p class="mt-2 text-xs text-theme-muted">
|
||||
{selectedAttendeeEmails.size} attendee{selectedAttendeeEmails.size !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm text-theme-muted italic">
|
||||
No team members with email addresses available.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Reminders -->
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-theme">Reminders</span>
|
||||
|
||||
<!-- Use Default Toggle -->
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useDefaultReminders"
|
||||
bind:checked={useDefaultReminders}
|
||||
class="h-4 w-4 rounded border-theme text-accent7-500 focus:ring-accent7-500"
|
||||
/>
|
||||
<label for="useDefaultReminders" class="text-sm text-theme">
|
||||
Use default reminders (30 minutes before via popup)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Custom Reminders -->
|
||||
{#if !useDefaultReminders}
|
||||
<div class="space-y-3 rounded-lg border border-theme bg-theme-secondary p-3">
|
||||
{#if customReminders.length === 0}
|
||||
<p class="text-sm text-theme-muted italic">No reminders set. Add one below.</p>
|
||||
{:else}
|
||||
{#each customReminders as reminder, index (index)}
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Reminder Method -->
|
||||
<select
|
||||
onchange={(e) => handleReminderMethodChange(index, e)}
|
||||
class="rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
>
|
||||
<option value="popup" selected={reminder.method === 'popup'}>Popup</option>
|
||||
<option value="email" selected={reminder.method === 'email'}>Email</option>
|
||||
</select>
|
||||
|
||||
<!-- Reminder Time -->
|
||||
<select
|
||||
onchange={(e) => handleReminderMinutesChange(index, e)}
|
||||
class="flex-1 rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme focus:border-accent7-500 focus:ring-1 focus:ring-accent7-500"
|
||||
>
|
||||
{#each reminderPresets as preset (preset.minutes)}
|
||||
<option value={preset.minutes} selected={reminder.minutes === preset.minutes}
|
||||
>{preset.label}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeReminder(index)}
|
||||
aria-label="Remove reminder"
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg text-theme-muted transition-colors hover:bg-error-500/10 hover:text-error-500"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Add Reminder Button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={addReminder}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-theme py-2 text-sm text-theme-muted transition-colors hover:border-accent7-500 hover:text-accent7-500"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Reminder
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg bg-error-500/10 p-4">
|
||||
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 border-t border-theme bg-theme p-6">
|
||||
<button type="button" onclick={handleCancel} class="btn-ghost" disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-accent7-500 px-4 py-2 font-medium text-white transition-colors hover:bg-accent7-600 disabled:opacity-50"
|
||||
disabled={submitting || !summary.trim()}
|
||||
>
|
||||
{#if submitting}
|
||||
{event ? 'Updating...' : 'Creating...'}
|
||||
{:else}
|
||||
{event ? 'Update Event' : 'Create Event'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
145
src/lib/components/admin/calendar/WeekView.svelte
Normal file
@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { type CalendarEvent, getEventColor } from '$lib/services/calendar';
|
||||
import {SvelteDate} from "svelte/reactivity";
|
||||
|
||||
interface Props {
|
||||
events: CalendarEvent[];
|
||||
weekStart: Date;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { events, weekStart, onEventClick }: Props = $props();
|
||||
|
||||
// Generate 7 days starting from weekStart (store as timestamps to avoid mutable Date in derived)
|
||||
let dayTimestamps = $derived.by(() => {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new SvelteDate(weekStart);
|
||||
date.setDate(date.getDate() + i);
|
||||
return date.getTime();
|
||||
});
|
||||
});
|
||||
|
||||
// Convert timestamps to Date objects for display
|
||||
function getDateFromTimestamp(ts: number): Date {
|
||||
return new Date(ts);
|
||||
}
|
||||
|
||||
// Format date for grouping (YYYY-MM-DD)
|
||||
function formatDateKey(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Get event date key
|
||||
function getEventDateKey(event: CalendarEvent): string {
|
||||
if (event.start.date) {
|
||||
return event.start.date;
|
||||
}
|
||||
if (event.start.dateTime) {
|
||||
return event.start.dateTime.split('T')[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Format time for display
|
||||
function formatEventTime(event: CalendarEvent): string {
|
||||
if (event.start.date) {
|
||||
return 'All day';
|
||||
}
|
||||
if (event.start.dateTime) {
|
||||
const start = new Date(event.start.dateTime);
|
||||
return start.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Group events by day (use plain object instead of Map for reactivity)
|
||||
let eventsByDay = $derived.by(() => {
|
||||
const grouped: Record<string, CalendarEvent[]> = {};
|
||||
|
||||
// Initialize each day with empty array
|
||||
for (const ts of dayTimestamps) {
|
||||
grouped[formatDateKey(getDateFromTimestamp(ts))] = [];
|
||||
}
|
||||
|
||||
// Add events to their respective days
|
||||
for (const event of events) {
|
||||
const dateKey = getEventDateKey(event);
|
||||
if (grouped[dateKey]) {
|
||||
grouped[dateKey].push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort events within each day by time
|
||||
for (const dayEvents of Object.values(grouped)) {
|
||||
dayEvents.sort((a, b) => {
|
||||
const aTime = a.start.dateTime || a.start.date || '';
|
||||
const bTime = b.start.dateTime || b.start.date || '';
|
||||
return aTime.localeCompare(bTime);
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
// Check if a date is today
|
||||
function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
{#each dayTimestamps as ts (ts)}
|
||||
{@const day = getDateFromTimestamp(ts)}
|
||||
{@const dateKey = formatDateKey(day)}
|
||||
{@const dayEvents = eventsByDay[dateKey] ?? []}
|
||||
{@const today = isToday(day)}
|
||||
<div
|
||||
class="min-h-[200px] rounded-lg border p-2
|
||||
{today ? 'border-accent7-500 bg-accent7-500/5' : 'border-theme bg-theme-card'}"
|
||||
>
|
||||
<!-- Day Header -->
|
||||
<div class="mb-2 text-center">
|
||||
<span class="block text-xs font-medium uppercase text-theme-muted">
|
||||
{day.toLocaleDateString('en-US', { weekday: 'short' })}
|
||||
</span>
|
||||
<span
|
||||
class="block text-lg font-semibold
|
||||
{today ? 'text-accent7-600 dark:text-accent7-400' : 'text-theme'}"
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Events -->
|
||||
<div class="space-y-1">
|
||||
{#each dayEvents as event (event.id)}
|
||||
{@const eventColor = getEventColor(event.colorId)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEventClick?.(event)}
|
||||
class="w-full rounded px-2 py-1 text-left text-xs transition-colors
|
||||
{eventColor
|
||||
? `${eventColor.bg} ${eventColor.text}`
|
||||
: 'bg-accent7-500/20 text-accent7-700 dark:text-accent7-300'}
|
||||
hover:opacity-80"
|
||||
>
|
||||
<span class="block truncate font-medium">{event.summary}</span>
|
||||
<span class="block truncate opacity-75">{formatEventTime(event)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if dayEvents.length === 0}
|
||||
<div class="py-4 text-center text-xs text-theme-muted">No events</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
206
src/lib/components/admin/customers/CustomerForm.svelte
Normal file
@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { CreateCustomerStore, UpdateCustomerStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { GetCustomer$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Customer = NonNullable<GetCustomer$result['customer']>;
|
||||
|
||||
interface Props {
|
||||
customer?: Customer;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { customer, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!customer);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let name = $state(customer?.name ?? '');
|
||||
let billingEmail = $state(customer?.billingEmail ?? '');
|
||||
let billingTerms = $state(customer?.billingTerms ?? '');
|
||||
let startDate = $state(customer?.startDate ?? '');
|
||||
let endDate = $state(customer?.endDate ?? '');
|
||||
let status = $state(customer?.status ?? 'ACTIVE');
|
||||
|
||||
const createStore = new CreateCustomerStore();
|
||||
const updateStore = new UpdateCustomerStore();
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!name || !billingEmail || !startDate) {
|
||||
error = 'Please provide name, billing email, and start date';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && customer) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: customer.id,
|
||||
name,
|
||||
billingEmail,
|
||||
billingTerms,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
status
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
name,
|
||||
billingEmail,
|
||||
billingTerms,
|
||||
startDate,
|
||||
status
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save customer';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Customer Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="Enter customer name"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billingEmail" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Billing Email <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="billingEmail"
|
||||
type="email"
|
||||
bind:value={billingEmail}
|
||||
disabled={submitting}
|
||||
placeholder="Enter billing email"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billingTerms" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Billing Terms
|
||||
</label>
|
||||
<input
|
||||
id="billingTerms"
|
||||
type="text"
|
||||
bind:value={billingTerms}
|
||||
disabled={submitting}
|
||||
placeholder="Enter billing terms (optional)"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="startDate" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Start Date <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="startDate"
|
||||
type="date"
|
||||
bind:value={startDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="endDate" class="mb-1.5 block text-sm font-medium text-theme"> End Date </label>
|
||||
<input
|
||||
id="endDate"
|
||||
type="date"
|
||||
bind:value={endDate}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isEdit}
|
||||
<div>
|
||||
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
|
||||
<select
|
||||
id="status"
|
||||
bind:value={status}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Customer'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
210
src/lib/components/admin/customers/WaveCustomerLinkForm.svelte
Normal file
@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { UpdateCustomerStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
interface WaveCustomer {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
customerId: string;
|
||||
currentWaveCustomerId?: string | null;
|
||||
waveCustomers: WaveCustomer[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { customerId, currentWaveCustomerId, waveCustomers, onSuccess }: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
// Store the decoded Wave customer ID (matches what backend stores)
|
||||
let selectedCustomerId = $derived<string | null>(null);
|
||||
|
||||
// Sync selectedCustomerId when currentWaveCustomerId prop changes (e.g., when form reopens)
|
||||
// currentWaveCustomerId from backend is already decoded
|
||||
$effect(() => {
|
||||
selectedCustomerId = currentWaveCustomerId ?? null;
|
||||
});
|
||||
|
||||
// Decode Wave ID to match backend storage format
|
||||
function decodeWaveId(id: string): string {
|
||||
return fromGlobalId(id);
|
||||
}
|
||||
|
||||
// Filter customers by search query
|
||||
let filteredCustomers = $derived.by(() => {
|
||||
const active = waveCustomers.filter((c) => !c.isArchived);
|
||||
if (!searchQuery.trim()) return active;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return active.filter((c) => {
|
||||
const name = c.name.toLowerCase();
|
||||
const email = c.email?.toLowerCase() ?? '';
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
const updateStore = new UpdateCustomerStore();
|
||||
|
||||
async function handleSave() {
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: customerId,
|
||||
waveCustomerId: selectedCustomerId ?? ''
|
||||
}
|
||||
});
|
||||
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
{#if error}
|
||||
<div class="mb-4 alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-4">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search Wave customers..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-4 pl-10 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer List -->
|
||||
<div class="mb-4 min-h-0 flex-1 overflow-y-auto">
|
||||
{#if filteredCustomers.length === 0 && !searchQuery}
|
||||
<div class="py-8 text-center text-theme-muted">No Wave customers found.</div>
|
||||
{:else if filteredCustomers.length === 0}
|
||||
<div class="py-8 text-center text-theme-muted">No customers match your search.</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<!-- Option to unlink -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedCustomerId = null)}
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedCustomerId ===
|
||||
null
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border-2 {selectedCustomerId ===
|
||||
null
|
||||
? 'border-primary-500'
|
||||
: 'border-theme-muted'}"
|
||||
>
|
||||
{#if selectedCustomerId === null}
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-primary-500"></div>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-theme-muted italic">No Wave customer (unlink)</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#each filteredCustomers as customer (customer.id)}
|
||||
{@const decodedId = decodeWaveId(customer.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedCustomerId = decodedId)}
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedCustomerId ===
|
||||
decodedId
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
|
||||
>
|
||||
<span class="flex items-start gap-3">
|
||||
<span
|
||||
class="mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 {selectedCustomerId ===
|
||||
decodedId
|
||||
? 'border-primary-500'
|
||||
: 'border-theme-muted'}"
|
||||
>
|
||||
{#if selectedCustomerId === decodedId}
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-primary-500"></div>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="font-medium text-theme">{customer.name}</span>
|
||||
{#if customer.email}
|
||||
<p class="truncate text-sm text-theme-secondary">{customer.email}</p>
|
||||
{/if}
|
||||
<span class="truncate font-mono text-xs text-theme-muted">{decodedId}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 border-t border-theme pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSave}
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>Saving...</span>
|
||||
</span>
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import SectionHeader from '$lib/components/admin/SectionHeader.svelte';
|
||||
import { IconEdit } from '$lib/components/icons';
|
||||
|
||||
interface WaveCustomer {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
waveCustomerId: string | null | undefined;
|
||||
linkedWaveCustomer: WaveCustomer | null;
|
||||
onLink: () => void;
|
||||
}
|
||||
|
||||
let { waveCustomerId, linkedWaveCustomer, onLink }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card-padded">
|
||||
<SectionHeader
|
||||
title="Wave Integration"
|
||||
buttonText={waveCustomerId ? 'Change' : 'Link'}
|
||||
buttonIcon="none"
|
||||
onButtonClick={onLink}
|
||||
/>
|
||||
|
||||
{#if linkedWaveCustomer}
|
||||
<!-- Linked Wave customer shown prominently -->
|
||||
<div
|
||||
class="rounded-lg border-2 border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-bold text-blue-700 dark:text-blue-400">
|
||||
{linkedWaveCustomer.name}
|
||||
</span>
|
||||
<span class="badge-primary">Linked</span>
|
||||
</div>
|
||||
{#if linkedWaveCustomer.email}
|
||||
<p class="mt-1 text-sm text-blue-600 dark:text-blue-500">
|
||||
{linkedWaveCustomer.email}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-1 font-mono text-xs text-blue-500 dark:text-blue-600">
|
||||
{waveCustomerId}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLink}
|
||||
class="shrink-0 rounded p-1.5 text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
aria-label="Change linked Wave customer"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if waveCustomerId}
|
||||
<!-- Has ID but couldn't find matching Wave customer -->
|
||||
<div
|
||||
class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-yellow-700 dark:text-yellow-400">
|
||||
Linked to unknown Wave customer
|
||||
</span>
|
||||
<span class="badge-warning">Unknown</span>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xs text-yellow-600 dark:text-yellow-500">
|
||||
{waveCustomerId}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLink}
|
||||
class="shrink-0 rounded p-1.5 text-yellow-600 hover:bg-yellow-100 dark:text-yellow-400 dark:hover:bg-yellow-900/40"
|
||||
aria-label="Change linked Wave customer"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-theme-muted">
|
||||
No Wave customer linked. Click "Link" to connect this customer to Wave.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
210
src/lib/components/admin/invoices/AddProjectsModal.svelte
Normal file
@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import SelectionModal from '$lib/components/shared/SelectionModal.svelte';
|
||||
import { AdminProjectsStore, UpdateInvoiceStore } from '$houdini';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { GetInvoice$result } from '$houdini';
|
||||
import type { AccountAddressInfo } from '$lib/utils/lookup';
|
||||
|
||||
type Invoice = NonNullable<GetInvoice$result['invoice']>;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
invoice: Invoice;
|
||||
accountLookup: Map<string, AccountAddressInfo>;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), invoice, accountLookup, onSuccess }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let availableProjects = $state<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
amount: string | number | null;
|
||||
accountAddressId: string | null;
|
||||
customerId: string;
|
||||
}[]
|
||||
>([]);
|
||||
let selectedProjectIds = new SvelteSet<string>();
|
||||
|
||||
const projectsStore = new AdminProjectsStore();
|
||||
const updateStore = new UpdateInvoiceStore();
|
||||
|
||||
// Get customer UUID from the invoice
|
||||
let customerUuid = $derived(invoice.customerId);
|
||||
|
||||
// Get already included project IDs
|
||||
let includedProjectIds = $derived(new Set(invoice.projects.map((p) => p.id)));
|
||||
|
||||
// Load available projects when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
loadAvailableProjects();
|
||||
selectedProjectIds.clear();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAvailableProjects() {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const result = await projectsStore.fetch({
|
||||
variables: {
|
||||
filters: {
|
||||
customerId: { exact: customerUuid },
|
||||
status: { exact: 'COMPLETED' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
availableProjects = (result.data?.projects ?? []).filter(
|
||||
(project) => !includedProjectIds.has(project.id)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err);
|
||||
error = 'Failed to load projects';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '$0.00';
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function getProjectDisplayName(project: {
|
||||
name: string;
|
||||
date: string;
|
||||
accountAddressId: string | null;
|
||||
customerId: string;
|
||||
}): string {
|
||||
const dateStr = formatDate(project.date);
|
||||
|
||||
let locationName = '';
|
||||
if (project.accountAddressId) {
|
||||
const accountInfo = accountLookup.get(project.accountAddressId);
|
||||
if (accountInfo) {
|
||||
locationName = accountInfo.accountName;
|
||||
}
|
||||
}
|
||||
|
||||
if (locationName) {
|
||||
return `${dateStr} - ${locationName} - ${project.name}`;
|
||||
}
|
||||
return `${dateStr} - ${project.name}`;
|
||||
}
|
||||
|
||||
function toggleProject(projectId: string) {
|
||||
if (selectedProjectIds.has(projectId)) {
|
||||
selectedProjectIds.delete(projectId);
|
||||
} else {
|
||||
selectedProjectIds.add(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
for (const project of availableProjects) {
|
||||
selectedProjectIds.add(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedProjectIds.clear();
|
||||
}
|
||||
|
||||
// Calculate total of selected projects
|
||||
let selectedTotal = $derived(
|
||||
availableProjects
|
||||
.filter((p) => selectedProjectIds.has(p.id))
|
||||
.reduce((sum, p) => sum + parseFloat(String(p.amount ?? 0)), 0)
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (selectedProjectIds.size === 0) return;
|
||||
|
||||
saving = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const existingIds = invoice.projects.map((p) => p.id);
|
||||
const newIds = [...selectedProjectIds];
|
||||
const allIds = [...existingIds, ...newIds];
|
||||
|
||||
await updateStore.mutate({
|
||||
input: {
|
||||
id: invoice.id,
|
||||
projectIds: allIds
|
||||
}
|
||||
});
|
||||
|
||||
open = false;
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to add projects:', err);
|
||||
error = 'Failed to add projects';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<SelectionModal
|
||||
bind:open
|
||||
title="Add Projects"
|
||||
subtitle="Select completed projects to add to this invoice."
|
||||
itemName="Project"
|
||||
{loading}
|
||||
{saving}
|
||||
{error}
|
||||
selectedCount={selectedProjectIds.size}
|
||||
hasItems={availableProjects.length > 0}
|
||||
emptyMessage="No additional completed projects available for this customer."
|
||||
showTotal={true}
|
||||
{selectedTotal}
|
||||
colorScheme="accent"
|
||||
onSave={handleSave}
|
||||
onClose={handleClose}
|
||||
{selectAll}
|
||||
{selectNone}
|
||||
>
|
||||
{#each availableProjects as project (project.id)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme interactive p-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProjectIds.has(project.id)}
|
||||
onchange={() => toggleProject(project.id)}
|
||||
class="checkbox-base"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="text-sm font-medium text-theme">{getProjectDisplayName(project)}</span>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-accent-600 dark:text-accent-400">
|
||||
{formatCurrency(project.amount)}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</SelectionModal>
|
||||
173
src/lib/components/admin/invoices/AddRevenuesModal.svelte
Normal file
@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import SelectionModal from '$lib/components/shared/SelectionModal.svelte';
|
||||
import { UpdateInvoiceStore } from '$houdini';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { GetInvoice$result, Accounts$result } from '$houdini';
|
||||
|
||||
type Invoice = NonNullable<GetInvoice$result['invoice']>;
|
||||
type Account = Accounts$result['accounts'][number];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
invoice: Invoice;
|
||||
accounts: Account[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), invoice, accounts, onSuccess }: Props = $props();
|
||||
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let selectedRevenueIds = new SvelteSet<string>();
|
||||
|
||||
const updateStore = new UpdateInvoiceStore();
|
||||
|
||||
// Get customer UUID from the invoice
|
||||
let customerUuid = $derived(invoice.customerId);
|
||||
|
||||
// Get already included revenue IDs
|
||||
let includedRevenueIds = $derived(new Set(invoice.revenues.map((r) => r.id)));
|
||||
|
||||
// Get accounts belonging to this customer with their revenues
|
||||
let customerAccounts = $derived(accounts.filter((a) => a.customerId === customerUuid));
|
||||
|
||||
// Flatten all revenues from customer accounts that aren't already included
|
||||
let availableRevenues = $derived(
|
||||
customerAccounts.flatMap((account) =>
|
||||
(account.revenues ?? [])
|
||||
.filter((r) => !includedRevenueIds.has(r.id))
|
||||
.map((r) => ({
|
||||
...r,
|
||||
accountId: account.id,
|
||||
accountName: account.name
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
// Reset selection when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
selectedRevenueIds.clear();
|
||||
}
|
||||
});
|
||||
|
||||
function formatCurrency(value: string | number): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRevenue(revenueId: string) {
|
||||
if (selectedRevenueIds.has(revenueId)) {
|
||||
selectedRevenueIds.delete(revenueId);
|
||||
} else {
|
||||
selectedRevenueIds.add(revenueId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
for (const revenue of availableRevenues) {
|
||||
selectedRevenueIds.add(revenue.id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedRevenueIds.clear();
|
||||
}
|
||||
|
||||
// Calculate total of selected revenues
|
||||
let selectedTotal = $derived(
|
||||
availableRevenues
|
||||
.filter((r) => selectedRevenueIds.has(r.id))
|
||||
.reduce((sum, r) => sum + parseFloat(String(r.amount)), 0)
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (selectedRevenueIds.size === 0) return;
|
||||
|
||||
saving = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const existingIds = invoice.revenues.map((r) => r.id);
|
||||
const newIds = [...selectedRevenueIds];
|
||||
const allIds = [...existingIds, ...newIds];
|
||||
|
||||
await updateStore.mutate({
|
||||
input: {
|
||||
id: invoice.id,
|
||||
revenueIds: allIds
|
||||
}
|
||||
});
|
||||
|
||||
open = false;
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to add revenues:', err);
|
||||
error = 'Failed to add revenues';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<SelectionModal
|
||||
bind:open
|
||||
title="Add Revenues"
|
||||
subtitle="Select account revenues to add to this invoice."
|
||||
itemName="Revenue"
|
||||
{saving}
|
||||
{error}
|
||||
selectedCount={selectedRevenueIds.size}
|
||||
hasItems={availableRevenues.length > 0}
|
||||
emptyMessage="No additional revenues available for this customer's accounts."
|
||||
showTotal={true}
|
||||
{selectedTotal}
|
||||
colorScheme="secondary"
|
||||
onSave={handleSave}
|
||||
onClose={handleClose}
|
||||
{selectAll}
|
||||
{selectNone}
|
||||
>
|
||||
{#each availableRevenues as revenue (revenue.id)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme interactive p-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRevenueIds.has(revenue.id)}
|
||||
onchange={() => toggleRevenue(revenue.id)}
|
||||
class="checkbox-base"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-sm font-medium text-theme">{revenue.accountName}</span>
|
||||
<span class="block text-xs text-theme-muted">
|
||||
{formatDate(revenue.startDate)}
|
||||
{#if revenue.endDate}
|
||||
- {formatDate(revenue.endDate)}
|
||||
{:else}
|
||||
- Ongoing
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-secondary-600 dark:text-secondary-400">
|
||||
{formatCurrency(revenue.amount)}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</SelectionModal>
|
||||
230
src/lib/components/admin/invoices/InvoiceForm.svelte
Normal file
@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { CreateInvoiceStore, UpdateInvoiceStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import { toGlobalId } from '$lib/utils/relay';
|
||||
import type { Customers$result } from '$houdini';
|
||||
|
||||
type Customer = Customers$result['customers'][number];
|
||||
|
||||
// Invoice can come from either AdminInvoices or GetInvoice query
|
||||
interface InvoiceData {
|
||||
id: string;
|
||||
date: string;
|
||||
customerId: string;
|
||||
status: string;
|
||||
datePaid?: string | null;
|
||||
paymentType?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
customers: Customer[];
|
||||
invoice?: InvoiceData | null;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { customers, invoice = null, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Convert customerId (UUID) to GlobalID for the select dropdown
|
||||
function getCustomerGlobalId(customerId: string | undefined): string {
|
||||
if (!customerId) return '';
|
||||
// customerId is a UUID, need to convert to GlobalID to match customer.id
|
||||
return toGlobalId('CustomerType', customerId);
|
||||
}
|
||||
|
||||
// Form fields
|
||||
let customerId = $state(getCustomerGlobalId(invoice?.customerId));
|
||||
let date = $state(invoice?.date ?? '');
|
||||
let status = $state(invoice?.status ?? 'DRAFT');
|
||||
let datePaid = $state(invoice?.datePaid ?? '');
|
||||
let paymentType = $state(invoice?.paymentType ?? '');
|
||||
|
||||
// Active customers only
|
||||
let activeCustomers = $derived(customers.filter((c) => c.status === 'ACTIVE'));
|
||||
|
||||
// Show payment fields when status is PAID
|
||||
let showPaymentFields = $derived(status === 'PAID');
|
||||
|
||||
const createStore = new CreateInvoiceStore();
|
||||
const updateStore = new UpdateInvoiceStore();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'DRAFT', label: 'Draft' },
|
||||
{ value: 'SENT', label: 'Sent' },
|
||||
{ value: 'PAID', label: 'Paid' },
|
||||
{ value: 'OVERDUE', label: 'Overdue' },
|
||||
{ value: 'CANCELLED', label: 'Cancelled' }
|
||||
];
|
||||
|
||||
const paymentTypeOptions = [
|
||||
{ value: '', label: 'Select payment type...' },
|
||||
{ value: 'CHECK', label: 'Check' },
|
||||
{ value: 'CREDIT_CARD', label: 'Credit Card' },
|
||||
{ value: 'BANK_TRANSFER', label: 'Bank Transfer' },
|
||||
{ value: 'CASH', label: 'Cash' }
|
||||
];
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!customerId || !date) {
|
||||
error = 'Please select a customer and date';
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice) {
|
||||
// Update existing invoice
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: invoice.id,
|
||||
customerId,
|
||||
date,
|
||||
status,
|
||||
datePaid: showPaymentFields && datePaid ? datePaid : null,
|
||||
paymentType: showPaymentFields && paymentType ? paymentType : null
|
||||
}
|
||||
});
|
||||
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Create new invoice
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
customerId,
|
||||
date,
|
||||
status,
|
||||
datePaid: showPaymentFields && datePaid ? datePaid : null,
|
||||
paymentType: showPaymentFields && paymentType ? paymentType : null
|
||||
}
|
||||
});
|
||||
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save invoice';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="flex h-full flex-col">
|
||||
<div class="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
<!-- Customer -->
|
||||
<div>
|
||||
<label for="customer" class="mb-1.5 block text-sm font-medium text-theme">Customer</label>
|
||||
<select
|
||||
id="customer"
|
||||
bind:value={customerId}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
<option value="">Select customer...</option>
|
||||
{#each activeCustomers as customer (customer.id)}
|
||||
<option value={customer.id}>{customer.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label for="date" class="mb-1.5 block text-sm font-medium text-theme">Invoice Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
bind:value={date}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label for="status" class="mb-1.5 block text-sm font-medium text-theme">Status</label>
|
||||
<select
|
||||
id="status"
|
||||
bind:value={status}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#each statusOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Payment fields (shown when status is PAID) -->
|
||||
{#if showPaymentFields}
|
||||
<div class="space-y-4 rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<h4 class="text-sm font-medium text-theme">Payment Details</h4>
|
||||
|
||||
<!-- Date Paid -->
|
||||
<div>
|
||||
<label for="datePaid" class="mb-1.5 block text-sm font-medium text-theme">Date Paid</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
id="datePaid"
|
||||
bind:value={datePaid}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Payment Type -->
|
||||
<div>
|
||||
<label for="paymentType" class="mb-1.5 block text-sm font-medium text-theme"
|
||||
>Payment Type</label
|
||||
>
|
||||
<select
|
||||
id="paymentType"
|
||||
bind:value={paymentType}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#each paymentTypeOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg bg-error-500/10 p-4">
|
||||
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 border-t border-theme bg-theme p-6">
|
||||
<button type="button" onclick={handleCancel} class="btn-ghost" disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" disabled={submitting || !customerId || !date}>
|
||||
{#if submitting}
|
||||
{invoice ? 'Updating...' : 'Creating...'}
|
||||
{:else}
|
||||
{invoice ? 'Update Invoice' : 'Create Invoice'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
136
src/lib/components/admin/invoices/InvoiceTabs.svelte
Normal file
@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
type InvoiceTab = 'details' | 'wave';
|
||||
|
||||
interface Props {
|
||||
activeTab: InvoiceTab;
|
||||
waveLinked: boolean;
|
||||
validationErrors: number;
|
||||
onTabChange: (tab: InvoiceTab) => void;
|
||||
}
|
||||
|
||||
let { activeTab, waveLinked, validationErrors, onTabChange }: Props = $props();
|
||||
|
||||
const tabs: { id: InvoiceTab; label: string }[] = [
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'wave', label: 'Wave' }
|
||||
];
|
||||
|
||||
// Mobile tab navigation
|
||||
let currentTabIndex = $derived(tabs.findIndex((t) => t.id === activeTab));
|
||||
|
||||
function goToPrevTab() {
|
||||
const newIndex = currentTabIndex > 0 ? currentTabIndex - 1 : tabs.length - 1;
|
||||
onTabChange(tabs[newIndex].id);
|
||||
}
|
||||
|
||||
function goToNextTab() {
|
||||
const newIndex = currentTabIndex < tabs.length - 1 ? currentTabIndex + 1 : 0;
|
||||
onTabChange(tabs[newIndex].id);
|
||||
}
|
||||
|
||||
function getBadgeForTab(tabId: InvoiceTab): { text: string; class: string } | null {
|
||||
if (tabId === 'wave') {
|
||||
if (waveLinked) {
|
||||
return {
|
||||
text: 'Linked',
|
||||
class: 'bg-success-500/20 text-success-600 dark:text-success-400'
|
||||
};
|
||||
}
|
||||
if (validationErrors > 0) {
|
||||
return {
|
||||
text: String(validationErrors),
|
||||
class: 'bg-warning-500/20 text-warning-600 dark:text-warning-400'
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile: Carousel style -->
|
||||
<div class="mb-6 md:hidden">
|
||||
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-card p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={goToPrevTab}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Previous tab"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-theme">{tabs[currentTabIndex].label}</span>
|
||||
{#if getBadgeForTab(tabs[currentTabIndex].id)}
|
||||
{@const badge = getBadgeForTab(tabs[currentTabIndex].id)}
|
||||
{#if badge}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {badge.class}">
|
||||
{badge.text}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={goToNextTab}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Next tab"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab indicators -->
|
||||
<div class="mt-2 flex justify-center gap-1.5">
|
||||
{#each tabs as tab, i (tab.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onTabChange(tab.id)}
|
||||
class="h-1.5 rounded-full transition-all {i === currentTabIndex
|
||||
? 'w-4 bg-accent6-500'
|
||||
: 'w-1.5 bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500'}"
|
||||
aria-label={tab.label}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Horizontal tabs -->
|
||||
<div class="mb-6 hidden md:block">
|
||||
<div class="border-b border-theme">
|
||||
<nav class="flex gap-1" aria-label="Invoice tabs">
|
||||
{#each tabs as tab (tab.id)}
|
||||
{@const badge = getBadgeForTab(tab.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onTabChange(tab.id)}
|
||||
class="relative px-4 py-3 text-sm font-medium transition-colors
|
||||
{activeTab === tab.id
|
||||
? 'text-accent6-600 dark:text-accent6-400'
|
||||
: 'text-theme-muted hover:text-theme'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{#if badge}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{activeTab === tab.id ? badge.class : 'bg-theme-secondary text-theme-muted'}"
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if activeTab === tab.id}
|
||||
<span class="absolute right-0 bottom-0 left-0 h-0.5 bg-accent6-500"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
278
src/lib/components/admin/invoices/wave/WaveCustomerForm.svelte
Normal file
@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { WaveCustomer } from './types';
|
||||
|
||||
interface Props {
|
||||
customer?: WaveCustomer | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { customer = null, onSuccess }: Props = $props();
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Basic info
|
||||
let name = $state(customer?.name ?? '');
|
||||
let firstName = $state(customer?.firstName ?? '');
|
||||
let lastName = $state(customer?.lastName ?? '');
|
||||
let email = $state(customer?.email ?? '');
|
||||
let phone = $state(customer?.phone ?? '');
|
||||
|
||||
// Address
|
||||
let addressLine1 = $state(customer?.address?.addressLine1 ?? '');
|
||||
let addressLine2 = $state(customer?.address?.addressLine2 ?? '');
|
||||
let city = $state(customer?.address?.city ?? '');
|
||||
let provinceCode = $state(customer?.address?.province?.code ?? '');
|
||||
let countryCode = $state(customer?.address?.country?.code ?? 'US');
|
||||
let postalCode = $state(customer?.address?.postalCode ?? '');
|
||||
|
||||
// Notes
|
||||
let internalNotes = $state(customer?.internalNotes ?? '');
|
||||
|
||||
let isEditing = $derived(!!customer);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={isEditing ? '?/updateCustomer' : '?/createCustomer'}
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
error = '';
|
||||
return async ({ result }) => {
|
||||
isSubmitting = false;
|
||||
if (result.type === 'failure') {
|
||||
const errorMsg = result.data?.error;
|
||||
error = typeof errorMsg === 'string' ? errorMsg : 'An error occurred';
|
||||
} else if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex h-full flex-col"
|
||||
>
|
||||
{#if isEditing}
|
||||
<input type="hidden" name="id" value={customer?.id} />
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 space-y-4 overflow-y-auto">
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-danger bg-danger p-3 text-sm text-danger">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Basic Info Section -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium text-theme-muted">Basic Information</h4>
|
||||
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Business/Display Name <span class="text-error-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={name}
|
||||
required
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="Customer or business name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="firstName" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
bind:value={firstName}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="First name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="lastName" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
bind:value={lastName}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="Last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1.5 block text-sm font-medium text-theme">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
bind:value={email}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="customer@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="phone" class="mb-1.5 block text-sm font-medium text-theme">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
bind:value={phone}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Section -->
|
||||
<div class="space-y-4 border-t border-theme pt-4">
|
||||
<h4 class="text-sm font-medium text-theme-muted">Address</h4>
|
||||
|
||||
<div>
|
||||
<label for="addressLine1" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Street Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addressLine1"
|
||||
name="addressLine1"
|
||||
bind:value={addressLine1}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="addressLine2" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Apt, Suite, etc.
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addressLine2"
|
||||
name="addressLine2"
|
||||
bind:value={addressLine2}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="Suite 100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="city" class="mb-1.5 block text-sm font-medium text-theme">City</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
bind:value={city}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="City"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="provinceCode" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
State/Province
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="provinceCode"
|
||||
name="provinceCode"
|
||||
bind:value={provinceCode}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="CA"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="postalCode" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
bind:value={postalCode}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="12345"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="countryCode" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Country
|
||||
</label>
|
||||
<select
|
||||
id="countryCode"
|
||||
name="countryCode"
|
||||
bind:value={countryCode}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
>
|
||||
<option value="US">United States</option>
|
||||
<option value="CA">Canada</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="space-y-4 border-t border-theme pt-4">
|
||||
<h4 class="text-sm font-medium text-theme-muted">Internal Notes</h4>
|
||||
|
||||
<div>
|
||||
<label for="internalNotes" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
id="internalNotes"
|
||||
name="internalNotes"
|
||||
bind:value={internalNotes}
|
||||
rows="3"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="Private notes about this customer (not visible to customer)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3 border-t border-theme pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => offCanvas.closeRight()}
|
||||
disabled={isSubmitting}
|
||||
class="flex-1 rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEditing ? 'Saving...' : 'Creating...'}</span>
|
||||
{:else}
|
||||
{isEditing ? 'Save Changes' : 'Create Customer'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
248
src/lib/components/admin/invoices/wave/WaveCustomersTab.svelte
Normal file
@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import WaveCustomerForm from './WaveCustomerForm.svelte';
|
||||
import type { WaveCustomer } from './types';
|
||||
|
||||
interface Props {
|
||||
customers: WaveCustomer[];
|
||||
onDelete: (customer: WaveCustomer) => void;
|
||||
}
|
||||
|
||||
let { customers, onDelete }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filteredCustomers = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return customers;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return customers.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
(c.email && c.email.toLowerCase().includes(query)) ||
|
||||
(c.firstName && c.firstName.toLowerCase().includes(query)) ||
|
||||
(c.lastName && c.lastName.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
function formatCurrency(value: string): string {
|
||||
const num = parseFloat(value.replace(/,/g, ''));
|
||||
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
offCanvas.showRight({
|
||||
title: 'New Customer',
|
||||
content: createFormContent
|
||||
});
|
||||
}
|
||||
|
||||
function openEditForm(customer: WaveCustomer) {
|
||||
selectedCustomer = customer;
|
||||
offCanvas.showRight({
|
||||
title: 'Edit Customer',
|
||||
content: editFormContent
|
||||
});
|
||||
}
|
||||
|
||||
let selectedCustomer = $state<WaveCustomer | null>(null);
|
||||
</script>
|
||||
|
||||
{#snippet createFormContent()}
|
||||
<WaveCustomerForm />
|
||||
{/snippet}
|
||||
|
||||
{#snippet editFormContent()}
|
||||
<WaveCustomerForm customer={selectedCustomer} />
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Header with search and add button -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="relative flex-1 sm:max-w-xs">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search customers..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme-card py-2 pr-4 pl-10 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateForm}
|
||||
class="flex items-center justify-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Customer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Customers List -->
|
||||
{#if filteredCustomers.length > 0}
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each filteredCustomers as customer (customer.id)}
|
||||
<div
|
||||
class="flex min-w-0 items-start justify-between gap-4 rounded-lg border border-theme bg-theme-card p-4"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium text-theme">{customer.name}</h3>
|
||||
{#if parseFloat(customer.overdueAmount.value) > 0}
|
||||
<span
|
||||
class="rounded-full bg-error-500/10 px-2 py-0.5 text-xs font-medium text-error-600 dark:text-error-400"
|
||||
>
|
||||
Overdue
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-theme-muted">
|
||||
{#if customer.email}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{customer.email}
|
||||
</span>
|
||||
{/if}
|
||||
{#if customer.phone}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
{customer.phone}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if parseFloat(customer.outstandingAmount.value) > 0}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm">
|
||||
<span class="text-theme-secondary">
|
||||
Outstanding: <span class="font-medium text-theme"
|
||||
>{formatCurrency(customer.outstandingAmount.value)}</span
|
||||
>
|
||||
</span>
|
||||
{#if parseFloat(customer.overdueAmount.value) > 0}
|
||||
<span class="text-error-600 dark:text-error-400">
|
||||
Overdue: <span class="font-medium"
|
||||
>{formatCurrency(customer.overdueAmount.value)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditForm(customer)}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Edit customer"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDelete(customer)}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-error-500/10 hover:text-error-600 dark:hover:text-error-400"
|
||||
aria-label="Delete customer"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if searchQuery}
|
||||
<div class="empty-state py-12">
|
||||
<svg
|
||||
class="empty-state-icon text-accent6-400"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="empty-state-title">No Customers Found</h2>
|
||||
<p class="empty-state-text">No customers match your search criteria.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state py-12">
|
||||
<svg
|
||||
class="empty-state-icon text-accent6-400"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="empty-state-title">No Customers</h2>
|
||||
<p class="empty-state-text">Get started by adding your first customer.</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateForm}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Customer
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,571 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import type { WaveInvoice, WaveInvoiceItem, WaveProduct } from './types';
|
||||
|
||||
interface EditableLineItem {
|
||||
productId: string;
|
||||
productName: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
waveInvoice: WaveInvoice;
|
||||
waveProducts: WaveProduct[];
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { waveInvoice, waveProducts, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Form state - pre-populated from Wave invoice
|
||||
let invoiceDate = $state(waveInvoice.invoiceDate);
|
||||
let dueDate = $state(waveInvoice.dueDate);
|
||||
let memo = $state(waveInvoice.memo || '');
|
||||
let footer = $state(waveInvoice.footer || '');
|
||||
|
||||
// Line items state - built from Wave invoice items
|
||||
let lineItems = $state<EditableLineItem[]>(buildLineItemsFromInvoice());
|
||||
|
||||
// Add line item state
|
||||
let showAddLineItem = $state(false);
|
||||
let newProductId = $state('');
|
||||
let newDescription = $state('');
|
||||
let newQuantity = $state(1);
|
||||
let newUnitPrice = $state(0);
|
||||
|
||||
// Discount state - initialize from existing Wave invoice discount
|
||||
let hasDiscount = $state(parseFloat(waveInvoice.discountTotal.value) > 0);
|
||||
let discountType = $state<'PERCENTAGE' | 'FIXED'>('PERCENTAGE');
|
||||
let discountName = $state('Discount');
|
||||
let discountValue = $state(0);
|
||||
|
||||
// Active (non-archived) products for the dropdown
|
||||
let activeProducts = $derived(waveProducts.filter((p) => !p.isArchived));
|
||||
|
||||
function buildLineItemsFromInvoice(): EditableLineItem[] {
|
||||
return waveInvoice.items.map((item: WaveInvoiceItem) => ({
|
||||
productId: item.product.id,
|
||||
productName: item.product.name,
|
||||
description: item.description || '',
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice
|
||||
}));
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
let subtotal = $derived(lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0));
|
||||
|
||||
let discountAmount = $derived(() => {
|
||||
if (!hasDiscount || discountValue <= 0) return 0;
|
||||
if (discountType === 'PERCENTAGE') {
|
||||
return subtotal * (discountValue / 100);
|
||||
}
|
||||
return discountValue;
|
||||
});
|
||||
|
||||
let total = $derived(subtotal - discountAmount());
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function updateLineItem(index: number, field: keyof EditableLineItem, value: string | number) {
|
||||
lineItems[index] = { ...lineItems[index], [field]: value };
|
||||
}
|
||||
|
||||
function removeLineItem(index: number) {
|
||||
lineItems = lineItems.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function handleProductSelect(productId: string) {
|
||||
const product = waveProducts.find((p) => p.id === productId);
|
||||
if (product) {
|
||||
newUnitPrice = product.unitPrice;
|
||||
newDescription = product.description || '';
|
||||
}
|
||||
}
|
||||
|
||||
function addLineItem() {
|
||||
if (!newProductId) return;
|
||||
|
||||
const product = waveProducts.find((p) => p.id === newProductId);
|
||||
if (!product) return;
|
||||
|
||||
lineItems = [
|
||||
...lineItems,
|
||||
{
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
description: newDescription,
|
||||
quantity: newQuantity,
|
||||
unitPrice: newUnitPrice
|
||||
}
|
||||
];
|
||||
|
||||
// Reset add line item form
|
||||
showAddLineItem = false;
|
||||
newProductId = '';
|
||||
newDescription = '';
|
||||
newQuantity = 1;
|
||||
newUnitPrice = 0;
|
||||
}
|
||||
|
||||
function cancelAddLineItem() {
|
||||
showAddLineItem = false;
|
||||
newProductId = '';
|
||||
newDescription = '';
|
||||
newQuantity = 1;
|
||||
newUnitPrice = 0;
|
||||
}
|
||||
|
||||
// Build items JSON for form submission
|
||||
// Wave API expects the product's Global ID
|
||||
let itemsJson = $derived(
|
||||
JSON.stringify(
|
||||
lineItems.map((item) => ({
|
||||
productId: item.productId,
|
||||
description: item.description,
|
||||
quantity: item.quantity.toString(),
|
||||
unitPrice: item.unitPrice.toString()
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
// Build discount JSON for form submission
|
||||
let discountJson = $derived(() => {
|
||||
if (!hasDiscount || discountValue <= 0) return null;
|
||||
return JSON.stringify({
|
||||
type: discountType,
|
||||
name: discountName,
|
||||
value: discountValue
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateWaveInvoice"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
error = '';
|
||||
return async ({ result }) => {
|
||||
submitting = false;
|
||||
if (result.type === 'failure') {
|
||||
const errorMsg = result.data?.error;
|
||||
error = typeof errorMsg === 'string' ? errorMsg : 'Failed to update invoice';
|
||||
} else if (result.type === 'success') {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Hidden fields for form action -->
|
||||
<input type="hidden" name="invoiceId" value={waveInvoice.id} />
|
||||
<input type="hidden" name="items" value={itemsJson} />
|
||||
<input type="hidden" name="discount" value={discountJson() || ''} />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="card-padded">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-theme">
|
||||
Edit Invoice #{waveInvoice.invoiceNumber}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-theme-muted">Modify invoice details and line items.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mx-4 alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Invoice Details -->
|
||||
<div class="card-padded">
|
||||
<h4 class="mb-4 text-sm font-medium text-theme-muted">Invoice Details</h4>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="invoiceDate" class="mb-1 block text-sm font-medium text-theme">
|
||||
Invoice Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="invoiceDate"
|
||||
name="invoiceDate"
|
||||
bind:value={invoiceDate}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="dueDate" class="mb-1 block text-sm font-medium text-theme">Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dueDate"
|
||||
name="dueDate"
|
||||
bind:value={dueDate}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line Items -->
|
||||
<div class="card-padded">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-theme-muted">
|
||||
Line Items ({lineItems.length})
|
||||
</h4>
|
||||
{#if !showAddLineItem}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAddLineItem = true)}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-accent6-500/10 px-3 py-1.5 text-sm font-medium text-accent6-600 transition-colors hover:bg-accent6-500/20 dark:text-accent6-400"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Item
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each lineItems as item, index (item.productId + '-' + index)}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="mb-3 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-theme">{item.productName}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-theme">
|
||||
{formatCurrency(item.quantity * item.unitPrice)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeLineItem(index)}
|
||||
class="rounded p-1 text-theme-muted transition-colors hover:bg-error-500/10 hover:text-error-600 dark:hover:text-error-400"
|
||||
title="Remove line item"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="line-item-desc-{index}" class="mb-1 block text-xs text-theme-muted"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="line-item-desc-{index}"
|
||||
value={item.description}
|
||||
onchange={(e) => updateLineItem(index, 'description', e.currentTarget.value)}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label for="line-item-qty-{index}" class="mb-1 block text-xs text-theme-muted"
|
||||
>Qty</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="line-item-qty-{index}"
|
||||
min="0"
|
||||
step="1"
|
||||
value={item.quantity}
|
||||
onchange={(e) =>
|
||||
updateLineItem(index, 'quantity', parseFloat(e.currentTarget.value) || 0)}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="line-item-price-{index}" class="mb-1 block text-xs text-theme-muted"
|
||||
>Price</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="line-item-price-{index}"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={item.unitPrice}
|
||||
onchange={(e) =>
|
||||
updateLineItem(index, 'unitPrice', parseFloat(e.currentTarget.value) || 0)}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add Line Item Form -->
|
||||
{#if showAddLineItem}
|
||||
<div class="rounded-lg border-2 border-dashed border-accent6-500/30 bg-accent6-500/5 p-4">
|
||||
<h5 class="mb-3 text-sm font-medium text-theme">Add New Line Item</h5>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="new-product" class="mb-1 block text-xs text-theme-muted">Product</label>
|
||||
<select
|
||||
id="new-product"
|
||||
bind:value={newProductId}
|
||||
onchange={(e) => handleProductSelect(e.currentTarget.value)}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Select a product...</option>
|
||||
{#each activeProducts as product (product.id)}
|
||||
<option value={product.id}>{product.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-description" class="mb-1 block text-xs text-theme-muted"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="new-description"
|
||||
bind:value={newDescription}
|
||||
placeholder="Optional description..."
|
||||
class="placeholder-theme-muted w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="new-quantity" class="mb-1 block text-xs text-theme-muted"
|
||||
>Quantity</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="new-quantity"
|
||||
min="1"
|
||||
step="1"
|
||||
bind:value={newQuantity}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-price" class="mb-1 block text-xs text-theme-muted">Unit Price</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="new-price"
|
||||
min="0"
|
||||
step="0.01"
|
||||
bind:value={newUnitPrice}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={addLineItem}
|
||||
disabled={!newProductId}
|
||||
class="rounded-lg bg-accent6-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add Item
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelAddLineItem}
|
||||
class="rounded-lg border border-theme interactive bg-theme px-3 py-1.5 text-sm font-medium text-theme"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Subtotal -->
|
||||
<div class="mt-4 flex items-center justify-between border-t border-theme pt-4">
|
||||
<span class="font-medium text-theme">Subtotal</span>
|
||||
<span class="text-lg font-bold text-theme">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
{#if hasDiscount && discountAmount() > 0}
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-theme-secondary">Discount ({discountName})</span>
|
||||
<span class="font-medium text-success-600 dark:text-success-400">
|
||||
-{formatCurrency(discountAmount())}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Total -->
|
||||
<div class="flex items-center justify-between border-t border-theme pt-4">
|
||||
<span class="font-medium text-theme">Total</span>
|
||||
<span class="text-lg font-bold text-accent6-600 dark:text-accent6-400">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discount Section -->
|
||||
<div class="card-padded">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-theme-muted">Discount</h4>
|
||||
<label class="inline-flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" bind:checked={hasDiscount} class="peer sr-only" />
|
||||
<span
|
||||
class="relative h-5 w-9 rounded-full bg-gray-300 peer-checked:bg-accent6-500 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full dark:bg-gray-600"
|
||||
></span>
|
||||
<span class="text-sm text-theme">{hasDiscount ? 'Enabled' : 'Disabled'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if hasDiscount}
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="discountType" class="mb-1 block text-sm font-medium text-theme">Type</label>
|
||||
<select
|
||||
id="discountType"
|
||||
bind:value={discountType}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="PERCENTAGE">Percentage</option>
|
||||
<option value="FIXED">Fixed Amount</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="discountName" class="mb-1 block text-sm font-medium text-theme">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="discountName"
|
||||
bind:value={discountName}
|
||||
placeholder="Discount"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="discountValue" class="mb-1 block text-sm font-medium text-theme">
|
||||
{discountType === 'PERCENTAGE' ? 'Percentage (%)' : 'Amount ($)'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="discountValue"
|
||||
min="0"
|
||||
step={discountType === 'PERCENTAGE' ? '1' : '0.01'}
|
||||
max={discountType === 'PERCENTAGE' ? '100' : undefined}
|
||||
bind:value={discountValue}
|
||||
placeholder={discountType === 'PERCENTAGE' ? '10' : '50.00'}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if discountValue > 0}
|
||||
<p class="text-sm text-theme-muted">
|
||||
{#if discountType === 'PERCENTAGE'}
|
||||
{discountValue}% off = -{formatCurrency(discountAmount())}
|
||||
{:else}
|
||||
Fixed discount of {formatCurrency(discountValue)}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="card-padded">
|
||||
<h4 class="mb-4 text-sm font-medium text-theme-muted">Additional Notes</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="memo" class="mb-1 block text-sm font-medium text-theme">Memo (Notes)</label>
|
||||
<textarea
|
||||
id="memo"
|
||||
name="memo"
|
||||
bind:value={memo}
|
||||
rows="2"
|
||||
placeholder="Internal notes for the invoice..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="footer" class="mb-1 block text-sm font-medium text-theme">Footer</label>
|
||||
<textarea
|
||||
id="footer"
|
||||
name="footer"
|
||||
bind:value={footer}
|
||||
rows="2"
|
||||
placeholder="Footer text shown on the invoice..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card-padded">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || lineItems.length === 0}
|
||||
class="flex-1 rounded-lg bg-accent6-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>Saving Changes...</span>
|
||||
</span>
|
||||
{:else}
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2.5 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
714
src/lib/components/admin/invoices/wave/WaveInvoiceForm.svelte
Normal file
@ -0,0 +1,714 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import type {
|
||||
NexusInvoice,
|
||||
NexusRevenue,
|
||||
NexusProject,
|
||||
WaveProduct,
|
||||
WaveLineItem,
|
||||
WaveDiscount
|
||||
} from './types';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
invoice: NexusInvoice;
|
||||
waveCustomerId: string;
|
||||
revenues: NexusRevenue[];
|
||||
projects: NexusProject[];
|
||||
waveProducts: WaveProduct[];
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { invoice, waveCustomerId, revenues, projects, waveProducts, onSuccess, onCancel }: Props =
|
||||
$props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Form state
|
||||
let invoiceDate = $state(invoice.date);
|
||||
let dueDate = $state(calculateDueDate(invoice.date, 15));
|
||||
let memo = $state(
|
||||
'Thank you for choosing Nexus Cleaning Solutions! We appreciate your business. If you have any feedback or requests, please reach out to us.'
|
||||
);
|
||||
let footer = $state('');
|
||||
|
||||
// Line items state
|
||||
let lineItems = $state<WaveLineItem[]>(buildInitialLineItems());
|
||||
|
||||
// Additional line item state
|
||||
let showAddLineItem = $state(false);
|
||||
let newProductId = $state('');
|
||||
let newDescription = $state('');
|
||||
let newQuantity = $state(1);
|
||||
let newUnitPrice = $state(0);
|
||||
|
||||
// Discount state
|
||||
let hasDiscount = $state(false);
|
||||
let discountType = $state<'PERCENTAGE' | 'FIXED'>('PERCENTAGE');
|
||||
let discountName = $state('Discount');
|
||||
let discountValue = $state(0);
|
||||
|
||||
// Available products for adding (non-archived only)
|
||||
let availableProducts = $derived(waveProducts.filter((p) => !p.isArchived));
|
||||
|
||||
function calculateDueDate(fromDate: string, daysToAdd: number): string {
|
||||
const date = new SvelteDate(fromDate + 'T00:00:00');
|
||||
date.setDate(date.getDate() + daysToAdd);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function getProduct(waveServiceId: string | null): WaveProduct | null {
|
||||
if (!waveServiceId) return null;
|
||||
return waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) ?? null;
|
||||
}
|
||||
|
||||
function getProductById(productId: string): WaveProduct | null {
|
||||
return waveProducts.find((p) => p.id === productId) ?? null;
|
||||
}
|
||||
|
||||
function getProductName(waveServiceId: string | null): string {
|
||||
const product = getProduct(waveServiceId);
|
||||
return product?.name ?? 'Unknown Product';
|
||||
}
|
||||
|
||||
function getProductDescription(waveServiceId: string | null): string {
|
||||
const product = getProduct(waveServiceId);
|
||||
return product?.description ?? '';
|
||||
}
|
||||
|
||||
function buildInitialLineItems(): WaveLineItem[] {
|
||||
const items: WaveLineItem[] = [];
|
||||
|
||||
// Add revenues as line items
|
||||
for (const revenue of revenues) {
|
||||
if (!revenue.waveServiceId) continue;
|
||||
|
||||
items.push({
|
||||
sourceType: 'revenue',
|
||||
sourceId: revenue.id,
|
||||
productId: revenue.waveServiceId,
|
||||
productName: getProductName(revenue.waveServiceId),
|
||||
description: getProductDescription(revenue.waveServiceId),
|
||||
quantity: 1,
|
||||
unitPrice: parseFloat(String(revenue.amount))
|
||||
});
|
||||
}
|
||||
|
||||
// Add projects as line items
|
||||
for (const project of projects) {
|
||||
if (!project.waveServiceId) continue;
|
||||
|
||||
items.push({
|
||||
sourceType: 'project',
|
||||
sourceId: project.id,
|
||||
productId: project.waveServiceId,
|
||||
productName: getProductName(project.waveServiceId),
|
||||
description: getProductDescription(project.waveServiceId),
|
||||
quantity: 1,
|
||||
unitPrice: parseFloat(String(project.amount))
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
let subtotal = $derived(lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0));
|
||||
|
||||
let discountAmount = $derived(() => {
|
||||
if (!hasDiscount || discountValue <= 0) return 0;
|
||||
if (discountType === 'PERCENTAGE') {
|
||||
return subtotal * (discountValue / 100);
|
||||
}
|
||||
return discountValue;
|
||||
});
|
||||
|
||||
let total = $derived(subtotal - discountAmount());
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function updateLineItem(index: number, field: keyof WaveLineItem, value: string | number) {
|
||||
lineItems[index] = { ...lineItems[index], [field]: value };
|
||||
}
|
||||
|
||||
function removeLineItem(index: number) {
|
||||
lineItems = lineItems.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function addLineItem() {
|
||||
if (!newProductId) return;
|
||||
|
||||
const product = getProductById(newProductId);
|
||||
if (!product) return;
|
||||
|
||||
const decodedProductId = fromGlobalId(product.id);
|
||||
|
||||
lineItems = [
|
||||
...lineItems,
|
||||
{
|
||||
sourceType: 'additional',
|
||||
sourceId: `additional-${Date.now()}`,
|
||||
productId: decodedProductId,
|
||||
productName: product.name,
|
||||
description: newDescription || product.description || '',
|
||||
quantity: newQuantity,
|
||||
unitPrice: newUnitPrice || product.unitPrice
|
||||
}
|
||||
];
|
||||
|
||||
// Reset form
|
||||
showAddLineItem = false;
|
||||
newProductId = '';
|
||||
newDescription = '';
|
||||
newQuantity = 1;
|
||||
newUnitPrice = 0;
|
||||
}
|
||||
|
||||
function onProductSelect(productId: string) {
|
||||
newProductId = productId;
|
||||
const product = getProductById(productId);
|
||||
if (product) {
|
||||
newDescription = product.description || '';
|
||||
newUnitPrice = product.unitPrice;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the Wave product Global ID for a given decoded ID
|
||||
function getWaveProductGlobalId(decodedId: string): string | null {
|
||||
const product = waveProducts.find((p) => fromGlobalId(p.id) === decodedId);
|
||||
return product?.id ?? null;
|
||||
}
|
||||
|
||||
// Find the Wave customer Global ID
|
||||
function getWaveCustomerGlobalId(): string {
|
||||
// waveCustomerId from backend is stored as "businessId;Customer:customerId"
|
||||
// Wave API expects base64 encoded "Business:<businessId>;Customer:<customerId>" format
|
||||
// which is the same format, just needs to be re-encoded
|
||||
return btoa(`Business:${waveCustomerId}`);
|
||||
}
|
||||
|
||||
// Build items JSON for form submission
|
||||
let itemsJson = $derived(
|
||||
JSON.stringify(
|
||||
lineItems.map((item) => {
|
||||
const productGlobalId = getWaveProductGlobalId(item.productId);
|
||||
return {
|
||||
productId: productGlobalId,
|
||||
description: item.description,
|
||||
quantity: item.quantity.toString(),
|
||||
unitPrice: item.unitPrice.toString()
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Build discount JSON for form submission
|
||||
let discountJson = $derived(() => {
|
||||
if (!hasDiscount || discountValue <= 0) return '';
|
||||
const discount: WaveDiscount = {
|
||||
type: discountType,
|
||||
name: discountName,
|
||||
value: discountValue
|
||||
};
|
||||
return JSON.stringify(discount);
|
||||
});
|
||||
|
||||
function getSourceTypeBadgeClass(sourceType: string): string {
|
||||
switch (sourceType) {
|
||||
case 'revenue':
|
||||
return 'bg-secondary-500/20 text-secondary-600 dark:text-secondary-400';
|
||||
case 'project':
|
||||
return 'bg-accent-500/20 text-accent-600 dark:text-accent-400';
|
||||
case 'additional':
|
||||
return 'bg-primary-500/20 text-primary-600 dark:text-primary-400';
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceTypeLabel(sourceType: string): string {
|
||||
switch (sourceType) {
|
||||
case 'revenue':
|
||||
return 'Revenue';
|
||||
case 'project':
|
||||
return 'Project';
|
||||
case 'additional':
|
||||
return 'Additional';
|
||||
default:
|
||||
return sourceType;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createWaveInvoice"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
error = '';
|
||||
return async ({ result }) => {
|
||||
submitting = false;
|
||||
if (result.type === 'failure') {
|
||||
const errorMsg = result.data?.error;
|
||||
error = typeof errorMsg === 'string' ? errorMsg : 'Failed to create invoice';
|
||||
} else if (result.type === 'success') {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Hidden fields for form action -->
|
||||
<input type="hidden" name="customerId" value={getWaveCustomerGlobalId()} />
|
||||
<input type="hidden" name="items" value={itemsJson} />
|
||||
<input type="hidden" name="discount" value={discountJson()} />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="card-padded">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-theme">Create Wave Invoice</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning Box -->
|
||||
<div class="warning-box">
|
||||
<div class="flex gap-3">
|
||||
<svg class="warning-box-icon" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="warning-box-title">Review before submitting</p>
|
||||
<p class="warning-box-text">
|
||||
Please review and adjust the descriptions and amounts below. This will create a draft
|
||||
invoice in Wave — you'll be able to preview and make final edits before sending.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mx-4 alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Invoice Details -->
|
||||
<div class="card-padded">
|
||||
<h4 class="mb-4 text-sm font-medium text-theme-muted">Invoice Details</h4>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="invoiceDate" class="mb-1 block text-sm font-medium text-theme">
|
||||
Invoice Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="invoiceDate"
|
||||
name="invoiceDate"
|
||||
bind:value={invoiceDate}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="dueDate" class="mb-1 block text-sm font-medium text-theme">Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dueDate"
|
||||
name="dueDate"
|
||||
bind:value={dueDate}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line Items -->
|
||||
<div class="card-padded">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-theme-muted">Line Items</h4>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAddLineItem = !showAddLineItem)}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-primary-500/10 px-3 py-1.5 text-sm font-medium text-primary-600 transition-colors hover:bg-primary-500/20 dark:text-primary-400"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Line Item Form -->
|
||||
{#if showAddLineItem}
|
||||
<div class="mb-4 rounded-lg border border-primary-500/30 bg-primary-500/5 p-4">
|
||||
<h5 class="mb-3 text-sm font-medium text-theme">Add Line Item</h5>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="new-product" class="mb-1 block text-xs text-theme-muted">Product</label>
|
||||
<select
|
||||
id="new-product"
|
||||
onchange={(e) => onProductSelect(e.currentTarget.value)}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Select a product...</option>
|
||||
{#each availableProducts as product (product.id)}
|
||||
<option value={product.id}>
|
||||
{product.name}
|
||||
{#if product.unitPrice > 0}
|
||||
({formatCurrency(product.unitPrice)})
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if newProductId}
|
||||
<div>
|
||||
<label for="new-description" class="mb-1 block text-xs text-theme-muted"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="new-description"
|
||||
bind:value={newDescription}
|
||||
placeholder="Optional description..."
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="new-quantity" class="mb-1 block text-xs text-theme-muted"
|
||||
>Quantity</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="new-quantity"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
bind:value={newQuantity}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-price" class="mb-1 block text-xs text-theme-muted">Unit Price</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="new-price"
|
||||
min="0"
|
||||
step="0.01"
|
||||
bind:value={newUnitPrice}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={addLineItem}
|
||||
class="rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showAddLineItem = false;
|
||||
newProductId = '';
|
||||
}}
|
||||
class="rounded-lg border border-theme interactive px-3 py-1.5 text-sm font-medium text-theme"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each lineItems as item, index (item.sourceId)}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="mb-3 flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {getSourceTypeBadgeClass(
|
||||
item.sourceType
|
||||
)}"
|
||||
>
|
||||
{getSourceTypeLabel(item.sourceType)}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-theme">{item.productName}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-theme">
|
||||
{formatCurrency(item.quantity * item.unitPrice)}
|
||||
</span>
|
||||
{#if item.sourceType === 'additional'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeLineItem(index)}
|
||||
class="rounded p-1 text-theme-muted transition-colors hover:bg-error-500/10 hover:text-error-600 dark:hover:text-error-400"
|
||||
title="Remove item"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="line-item-desc-{index}" class="mb-1 block text-xs text-theme-muted"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="line-item-desc-{index}"
|
||||
value={item.description}
|
||||
onchange={(e) => updateLineItem(index, 'description', e.currentTarget.value)}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label for="line-item-qty-{index}" class="mb-1 block text-xs text-theme-muted"
|
||||
>Qty</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="line-item-qty-{index}"
|
||||
min="0"
|
||||
step="1"
|
||||
value={item.quantity}
|
||||
onchange={(e) =>
|
||||
updateLineItem(index, 'quantity', parseFloat(e.currentTarget.value) || 0)}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="line-item-price-{index}" class="mb-1 block text-xs text-theme-muted"
|
||||
>Price</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="line-item-price-{index}"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={item.unitPrice}
|
||||
onchange={(e) =>
|
||||
updateLineItem(index, 'unitPrice', parseFloat(e.currentTarget.value) || 0)}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1.5 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="mt-4 space-y-2 border-t border-theme pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-theme-muted">Subtotal</span>
|
||||
<span class="text-sm font-medium text-theme">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if hasDiscount && discountValue > 0}
|
||||
<div class="flex items-center justify-between text-error-600 dark:text-error-400">
|
||||
<span class="text-sm">
|
||||
{discountName}
|
||||
{#if discountType === 'PERCENTAGE'}
|
||||
({discountValue}%)
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-sm font-medium">
|
||||
-{formatCurrency(discountAmount())}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between border-t border-theme pt-2">
|
||||
<span class="font-medium text-theme">Total</span>
|
||||
<span class="text-lg font-bold text-accent6-600 dark:text-accent6-400">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discount Section -->
|
||||
<div class="card-padded">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-theme-muted">Discount</h4>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={hasDiscount}
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-theme">Apply discount</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if hasDiscount}
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="discountType" class="mb-1 block text-sm font-medium text-theme">Type</label>
|
||||
<select
|
||||
id="discountType"
|
||||
bind:value={discountType}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="PERCENTAGE">Percentage (%)</option>
|
||||
<option value="FIXED">Fixed Amount ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="discountName" class="mb-1 block text-sm font-medium text-theme">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="discountName"
|
||||
bind:value={discountName}
|
||||
placeholder="e.g., Loyalty Discount"
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="discountValue" class="mb-1 block text-sm font-medium text-theme">
|
||||
{discountType === 'PERCENTAGE' ? 'Percentage' : 'Amount'}
|
||||
</label>
|
||||
<div class="relative">
|
||||
{#if discountType === 'FIXED'}
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-theme-muted"
|
||||
>$</span
|
||||
>
|
||||
{/if}
|
||||
<input
|
||||
type="number"
|
||||
id="discountValue"
|
||||
min="0"
|
||||
step={discountType === 'PERCENTAGE' ? '1' : '0.01'}
|
||||
max={discountType === 'PERCENTAGE' ? '100' : undefined}
|
||||
bind:value={discountValue}
|
||||
class="w-full rounded-lg border border-theme bg-theme py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 {discountType ===
|
||||
'FIXED'
|
||||
? 'pr-3 pl-7'
|
||||
: 'px-3'}"
|
||||
/>
|
||||
{#if discountType === 'PERCENTAGE'}
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-theme-muted"
|
||||
>%</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="card-padded">
|
||||
<h4 class="mb-4 text-sm font-medium text-theme-muted">Additional Notes</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="memo" class="mb-1 block text-sm font-medium text-theme">Memo (Notes)</label>
|
||||
<textarea
|
||||
id="memo"
|
||||
name="memo"
|
||||
bind:value={memo}
|
||||
rows="2"
|
||||
placeholder="Internal notes for the invoice..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="footer" class="mb-1 block text-sm font-medium text-theme">Footer</label>
|
||||
<textarea
|
||||
id="footer"
|
||||
name="footer"
|
||||
bind:value={footer}
|
||||
rows="2"
|
||||
placeholder="Footer text shown on the invoice..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card-padded">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || lineItems.length === 0}
|
||||
class="flex-1 rounded-lg bg-accent6-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>Creating Invoice...</span>
|
||||
</span>
|
||||
{:else}
|
||||
Create Draft Invoice
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2.5 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
416
src/lib/components/admin/invoices/wave/WaveInvoicePreview.svelte
Normal file
@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import type { WaveInvoice } from './types';
|
||||
|
||||
interface Props {
|
||||
waveInvoice: WaveInvoice;
|
||||
onSend: () => void;
|
||||
onApprove?: () => void;
|
||||
onRefresh: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
isSending?: boolean;
|
||||
isApproving?: boolean;
|
||||
isRefreshing?: boolean;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
waveInvoice,
|
||||
onSend,
|
||||
onApprove,
|
||||
onRefresh,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isSending = false,
|
||||
isApproving = false,
|
||||
isRefreshing = false,
|
||||
isDeleting = false
|
||||
}: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(value: string, symbol: string = '$'): string {
|
||||
const num = parseFloat(value.replace(/,/g, ''));
|
||||
return `${symbol}${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function getStatusClasses(status: string): string {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
return 'bg-gray-500/10 text-gray-600 dark:text-gray-400';
|
||||
case 'SAVED':
|
||||
return 'bg-gray-500/10 text-gray-600 dark:text-gray-400';
|
||||
case 'SENT':
|
||||
return 'bg-primary-500/10 text-primary-600 dark:text-primary-400';
|
||||
case 'VIEWED':
|
||||
return 'bg-accent2-500/10 text-accent2-600 dark:text-accent2-400';
|
||||
case 'PAID':
|
||||
return 'bg-success-500/10 text-success-600 dark:text-success-400';
|
||||
case 'PARTIAL':
|
||||
return 'bg-warning-500/10 text-warning-600 dark:text-warning-400';
|
||||
case 'OVERDUE':
|
||||
return 'bg-error-500/10 text-error-600 dark:text-error-400';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
return 'Draft';
|
||||
case 'SAVED':
|
||||
return 'Saved';
|
||||
case 'SENT':
|
||||
return 'Sent';
|
||||
case 'VIEWED':
|
||||
return 'Viewed';
|
||||
case 'PAID':
|
||||
return 'Paid';
|
||||
case 'PARTIAL':
|
||||
return 'Partial';
|
||||
case 'OVERDUE':
|
||||
return 'Overdue';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
let canApprove = $derived(waveInvoice.status === 'DRAFT');
|
||||
let canSend = $derived(waveInvoice.status === 'SAVED');
|
||||
let canEdit = $derived(waveInvoice.status === 'DRAFT' || waveInvoice.status === 'SAVED');
|
||||
let canDelete = $derived(waveInvoice.status === 'DRAFT' || waveInvoice.status === 'SAVED');
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Status and Actions -->
|
||||
<div class="card-padded">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-accent6-500/20">
|
||||
<svg
|
||||
class="h-6 w-6 text-accent6-600 dark:text-accent6-400"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold text-theme">
|
||||
Invoice #{waveInvoice.invoiceNumber}
|
||||
</h3>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {getStatusClasses(
|
||||
waveInvoice.status
|
||||
)}"
|
||||
>
|
||||
{getStatusLabel(waveInvoice.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-theme-muted">{waveInvoice.customer.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={waveInvoice.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg interactive bg-theme px-3 py-2 text-sm font-medium text-theme"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Download PDF
|
||||
</a>
|
||||
{#if canEdit && onEdit}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onEdit}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg interactive bg-theme px-3 py-2 text-sm font-medium text-theme"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
{/if}
|
||||
{#if canApprove && onApprove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onApprove}
|
||||
disabled={isApproving}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-success-500 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-success-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if isApproving}
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>Approving...</span>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Approve
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canSend}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSend}
|
||||
disabled={isSending}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if isSending}
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>Sending...</span>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
Send Invoice
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-theme px-3 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
{#if isRefreshing}
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
{:else}
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if canDelete && onDelete}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDelete}
|
||||
disabled={isDeleting}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-error-500/10 px-3 py-2 text-sm font-medium text-error-600 transition-colors hover:bg-error-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-error-400"
|
||||
>
|
||||
{#if isDeleting}
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>Deleting...</span>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Delete
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Summary -->
|
||||
<div class="card-padded">
|
||||
<h4 class="mb-4 text-sm font-medium text-theme-muted">Invoice Summary</h4>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-theme-secondary">Subtotal</span>
|
||||
<span class="font-medium text-theme">
|
||||
{formatCurrency(waveInvoice.subtotal.value, waveInvoice.subtotal.currency.symbol)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if parseFloat(waveInvoice.discountTotal.value) > 0}
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-theme-secondary">Discounts</span>
|
||||
<span class="font-medium text-success-600 dark:text-success-400">
|
||||
-{formatCurrency(
|
||||
waveInvoice.discountTotal.value,
|
||||
waveInvoice.discountTotal.currency.symbol
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if parseFloat(waveInvoice.taxTotal.value) > 0}
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-theme-secondary">Tax</span>
|
||||
<span class="font-medium text-theme">
|
||||
{formatCurrency(waveInvoice.taxTotal.value, waveInvoice.taxTotal.currency.symbol)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between border-t border-theme py-3">
|
||||
<span class="font-medium text-theme">Total</span>
|
||||
<span class="text-lg font-bold text-accent6-600 dark:text-accent6-400">
|
||||
{formatCurrency(waveInvoice.total.value, waveInvoice.total.currency.symbol)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if parseFloat(waveInvoice.amountPaid.value) > 0}
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-theme-secondary">Amount Paid</span>
|
||||
<span class="font-medium text-success-600 dark:text-success-400">
|
||||
{formatCurrency(waveInvoice.amountPaid.value, waveInvoice.amountPaid.currency.symbol)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if parseFloat(waveInvoice.amountDue.value) > 0}
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="font-medium text-theme">Amount Due</span>
|
||||
<span class="text-lg font-bold text-theme">
|
||||
{formatCurrency(waveInvoice.amountDue.value, waveInvoice.amountDue.currency.symbol)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line Items -->
|
||||
<div class="card-padded">
|
||||
<h4 class="mb-4 text-sm font-medium text-theme-muted">
|
||||
Line Items ({waveInvoice.items.length})
|
||||
</h4>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each waveInvoice.items as item, index (index)}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-theme">{item.product.name}</p>
|
||||
{#if item.description}
|
||||
<p class="mt-0.5 text-sm text-theme-muted">{item.description}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
{item.quantity} x {formatCurrency(String(item.unitPrice))}
|
||||
</p>
|
||||
</div>
|
||||
<span class="font-semibold text-theme">
|
||||
{formatCurrency(item.total.value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Details -->
|
||||
<div class="card-padded">
|
||||
<h4 class="mb-4 text-sm font-medium text-theme-muted">Invoice Details</h4>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-sm text-theme-muted">Invoice Date</p>
|
||||
<p class="font-medium text-theme">{formatDate(waveInvoice.invoiceDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-theme-muted">Due Date</p>
|
||||
<p class="font-medium text-theme">{formatDate(waveInvoice.dueDate)}</p>
|
||||
</div>
|
||||
{#if waveInvoice.poNumber}
|
||||
<div>
|
||||
<p class="text-sm text-theme-muted">PO Number</p>
|
||||
<p class="font-medium text-theme">{waveInvoice.poNumber}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if waveInvoice.lastSentAt}
|
||||
<div>
|
||||
<p class="text-sm text-theme-muted">Last Sent</p>
|
||||
<p class="font-medium text-theme">
|
||||
{formatDateTime(waveInvoice.lastSentAt)}
|
||||
{#if waveInvoice.lastSentVia}
|
||||
<span class="text-theme-muted">via {waveInvoice.lastSentVia}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if waveInvoice.lastViewedAt}
|
||||
<div>
|
||||
<p class="text-sm text-theme-muted">Last Viewed</p>
|
||||
<p class="font-medium text-theme">{formatDateTime(waveInvoice.lastViewedAt)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
{#if waveInvoice.memo || waveInvoice.footer}
|
||||
<div class="card-padded">
|
||||
<h4 class="mb-4 text-sm font-medium text-theme-muted">Notes</h4>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if waveInvoice.memo}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme">Memo</p>
|
||||
<p class="mt-1 text-sm text-theme-secondary">{waveInvoice.memo}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if waveInvoice.footer}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme">Footer</p>
|
||||
<p class="mt-1 text-sm text-theme-secondary">{waveInvoice.footer}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { UpdateRevenueStore, UpdateProjectStore } from '$houdini';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import type { WaveProduct } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
entityType: 'revenue' | 'project';
|
||||
entityId: string;
|
||||
entityDescription: string;
|
||||
currentWaveServiceId: string | null;
|
||||
waveProducts: WaveProduct[];
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open,
|
||||
entityType,
|
||||
entityId,
|
||||
entityDescription,
|
||||
currentWaveServiceId,
|
||||
waveProducts,
|
||||
onSuccess,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
let selectedProductId = $state<string | null>(null);
|
||||
|
||||
// Reset state when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
selectedProductId = currentWaveServiceId;
|
||||
searchQuery = '';
|
||||
error = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Decode Wave ID to match backend storage format
|
||||
function decodeWaveId(id: string): string {
|
||||
return fromGlobalId(id);
|
||||
}
|
||||
|
||||
// Filter products by search query
|
||||
let filteredProducts = $derived.by(() => {
|
||||
const active = waveProducts.filter((p) => !p.isArchived);
|
||||
if (!searchQuery.trim()) return active;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return active.filter((p) => {
|
||||
const name = p.name.toLowerCase();
|
||||
const description = p.description?.toLowerCase() ?? '';
|
||||
return name.includes(query) || description.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
// Format currency
|
||||
function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
const updateRevenueStore = new UpdateRevenueStore();
|
||||
const updateProjectStore = new UpdateProjectStore();
|
||||
|
||||
async function handleSave() {
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (entityType === 'revenue') {
|
||||
const res = await updateRevenueStore.mutate({
|
||||
input: {
|
||||
id: entityId,
|
||||
waveServiceId: selectedProductId ?? ''
|
||||
}
|
||||
});
|
||||
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await updateProjectStore.mutate({
|
||||
input: {
|
||||
id: entityId,
|
||||
waveServiceId: selectedProductId ?? ''
|
||||
}
|
||||
});
|
||||
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => e.key === 'Escape' && onCancel()}
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[80vh] w-full max-w-lg flex-col rounded-xl bg-theme shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={() => {}}
|
||||
role="document"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-theme">Link Wave Product</h2>
|
||||
<p class="mt-0.5 text-sm text-theme-muted">{entityDescription}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex min-h-0 flex-1 flex-col p-6">
|
||||
{#if error}
|
||||
<div class="mb-4 alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-4">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search Wave products..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-4 pl-10 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product List -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#if filteredProducts.length === 0 && !searchQuery}
|
||||
<div class="py-8 text-center text-theme-muted">No Wave products found.</div>
|
||||
{:else if filteredProducts.length === 0}
|
||||
<div class="py-8 text-center text-theme-muted">No products match your search.</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<!-- Option to unlink -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedProductId = null)}
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedProductId ===
|
||||
null
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border-2 {selectedProductId ===
|
||||
null
|
||||
? 'border-primary-500'
|
||||
: 'border-theme-muted'}"
|
||||
>
|
||||
{#if selectedProductId === null}
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-primary-500"></div>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-theme-muted italic">No product (unlink)</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#each filteredProducts as product (product.id)}
|
||||
{@const decodedId = decodeWaveId(product.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedProductId = decodedId)}
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedProductId ===
|
||||
decodedId
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
|
||||
>
|
||||
<span class="flex items-start gap-3">
|
||||
<span
|
||||
class="mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 {selectedProductId ===
|
||||
decodedId
|
||||
? 'border-primary-500'
|
||||
: 'border-theme-muted'}"
|
||||
>
|
||||
{#if selectedProductId === decodedId}
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-primary-500"></div>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-theme">{product.name}</span>
|
||||
<span
|
||||
class="text-sm font-semibold text-secondary-600 dark:text-secondary-400"
|
||||
>
|
||||
{formatCurrency(product.unitPrice)}
|
||||
</span>
|
||||
</span>
|
||||
{#if product.description}
|
||||
<p class="mt-0.5 truncate text-sm text-theme-muted">
|
||||
{product.description}
|
||||
</p>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center gap-3 border-t border-theme px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSave}
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>Saving...</span>
|
||||
</span>
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
163
src/lib/components/admin/invoices/wave/WaveProductForm.svelte
Normal file
@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { WaveProduct } from './types';
|
||||
|
||||
interface IncomeAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
subtype: { value: string };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
product?: WaveProduct | null;
|
||||
incomeAccounts?: IncomeAccount[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { product = null, incomeAccounts = [], onSuccess }: Props = $props();
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let name = $state(product?.name ?? '');
|
||||
let unitPrice = $state(product?.unitPrice?.toString() ?? '0');
|
||||
let description = $state(product?.description ?? '');
|
||||
|
||||
// Default to existing product's income account, or find "Sales" account, or first available
|
||||
function getDefaultAccountId() {
|
||||
if (product?.incomeAccount?.id) return product.incomeAccount.id;
|
||||
const salesAccount = incomeAccounts.find((a) => a.name === 'Sales');
|
||||
if (salesAccount) return salesAccount.id;
|
||||
return incomeAccounts[0]?.id ?? '';
|
||||
}
|
||||
|
||||
let incomeAccountId = $state(getDefaultAccountId());
|
||||
|
||||
let isEditing = $derived(!!product);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={isEditing ? '?/updateProduct' : '?/createProduct'}
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
error = '';
|
||||
return async ({ result }) => {
|
||||
isSubmitting = false;
|
||||
if (result.type === 'failure') {
|
||||
const errorMsg = result.data?.error;
|
||||
error = typeof errorMsg === 'string' ? errorMsg : 'An error occurred';
|
||||
} else if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex h-full flex-col"
|
||||
>
|
||||
{#if isEditing}
|
||||
<input type="hidden" name="id" value={product?.id} />
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 space-y-4">
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-danger bg-danger p-3 text-sm text-danger">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Name <span class="text-error-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={name}
|
||||
required
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="Product or service name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="unitPrice" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Unit Price
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
|
||||
<input
|
||||
type="number"
|
||||
id="unitPrice"
|
||||
name="unitPrice"
|
||||
bind:value={unitPrice}
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
bind:value={description}
|
||||
rows="3"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
placeholder="Optional description"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if incomeAccounts.length > 0}
|
||||
<div>
|
||||
<label for="incomeAccountId" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Income Account <span class="text-error-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="incomeAccountId"
|
||||
name="incomeAccountId"
|
||||
bind:value={incomeAccountId}
|
||||
required
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
>
|
||||
{#each incomeAccounts as account (account.id)}
|
||||
<option value={account.id}>{account.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3 border-t border-theme pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => offCanvas.closeRight()}
|
||||
disabled={isSubmitting}
|
||||
class="flex-1 rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEditing ? 'Saving...' : 'Creating...'}</span>
|
||||
{:else}
|
||||
{isEditing ? 'Save Changes' : 'Create Product'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
260
src/lib/components/admin/invoices/wave/WaveProductsTab.svelte
Normal file
@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import WaveProductForm from './WaveProductForm.svelte';
|
||||
import type { WaveProduct } from './types';
|
||||
|
||||
interface IncomeAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
subtype: { value: string };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
products: WaveProduct[];
|
||||
archivedProducts: WaveProduct[];
|
||||
incomeAccounts: IncomeAccount[];
|
||||
onArchive: (product: WaveProduct) => void;
|
||||
}
|
||||
|
||||
let { products, archivedProducts, incomeAccounts, onArchive }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let showArchived = $state(false);
|
||||
|
||||
let activeProducts = $derived.by(() => {
|
||||
const list = showArchived ? archivedProducts : products;
|
||||
if (!searchQuery.trim()) return list;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return list.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
(p.description && p.description.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
offCanvas.showRight({
|
||||
title: 'New Product/Service',
|
||||
content: createFormContent
|
||||
});
|
||||
}
|
||||
|
||||
function openEditForm(product: WaveProduct) {
|
||||
selectedProduct = product;
|
||||
offCanvas.showRight({
|
||||
title: 'Edit Product/Service',
|
||||
content: editFormContent
|
||||
});
|
||||
}
|
||||
|
||||
let selectedProduct = $state<WaveProduct | null>(null);
|
||||
</script>
|
||||
|
||||
{#snippet createFormContent()}
|
||||
<WaveProductForm {incomeAccounts} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet editFormContent()}
|
||||
<WaveProductForm product={selectedProduct} {incomeAccounts} />
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Header with search, toggle, and add button -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div class="relative flex-1 sm:max-w-xs">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search products..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme-card py-2 pr-4 pl-10 text-theme focus:border-accent6-500 focus:ring-1 focus:ring-accent6-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if archivedProducts.length > 0}
|
||||
<label class="flex items-center gap-2 text-sm text-theme-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showArchived}
|
||||
class="h-4 w-4 rounded border-theme text-accent6-500 focus:ring-accent6-500"
|
||||
/>
|
||||
Show archived ({archivedProducts.length})
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !showArchived}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateForm}
|
||||
class="flex items-center justify-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Product
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Products List -->
|
||||
{#if activeProducts.length > 0}
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each activeProducts as product (product.id)}
|
||||
<div
|
||||
class="flex min-w-0 items-start justify-between gap-4 rounded-lg border border-theme bg-theme-card p-4"
|
||||
class:opacity-60={showArchived}
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium text-theme">{product.name}</h3>
|
||||
{#if showArchived}
|
||||
<span
|
||||
class="rounded-full bg-gray-500/10 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Archived
|
||||
</span>
|
||||
{/if}
|
||||
{#if product.isSold}
|
||||
<span
|
||||
class="rounded-full bg-success-500/10 px-2 py-0.5 text-xs font-medium text-success-600 dark:text-success-400"
|
||||
>
|
||||
Sold
|
||||
</span>
|
||||
{/if}
|
||||
{#if product.isBought}
|
||||
<span
|
||||
class="rounded-full bg-primary-500/10 px-2 py-0.5 text-xs font-medium text-primary-600 dark:text-primary-400"
|
||||
>
|
||||
Bought
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if product.description}
|
||||
<p class="mt-1 line-clamp-2 text-sm text-theme-muted">{product.description}</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<p class="text-lg font-semibold text-accent6-600 dark:text-accent6-400">
|
||||
{formatCurrency(product.unitPrice)}
|
||||
</p>
|
||||
{#if product.incomeAccount}
|
||||
<span class="text-sm text-theme-muted">
|
||||
{product.incomeAccount.name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !showArchived}
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditForm(product)}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Edit product"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onArchive(product)}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-warning-500/10 hover:text-warning-600 dark:hover:text-warning-400"
|
||||
aria-label="Archive product"
|
||||
title="Archive product"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if searchQuery}
|
||||
<div class="empty-state py-12">
|
||||
<svg
|
||||
class="empty-state-icon text-accent6-400"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="empty-state-title">No Products Found</h2>
|
||||
<p class="empty-state-text">No products match your search criteria.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state py-12">
|
||||
<svg
|
||||
class="empty-state-icon text-accent6-400"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="empty-state-title">No Products</h2>
|
||||
<p class="empty-state-text">Get started by creating your first product or service.</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateForm}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent6-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent6-600"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
129
src/lib/components/admin/invoices/wave/WaveTabs.svelte
Normal file
@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
export type WaveTab = 'invoices' | 'products' | 'customers';
|
||||
|
||||
interface Props {
|
||||
activeTab: WaveTab;
|
||||
invoiceCount: number;
|
||||
productCount: number;
|
||||
customerCount: number;
|
||||
onTabChange: (tab: WaveTab) => void;
|
||||
}
|
||||
|
||||
let { activeTab, invoiceCount, productCount, customerCount, onTabChange }: Props = $props();
|
||||
|
||||
const tabs: { id: WaveTab; label: string; icon: string }[] = [
|
||||
{ id: 'invoices', label: 'Invoices', icon: 'invoice' },
|
||||
{ id: 'products', label: 'Products/Services', icon: 'product' },
|
||||
{ id: 'customers', label: 'Customers', icon: 'customer' }
|
||||
];
|
||||
|
||||
// Mobile tab navigation
|
||||
let currentTabIndex = $derived(tabs.findIndex((t) => t.id === activeTab));
|
||||
|
||||
function goToPrevTab() {
|
||||
const newIndex = currentTabIndex > 0 ? currentTabIndex - 1 : tabs.length - 1;
|
||||
onTabChange(tabs[newIndex].id);
|
||||
}
|
||||
|
||||
function goToNextTab() {
|
||||
const newIndex = currentTabIndex < tabs.length - 1 ? currentTabIndex + 1 : 0;
|
||||
onTabChange(tabs[newIndex].id);
|
||||
}
|
||||
|
||||
function getCountForTab(tabId: WaveTab): number {
|
||||
switch (tabId) {
|
||||
case 'invoices':
|
||||
return invoiceCount;
|
||||
case 'products':
|
||||
return productCount;
|
||||
case 'customers':
|
||||
return customerCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile: Carousel style -->
|
||||
<div class="mb-6 md:hidden">
|
||||
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-card p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={goToPrevTab}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Previous tab"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-theme">{tabs[currentTabIndex].label}</span>
|
||||
<span
|
||||
class="rounded-full bg-accent6-500/20 px-2 py-0.5 text-xs font-medium text-accent6-600 dark:text-accent6-400"
|
||||
>
|
||||
{getCountForTab(tabs[currentTabIndex].id)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={goToNextTab}
|
||||
class="rounded-lg p-2 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Next tab"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab indicators -->
|
||||
<div class="mt-2 flex justify-center gap-1.5">
|
||||
{#each tabs as tab, i (tab.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onTabChange(tab.id)}
|
||||
class="h-1.5 rounded-full transition-all {i === currentTabIndex
|
||||
? 'w-4 bg-accent6-500'
|
||||
: 'w-1.5 bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500'}"
|
||||
aria-label={tab.label}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Horizontal tabs -->
|
||||
<div class="mb-6 hidden md:block">
|
||||
<div class="border-b border-theme">
|
||||
<nav class="flex gap-1" aria-label="Wave tabs">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onTabChange(tab.id)}
|
||||
class="relative px-4 py-3 text-sm font-medium transition-colors
|
||||
{activeTab === tab.id
|
||||
? 'text-accent6-600 dark:text-accent6-400'
|
||||
: 'text-theme-muted hover:text-theme'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
{tab.label}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{activeTab === tab.id
|
||||
? 'bg-accent6-500/20 text-accent6-600 dark:text-accent6-400'
|
||||
: 'bg-theme-secondary text-theme-muted'}"
|
||||
>
|
||||
{getCountForTab(tab.id)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if activeTab === tab.id}
|
||||
<span class="absolute right-0 bottom-0 left-0 h-0.5 bg-accent6-500"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
import type { NexusRevenue, NexusProject, WaveProduct } from './types';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
|
||||
interface CustomerInfo {
|
||||
customerName: string;
|
||||
waveCustomerId: string | null;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
customer: CustomerInfo;
|
||||
revenues: NexusRevenue[];
|
||||
projects: NexusProject[];
|
||||
waveProducts: WaveProduct[];
|
||||
accounts: Account[];
|
||||
onLinkCustomer: () => void;
|
||||
onLinkRevenue: (revenueId: string, accountName: string) => void;
|
||||
onLinkProject: (projectId: string, projectName: string) => void;
|
||||
onCreateInvoice: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
customer,
|
||||
revenues,
|
||||
projects,
|
||||
waveProducts,
|
||||
accounts,
|
||||
onLinkCustomer,
|
||||
onLinkRevenue,
|
||||
onLinkProject,
|
||||
onCreateInvoice
|
||||
}: Props = $props();
|
||||
|
||||
// Validation checks
|
||||
let customerLinked = $derived(!!customer.waveCustomerId);
|
||||
let unlinkedRevenues = $derived(revenues.filter((r) => !r.waveServiceId));
|
||||
let unlinkedProjects = $derived(projects.filter((p) => !p.waveServiceId));
|
||||
let linkedRevenues = $derived(revenues.filter((r) => r.waveServiceId));
|
||||
let linkedProjects = $derived(projects.filter((p) => p.waveServiceId));
|
||||
let hasLinkedItems = $derived(linkedRevenues.length > 0 || linkedProjects.length > 0);
|
||||
let totalErrors = $derived(
|
||||
(customerLinked ? 0 : 1) + unlinkedRevenues.length + unlinkedProjects.length
|
||||
);
|
||||
let isValid = $derived(totalErrors === 0 && hasLinkedItems);
|
||||
|
||||
// Get product name for a linked item (waveServiceId is stored as decoded UUID)
|
||||
function getProductName(waveServiceId: string | null): string {
|
||||
if (!waveServiceId) return 'Not linked';
|
||||
const product = waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId);
|
||||
return product?.name ?? 'Unknown product';
|
||||
}
|
||||
|
||||
// Get account name for revenue (accountId is UUID, account.id is GlobalID)
|
||||
function getAccountName(accountId: string): string {
|
||||
const account = accounts.find((a) => fromGlobalId(a.id) === accountId);
|
||||
return account?.name ?? 'Unknown Account';
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatShortDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Format currency
|
||||
function formatCurrency(value: string | number): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value.replace(/,/g, '')) : value;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(num);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Status Summary -->
|
||||
<div class="card-padded">
|
||||
{#if isValid}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-success-500/20">
|
||||
<svg
|
||||
class="h-5 w-5 text-success-600 dark:text-success-400"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-theme">Ready to create Wave invoice</h3>
|
||||
<p class="text-sm text-theme-muted">
|
||||
All items are linked to Wave. Click below to create the invoice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCreateInvoice}
|
||||
class="mt-4 w-full rounded-lg bg-accent6-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent6-600"
|
||||
>
|
||||
Create Wave Invoice
|
||||
</button>
|
||||
{:else if !hasLinkedItems && customerLinked && totalErrors === 0}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-warning-500/20">
|
||||
<svg
|
||||
class="h-5 w-5 text-warning-600 dark:text-warning-400"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-theme">No line items</h3>
|
||||
<p class="text-sm text-theme-muted">
|
||||
Add at least one linked revenue or project to create the invoice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-warning-500/20">
|
||||
<svg
|
||||
class="h-5 w-5 text-warning-600 dark:text-warning-400"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-theme">
|
||||
{totalErrors} item{totalErrors !== 1 ? 's' : ''} need{totalErrors === 1 ? 's' : ''} linking
|
||||
</h3>
|
||||
<p class="text-sm text-theme-muted">
|
||||
Link all items to Wave products before creating the invoice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Customer Section -->
|
||||
<div class="card-padded">
|
||||
<h3 class="mb-4 text-sm font-medium text-theme-muted">Customer</h3>
|
||||
|
||||
<div
|
||||
class="rounded-lg border p-4 {customerLinked
|
||||
? 'border-success-200 bg-success-50 dark:border-success-800 dark:bg-success-900/20'
|
||||
: 'border-warning-200 bg-warning-50 dark:border-warning-800 dark:bg-warning-900/20'}"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-theme">{customer.customerName}</span>
|
||||
{#if customerLinked}
|
||||
<span class="badge-success">Linked</span>
|
||||
{:else}
|
||||
<span class="badge-warning">Not Linked</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if customerLinked}
|
||||
<p class="mt-1 truncate text-sm text-theme-muted">
|
||||
Wave Customer ID: {customer.waveCustomerId}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-sm text-warning-600 dark:text-warning-400">
|
||||
Link this customer to a Wave customer to continue.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLinkCustomer}
|
||||
class="shrink-0 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {customerLinked
|
||||
? 'bg-theme text-theme-secondary hover:bg-black/5 dark:hover:bg-white/10'
|
||||
: 'bg-warning-500 text-white hover:bg-warning-600'}"
|
||||
>
|
||||
{customerLinked ? 'Change' : 'Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenues Section -->
|
||||
<div class="card-padded">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-theme-muted">Revenues</h3>
|
||||
{#if unlinkedRevenues.length > 0}
|
||||
<span
|
||||
class="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-600 dark:text-warning-400"
|
||||
>
|
||||
{unlinkedRevenues.length} unlinked
|
||||
</span>
|
||||
{:else if revenues.length > 0}
|
||||
<span
|
||||
class="rounded-full bg-success-500/20 px-2 py-0.5 text-xs font-medium text-success-600 dark:text-success-400"
|
||||
>
|
||||
All linked
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if revenues.length === 0}
|
||||
<p class="text-sm text-theme-muted italic">No revenues on this invoice.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each revenues as revenue (revenue.id)}
|
||||
{@const accountName = getAccountName(revenue.accountId)}
|
||||
{@const isLinked = !!revenue.waveServiceId}
|
||||
<div
|
||||
class="rounded-lg border p-3 {isLinked
|
||||
? 'border-theme bg-theme-secondary'
|
||||
: 'border-warning-200 bg-warning-50 dark:border-warning-800 dark:bg-warning-900/20'}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-theme">{accountName}</span>
|
||||
{#if isLinked}
|
||||
<span class="badge-success">Linked</span>
|
||||
{:else}
|
||||
<span class="badge-warning">Not Linked</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-theme-muted">
|
||||
{formatShortDate(revenue.startDate)}
|
||||
{#if revenue.endDate}
|
||||
- {formatShortDate(revenue.endDate)}
|
||||
{:else}
|
||||
- Ongoing
|
||||
{/if}
|
||||
</p>
|
||||
{#if isLinked}
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
Product: {getProductName(revenue.waveServiceId)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-secondary-600 dark:text-secondary-400">
|
||||
{formatCurrency(revenue.amount)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onLinkRevenue(revenue.id, accountName)}
|
||||
class="rounded px-2 py-1 text-xs font-medium transition-colors {isLinked
|
||||
? 'text-theme-muted hover:bg-black/5 dark:hover:bg-white/10'
|
||||
: 'bg-warning-500 text-white hover:bg-warning-600'}"
|
||||
>
|
||||
{isLinked ? 'Change' : 'Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Projects Section -->
|
||||
<div class="card-padded">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-theme-muted">Projects</h3>
|
||||
{#if unlinkedProjects.length > 0}
|
||||
<span
|
||||
class="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-600 dark:text-warning-400"
|
||||
>
|
||||
{unlinkedProjects.length} unlinked
|
||||
</span>
|
||||
{:else if projects.length > 0}
|
||||
<span
|
||||
class="rounded-full bg-success-500/20 px-2 py-0.5 text-xs font-medium text-success-600 dark:text-success-400"
|
||||
>
|
||||
All linked
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if projects.length === 0}
|
||||
<p class="text-sm text-theme-muted italic">No projects on this invoice.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each projects as project (project.id)}
|
||||
{@const isLinked = !!project.waveServiceId}
|
||||
<div
|
||||
class="rounded-lg border p-3 {isLinked
|
||||
? 'border-theme bg-theme-secondary'
|
||||
: 'border-warning-200 bg-warning-50 dark:border-warning-800 dark:bg-warning-900/20'}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-theme">{project.name}</span>
|
||||
{#if isLinked}
|
||||
<span class="badge-success">Linked</span>
|
||||
{:else}
|
||||
<span class="badge-warning">Not Linked</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-theme-muted">{formatShortDate(project.date)}</p>
|
||||
{#if isLinked}
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
Product: {getProductName(project.waveServiceId)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-accent-600 dark:text-accent-400">
|
||||
{formatCurrency(project.amount)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onLinkProject(project.id, project.name)}
|
||||
class="rounded px-2 py-1 text-xs font-medium transition-colors {isLinked
|
||||
? 'text-theme-muted hover:bg-black/5 dark:hover:bg-white/10'
|
||||
: 'bg-warning-500 text-white hover:bg-warning-600'}"
|
||||
>
|
||||
{isLinked ? 'Change' : 'Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
146
src/lib/components/admin/invoices/wave/types.ts
Normal file
@ -0,0 +1,146 @@
|
||||
// Wave types (duplicated from page.server.ts since we can't import from there)
|
||||
export interface WaveAddress {
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
city: string | null;
|
||||
province: { code: string; name: string } | null;
|
||||
country: { code: string; name: string } | null;
|
||||
postalCode: string | null;
|
||||
}
|
||||
|
||||
export interface WaveCustomer {
|
||||
id: string;
|
||||
name: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
address: WaveAddress | null;
|
||||
currency: { code: string; symbol: string } | null;
|
||||
internalNotes: string | null;
|
||||
outstandingAmount: { value: string; currency: { code: string } };
|
||||
overdueAmount: { value: string; currency: { code: string } };
|
||||
isArchived: boolean | null;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
// Base WaveProduct with required fields - used across all pages
|
||||
export interface WaveProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unitPrice: number;
|
||||
isArchived: boolean;
|
||||
// Optional extended fields - available on Wave admin page
|
||||
isSold?: boolean;
|
||||
isBought?: boolean;
|
||||
incomeAccount?: { id: string; name: string } | null;
|
||||
expenseAccount?: { id: string; name: string } | null;
|
||||
defaultSalesTaxes?: Array<{ id: string; name: string; abbreviation: string }>;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface WaveInvoiceItem {
|
||||
product: { id: string; name: string };
|
||||
description: string | null;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
subtotal: { value: string };
|
||||
total: { value: string };
|
||||
}
|
||||
|
||||
export interface WaveInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
status: string;
|
||||
title: string;
|
||||
subhead: string | null;
|
||||
poNumber: string | null;
|
||||
memo: string | null;
|
||||
footer: string | null;
|
||||
pdfUrl: string;
|
||||
viewUrl: string;
|
||||
customer: { id: string; name: string; email: string | null };
|
||||
items: WaveInvoiceItem[];
|
||||
subtotal: { value: string; currency: { code: string; symbol: string } };
|
||||
taxTotal: { value: string; currency: { code: string; symbol: string } };
|
||||
discountTotal: { value: string; currency: { code: string; symbol: string } };
|
||||
total: { value: string; currency: { code: string; symbol: string } };
|
||||
amountDue: { value: string; currency: { code: string; symbol: string } };
|
||||
amountPaid: { value: string; currency: { code: string; symbol: string } };
|
||||
lastSentAt: string | null;
|
||||
lastSentVia: string | null;
|
||||
lastViewedAt: string | null;
|
||||
}
|
||||
|
||||
// Types for revenue and project data from Nexus invoice
|
||||
export interface NexusRevenue {
|
||||
id: string;
|
||||
accountId: string;
|
||||
amount: string | number;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
waveServiceId: string | null;
|
||||
}
|
||||
|
||||
export interface NexusProject {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
amount: string | number;
|
||||
customerId: string;
|
||||
accountAddressId: string | null;
|
||||
waveServiceId: string | null;
|
||||
}
|
||||
|
||||
export interface NexusInvoice {
|
||||
id: string;
|
||||
date: string;
|
||||
customerId: string;
|
||||
status: string;
|
||||
datePaid: string | null;
|
||||
paymentType: string | null;
|
||||
waveInvoiceId: string | null;
|
||||
revenues: NexusRevenue[];
|
||||
projects: NexusProject[];
|
||||
}
|
||||
|
||||
// Types for the invoice form
|
||||
export interface WaveLineItem {
|
||||
sourceType: 'revenue' | 'project' | 'additional';
|
||||
sourceId: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface WaveDiscount {
|
||||
type: 'PERCENTAGE' | 'FIXED';
|
||||
name: string;
|
||||
value: number; // percentage (0-100) or fixed amount
|
||||
}
|
||||
|
||||
export interface WaveInvoiceFormData {
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
memo: string;
|
||||
footer: string;
|
||||
items: WaveLineItem[];
|
||||
discount?: WaveDiscount;
|
||||
}
|
||||
|
||||
// Validation types
|
||||
export interface ValidationIssue {
|
||||
type: 'customer' | 'revenue' | 'project';
|
||||
id: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type InvoiceTab = 'details' | 'wave';
|
||||
@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
selected: string[];
|
||||
}
|
||||
|
||||
let { selected = $bindable([]) }: Props = $props();
|
||||
|
||||
const channels = [
|
||||
{ value: 'IN_APP', label: 'In-App', description: 'Notifications appear in the app' },
|
||||
{ value: 'EMAIL', label: 'Email', description: 'Send email notifications' },
|
||||
{ value: 'SMS', label: 'SMS', description: 'Send text message notifications' }
|
||||
];
|
||||
|
||||
function toggle(value: string) {
|
||||
if (selected.includes(value)) {
|
||||
selected = selected.filter((v) => v !== value);
|
||||
} else {
|
||||
selected = [...selected, value];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-theme">
|
||||
Notification Channels <span class="text-error-500">*</span>
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each channels as channel (channel.value)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle(channel.value)}
|
||||
class="flex items-center gap-2 rounded-lg border px-4 py-2.5 text-left transition-colors {selected.includes(
|
||||
channel.value
|
||||
)
|
||||
? 'border-primary-500 bg-primary-500/10 text-primary-600 dark:text-primary-400'
|
||||
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
|
||||
>
|
||||
<span class="flex h-5 w-5 items-center justify-center">
|
||||
{#if channel.value === 'IN_APP'}
|
||||
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
{:else if channel.value === 'EMAIL'}
|
||||
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex flex-col">
|
||||
<span class="text-sm font-medium">{channel.label}</span>
|
||||
<span class="text-xs text-theme-muted">{channel.description}</span>
|
||||
</span>
|
||||
{#if selected.includes(channel.value)}
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-primary-500"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selected.length === 0}
|
||||
<p class="mt-2 text-xs text-error-500">Please select at least one channel</p>
|
||||
{/if}
|
||||
</div>
|
||||
241
src/lib/components/admin/notifications/EventTypeSelector.svelte
Normal file
@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { EVENT_TYPE_CATEGORIES } from '$lib/data/eventTypes';
|
||||
|
||||
interface Props {
|
||||
selected: string[];
|
||||
}
|
||||
|
||||
let { selected = $bindable([]) }: Props = $props();
|
||||
|
||||
let searchFilter = $state('');
|
||||
let selectedSet = new SvelteSet<string>(selected);
|
||||
let expandedCategories = new SvelteSet<string>();
|
||||
|
||||
// Sync external changes to internal set
|
||||
$effect(() => {
|
||||
// Only sync if the arrays are actually different
|
||||
const currentValues = [...selectedSet];
|
||||
const isSame =
|
||||
currentValues.length === selected.length && currentValues.every((v) => selected.includes(v));
|
||||
if (!isSame) {
|
||||
selectedSet.clear();
|
||||
for (const v of selected) {
|
||||
selectedSet.add(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sync internal set back to parent
|
||||
$effect(() => {
|
||||
const newSelected = [...selectedSet];
|
||||
// Only update if actually changed to avoid infinite loops
|
||||
if (newSelected.length !== selected.length || !newSelected.every((v) => selected.includes(v))) {
|
||||
selected = newSelected;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter categories by search
|
||||
let filteredCategories = $derived(
|
||||
EVENT_TYPE_CATEGORIES.map((cat) => ({
|
||||
...cat,
|
||||
types: cat.types.filter(
|
||||
(t) =>
|
||||
t.label.toLowerCase().includes(searchFilter.toLowerCase()) ||
|
||||
t.value.toLowerCase().includes(searchFilter.toLowerCase())
|
||||
)
|
||||
})).filter((cat) => cat.types.length > 0)
|
||||
);
|
||||
|
||||
function toggle(value: string) {
|
||||
if (selectedSet.has(value)) {
|
||||
selectedSet.delete(value);
|
||||
} else {
|
||||
selectedSet.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllInCategory(categoryName: string) {
|
||||
const category = filteredCategories.find((c) => c.name === categoryName);
|
||||
if (category) {
|
||||
for (const type of category.types) {
|
||||
selectedSet.add(type.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectNoneInCategory(categoryName: string) {
|
||||
const category = filteredCategories.find((c) => c.name === categoryName);
|
||||
if (category) {
|
||||
for (const type of category.types) {
|
||||
selectedSet.delete(type.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
for (const cat of filteredCategories) {
|
||||
for (const type of cat.types) {
|
||||
selectedSet.add(type.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedSet.clear();
|
||||
}
|
||||
|
||||
function toggleCategory(categoryName: string) {
|
||||
if (expandedCategories.has(categoryName)) {
|
||||
expandedCategories.delete(categoryName);
|
||||
} else {
|
||||
expandedCategories.add(categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedCountInCategory(categoryName: string): number {
|
||||
const category = EVENT_TYPE_CATEGORIES.find((c) => c.name === categoryName);
|
||||
if (!category) return 0;
|
||||
return category.types.filter((t) => selectedSet.has(t.value)).length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-theme">
|
||||
Event Types <span class="text-error-500">*</span>
|
||||
<span class="ml-1 text-primary-500">({selectedSet.size} selected)</span>
|
||||
</span>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={selectAll}
|
||||
class="text-xs font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={selectNone}
|
||||
class="text-xs font-medium text-theme-muted hover:underline"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchFilter}
|
||||
placeholder="Search event types..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-9 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Groups -->
|
||||
<div class="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-theme bg-theme-card">
|
||||
{#each filteredCategories as category (category.name)}
|
||||
{@const selectedCount = getSelectedCountInCategory(category.name)}
|
||||
{@const totalCount = category.types.length}
|
||||
{@const isExpanded = expandedCategories.has(category.name)}
|
||||
|
||||
<div class="border-b border-theme last:border-b-0">
|
||||
<!-- Category Header -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleCategory(category.name)}
|
||||
class="flex w-full items-center justify-between interactive px-3 py-2.5 text-left"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-theme">{category.label}</span>
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold {selectedCount > 0
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
|
||||
>
|
||||
{selectedCount}/{totalCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Category Content -->
|
||||
{#if isExpanded}
|
||||
<div class="border-t border-theme bg-black/5 dark:bg-white/5">
|
||||
<!-- Category Actions -->
|
||||
<div class="flex items-center gap-4 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectAllInCategory(category.name)}
|
||||
class="text-xs font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectNoneInCategory(category.name)}
|
||||
class="text-xs font-medium text-theme-muted hover:underline"
|
||||
>
|
||||
Select None
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Event Types -->
|
||||
<div class="space-y-0.5 px-3 pb-2">
|
||||
{#each category.types as eventType (eventType.value)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded interactive px-2 py-1.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(eventType.value)}
|
||||
onchange={() => toggle(eventType.value)}
|
||||
class="border-theme-muted h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-theme">{eventType.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if filteredCategories.length === 0}
|
||||
<p class="px-3 py-4 text-center text-sm text-theme-muted italic">
|
||||
No event types match "{searchFilter}"
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedSet.size === 0}
|
||||
<p class="text-xs text-error-500">Please select at least one event type</p>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,396 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
CreateNotificationRuleStore,
|
||||
UpdateNotificationRuleStore,
|
||||
type EventTypeChoices$options,
|
||||
type NotificationChannelChoices$options,
|
||||
type RoleChoices$options
|
||||
} from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import EventTypeSelector from './EventTypeSelector.svelte';
|
||||
import ChannelSelector from './ChannelSelector.svelte';
|
||||
import TargetSelector from './TargetSelector.svelte';
|
||||
import TemplateEditor from './TemplateEditor.svelte';
|
||||
import type { AdminNotificationRules$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type NotificationRule = AdminNotificationRules$result['notificationRules'][number];
|
||||
|
||||
interface TeamProfile {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface CustomerProfile {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rule?: NotificationRule | null;
|
||||
teamProfiles: TeamProfile[];
|
||||
customerProfiles: CustomerProfile[];
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { rule = null, teamProfiles, customerProfiles, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!rule);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Form fields
|
||||
let name = $state(rule?.name ?? '');
|
||||
let description = $state(rule?.description ?? '');
|
||||
let eventTypes = $state<string[]>(rule?.eventTypes ?? []);
|
||||
let channels = $state<string[]>(rule?.channels ?? ['IN_APP']);
|
||||
let targetRoles = $state<string[]>(rule?.targetRoles ?? []);
|
||||
let targetTeamProfileIds = $state<string[]>(rule?.targetTeamProfileIds ?? []);
|
||||
let targetCustomerProfileIds = $state<string[]>(rule?.targetCustomerProfileIds ?? []);
|
||||
let templateSubject = $state(rule?.templateSubject ?? '');
|
||||
let templateBody = $state(rule?.templateBody ?? '');
|
||||
let conditions = $state<string>(rule?.conditions ? JSON.stringify(rule.conditions, null, 2) : '');
|
||||
let isActive = $state(rule?.isActive ?? true);
|
||||
|
||||
let showAdvanced = $state(false);
|
||||
let conditionsError = $state('');
|
||||
|
||||
const createStore = new CreateNotificationRuleStore();
|
||||
const updateStore = new UpdateNotificationRuleStore();
|
||||
|
||||
const conditionsExample = '{"status": "COMPLETED"}';
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'ADMIN', label: 'Admin' },
|
||||
{ value: 'TEAM_LEADER', label: 'Team Leader' },
|
||||
{ value: 'TEAM_MEMBER', label: 'Team Member' }
|
||||
];
|
||||
|
||||
function toggleRole(role: string) {
|
||||
if (targetRoles.includes(role)) {
|
||||
targetRoles = targetRoles.filter((r) => r !== role);
|
||||
} else {
|
||||
targetRoles = [...targetRoles, role];
|
||||
}
|
||||
}
|
||||
|
||||
function parseConditions(): Record<string, unknown> | null {
|
||||
if (!conditions.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(conditions);
|
||||
conditionsError = '';
|
||||
return parsed;
|
||||
} catch {
|
||||
conditionsError = 'Invalid JSON';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
// Validation
|
||||
if (!name.trim()) {
|
||||
error = 'Please provide a name for the rule';
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventTypes.length === 0) {
|
||||
error = 'Please select at least one event type';
|
||||
return;
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
error = 'Please select at least one notification channel';
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse conditions if provided
|
||||
let parsedConditions: Record<string, unknown> | null = null;
|
||||
if (conditions.trim()) {
|
||||
parsedConditions = parseConditions();
|
||||
if (conditionsError) {
|
||||
error = 'Invalid JSON in conditions field';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdit && rule) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: rule.id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
eventTypes: eventTypes as EventTypeChoices$options[],
|
||||
channels: channels as NotificationChannelChoices$options[],
|
||||
targetRoles: targetRoles.length > 0 ? (targetRoles as RoleChoices$options[]) : null,
|
||||
targetTeamProfileIds: targetTeamProfileIds.length > 0 ? targetTeamProfileIds : null,
|
||||
targetCustomerProfileIds:
|
||||
targetCustomerProfileIds.length > 0 ? targetCustomerProfileIds : null,
|
||||
templateSubject: templateSubject.trim() || null,
|
||||
templateBody: templateBody.trim() || null,
|
||||
conditions: parsedConditions,
|
||||
isActive
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
name: name.trim(),
|
||||
description: description.trim() || '',
|
||||
eventTypes: eventTypes as EventTypeChoices$options[],
|
||||
channels: channels as NotificationChannelChoices$options[],
|
||||
targetRoles: targetRoles.length > 0 ? (targetRoles as RoleChoices$options[]) : [],
|
||||
targetTeamProfileIds: targetTeamProfileIds.length > 0 ? targetTeamProfileIds : [],
|
||||
targetCustomerProfileIds:
|
||||
targetCustomerProfileIds.length > 0 ? targetCustomerProfileIds : [],
|
||||
templateSubject: templateSubject.trim() || '',
|
||||
templateBody: templateBody.trim() || '',
|
||||
conditions: parsedConditions,
|
||||
isActive
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save notification rule';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-6" onsubmit={handleSubmit}>
|
||||
<!-- Basic Info -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">Basic Info</h3>
|
||||
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Name <span class="text-error-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="e.g., Service Completion Alert"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
disabled={submitting}
|
||||
rows={2}
|
||||
placeholder="Describe when this rule triggers and what it does"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Active Toggle -->
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isActive}
|
||||
disabled={submitting}
|
||||
class="border-theme-muted h-5 w-5 rounded text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm font-medium text-theme">Active</span>
|
||||
<span class="text-xs text-theme-muted">
|
||||
{isActive ? 'This rule will trigger notifications' : 'This rule is disabled'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr class="border-theme" />
|
||||
|
||||
<!-- Event Types -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">
|
||||
Trigger Events
|
||||
</h3>
|
||||
<EventTypeSelector bind:selected={eventTypes} />
|
||||
</div>
|
||||
|
||||
<hr class="border-theme" />
|
||||
|
||||
<!-- Channels -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">
|
||||
Delivery Channels
|
||||
</h3>
|
||||
<ChannelSelector bind:selected={channels} />
|
||||
</div>
|
||||
|
||||
<hr class="border-theme" />
|
||||
|
||||
<!-- Targets -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">Recipients</h3>
|
||||
|
||||
<!-- Target by Role -->
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-theme">Target by Role</span>
|
||||
<p class="mb-2 text-xs text-theme-muted">
|
||||
Leave empty to notify all roles. Select specific roles to limit recipients.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each roleOptions as role (role.value)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleRole(role.value)}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors {targetRoles.includes(
|
||||
role.value
|
||||
)
|
||||
? 'border-primary-500 bg-primary-500/10 text-primary-600 dark:text-primary-400'
|
||||
: 'border-theme bg-theme hover:bg-black/5 dark:hover:bg-white/10'}"
|
||||
>
|
||||
{role.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if targetRoles.length === 0}
|
||||
<p class="mt-2 text-xs text-success-600 dark:text-success-400">
|
||||
All team members will receive notifications
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Specific Team Members -->
|
||||
<TargetSelector
|
||||
label="Specific Team Members"
|
||||
profiles={teamProfiles}
|
||||
bind:selectedIds={targetTeamProfileIds}
|
||||
placeholder="Search team members..."
|
||||
/>
|
||||
|
||||
<!-- Specific Customers -->
|
||||
<TargetSelector
|
||||
label="Specific Customers"
|
||||
profiles={customerProfiles}
|
||||
bind:selectedIds={targetCustomerProfileIds}
|
||||
placeholder="Search customers..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr class="border-theme" />
|
||||
|
||||
<!-- Template -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-theme-secondary uppercase">
|
||||
Message Template
|
||||
</h3>
|
||||
<TemplateEditor
|
||||
bind:subject={templateSubject}
|
||||
bind:body={templateBody}
|
||||
selectedEventTypes={eventTypes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options -->
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAdvanced = !showAdvanced)}
|
||||
class="flex items-center gap-2 text-sm font-medium text-theme-secondary hover:text-theme"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform {showAdvanced ? 'rotate-90' : ''}"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Advanced Options
|
||||
</button>
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label for="conditions" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Conditions (JSON)
|
||||
</label>
|
||||
<p class="mb-2 text-xs text-theme-muted">
|
||||
Add JSON conditions to filter events. Example: {conditionsExample}
|
||||
</p>
|
||||
<textarea
|
||||
id="conditions"
|
||||
bind:value={conditions}
|
||||
oninput={() => parseConditions()}
|
||||
disabled={submitting}
|
||||
rows={3}
|
||||
placeholder={conditionsExample}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 font-mono text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
{#if conditionsError}
|
||||
<p class="mt-1 text-xs text-error-500">{conditionsError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Rule'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
205
src/lib/components/admin/notifications/TargetSelector.svelte
Normal file
@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string | null;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
profiles: Profile[];
|
||||
selectedIds: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { label, profiles, selectedIds = $bindable([]), placeholder = 'Search...' }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let expanded = $state(false);
|
||||
let selectedSet = new SvelteSet<string>(selectedIds);
|
||||
|
||||
// Sync external changes to internal set
|
||||
$effect(() => {
|
||||
const currentValues = [...selectedSet];
|
||||
const isSame =
|
||||
currentValues.length === selectedIds.length &&
|
||||
currentValues.every((v) => selectedIds.includes(v));
|
||||
if (!isSame) {
|
||||
selectedSet.clear();
|
||||
for (const v of selectedIds) {
|
||||
selectedSet.add(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sync internal set back to parent
|
||||
$effect(() => {
|
||||
const newSelected = [...selectedSet];
|
||||
if (
|
||||
newSelected.length !== selectedIds.length ||
|
||||
!newSelected.every((v) => selectedIds.includes(v))
|
||||
) {
|
||||
selectedIds = newSelected;
|
||||
}
|
||||
});
|
||||
|
||||
let filteredProfiles = $derived(
|
||||
profiles.filter(
|
||||
(p) =>
|
||||
p.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.email?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false)
|
||||
)
|
||||
);
|
||||
|
||||
function toggle(id: string) {
|
||||
if (selectedSet.has(id)) {
|
||||
selectedSet.delete(id);
|
||||
} else {
|
||||
selectedSet.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
for (const profile of filteredProfiles) {
|
||||
selectedSet.add(profile.id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedSet.clear();
|
||||
}
|
||||
|
||||
function getSelectedNames(): string {
|
||||
if (selectedSet.size === 0) return 'None selected';
|
||||
if (selectedSet.size <= 2) {
|
||||
return profiles
|
||||
.filter((p) => selectedSet.has(p.id))
|
||||
.map((p) => p.fullName)
|
||||
.join(', ');
|
||||
}
|
||||
return `${selectedSet.size} selected`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border interactive px-4 py-2.5 text-left {selectedSet.size >
|
||||
0
|
||||
? 'border-primary-500 bg-primary-500/5'
|
||||
: 'border-theme bg-theme'}"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
<span class="flex flex-col">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-theme">{label}</span>
|
||||
{#if selectedSet.size > 0}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-medium text-white"
|
||||
>
|
||||
{selectedSet.size}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs {selectedSet.size > 0
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-theme-muted'}">{getSelectedNames()}</span
|
||||
>
|
||||
</span>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-muted transition-transform {expanded ? 'rotate-180' : ''}"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="rounded-lg border border-theme bg-theme-card">
|
||||
<!-- Search -->
|
||||
<div class="border-b border-theme p-3">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
{placeholder}
|
||||
class="placeholder-theme-muted w-full rounded-md border border-theme bg-theme py-1.5 pr-3 pl-9 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Select All / None -->
|
||||
<div class="flex items-center gap-4 border-b border-theme px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={selectAll}
|
||||
class="text-xs font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={selectNone}
|
||||
class="text-xs font-medium text-theme-muted hover:underline"
|
||||
>
|
||||
Select None
|
||||
</button>
|
||||
<span class="ml-auto text-xs text-theme-muted">{selectedSet.size} selected</span>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
{#if filteredProfiles.length === 0}
|
||||
<p class="px-3 py-4 text-center text-sm text-theme-muted italic">No profiles found</p>
|
||||
{:else}
|
||||
{#each filteredProfiles as profile (profile.id)}
|
||||
<label class="flex cursor-pointer items-center gap-3 interactive px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(profile.id)}
|
||||
onchange={() => toggle(profile.id)}
|
||||
class="border-theme-muted h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm text-theme">{profile.fullName}</span>
|
||||
{#if profile.email}
|
||||
<span class="block truncate text-xs text-theme-muted">{profile.email}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if profile.role}
|
||||
<span
|
||||
class="flex-shrink-0 rounded-full bg-gray-500/10 px-2 py-0.5 text-xs text-theme-muted"
|
||||
>
|
||||
{profile.role === 'ADMIN'
|
||||
? 'Admin'
|
||||
: profile.role === 'TEAM_LEADER'
|
||||
? 'Team Leader'
|
||||
: 'Member'}
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
113
src/lib/components/admin/notifications/TemplateEditor.svelte
Normal file
@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
UNIVERSAL_FIELDS,
|
||||
getActiveDomainsForEvents,
|
||||
type MetadataDomain
|
||||
} from '$lib/data/notificationMetadata';
|
||||
|
||||
interface Props {
|
||||
subject: string;
|
||||
body: string;
|
||||
selectedEventTypes?: string[];
|
||||
}
|
||||
|
||||
let { subject = $bindable(''), body = $bindable(''), selectedEventTypes = [] }: Props = $props();
|
||||
|
||||
let showVariableHints = $state(false);
|
||||
|
||||
// Compute active domains based on selected event types
|
||||
let activeDomains = $derived<MetadataDomain[]>(getActiveDomainsForEvents(selectedEventTypes));
|
||||
|
||||
const subjectPlaceholder = 'e.g., New {event_type} notification';
|
||||
const bodyPlaceholder = 'e.g., A new {event_type} event occurred for {entity_type} {entity_id}.';
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-theme">Message Template</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showVariableHints = !showVariableHints)}
|
||||
class="text-xs text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
{showVariableHints ? 'Hide' : 'Show'} Variables
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showVariableHints}
|
||||
<div class="space-y-3 rounded-lg border border-theme bg-theme-card p-3">
|
||||
<!-- Universal fields -->
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium text-theme">Universal Variables:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each UNIVERSAL_FIELDS as field (field.name)}
|
||||
<span class="group relative">
|
||||
<code
|
||||
class="cursor-help rounded bg-primary-500/10 px-1.5 py-0.5 text-xs text-primary-600 dark:text-primary-400"
|
||||
>
|
||||
{`{${field.name}}`}
|
||||
</code>
|
||||
<span
|
||||
class="absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white group-hover:block"
|
||||
>
|
||||
{field.description}
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain-specific fields -->
|
||||
{#if activeDomains.length > 0}
|
||||
{#each activeDomains as domain (domain.name)}
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium text-theme">{domain.label} Fields:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each domain.fields as field (field.name)}
|
||||
<span class="group relative">
|
||||
<code
|
||||
class="cursor-help rounded bg-secondary-500/10 px-1.5 py-0.5 text-xs text-secondary-600 dark:text-secondary-400"
|
||||
>
|
||||
{`{${field.name}}`}
|
||||
</code>
|
||||
<span
|
||||
class="absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white group-hover:block"
|
||||
>
|
||||
{field.description}
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-xs text-theme-muted">
|
||||
Select event types above to see available metadata fields.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="template-subject" class="mb-1.5 block text-sm font-medium text-theme">Subject</label
|
||||
>
|
||||
<input
|
||||
id="template-subject"
|
||||
type="text"
|
||||
bind:value={subject}
|
||||
placeholder={subjectPlaceholder}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="template-body" class="mb-1.5 block text-sm font-medium text-theme">Body</label>
|
||||
<textarea
|
||||
id="template-body"
|
||||
bind:value={body}
|
||||
rows={4}
|
||||
placeholder={bodyPlaceholder}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
259
src/lib/components/admin/profiles/CustomerProfileForm.svelte
Normal file
@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { CreateCustomerProfileStore, UpdateCustomerProfileStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
import type { AdminProfiles$result, Customers$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type CustomerProfile = AdminProfiles$result['customerProfiles'][number];
|
||||
type Customer = Customers$result['customers'][number];
|
||||
|
||||
interface Props {
|
||||
profile?: CustomerProfile;
|
||||
customers: Customer[];
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { profile, customers, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!profile);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let firstName = $state(profile?.firstName ?? '');
|
||||
let lastName = $state(profile?.lastName ?? '');
|
||||
let email = $state(profile?.email ?? '');
|
||||
let phone = $state(profile?.phone ?? '');
|
||||
let status = $state(profile?.status ?? 'ACTIVE');
|
||||
let notes = $state(profile?.notes ?? '');
|
||||
let selectedCustomerIds = $state<string[]>(profile?.customers.map((c) => c.id) ?? []);
|
||||
|
||||
const createStore = new CreateCustomerProfileStore();
|
||||
const updateStore = new UpdateCustomerProfileStore();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: 'Active' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'INACTIVE', label: 'Inactive' }
|
||||
];
|
||||
|
||||
// Active customers only for selection
|
||||
let activeCustomers = $derived(customers.filter((c) => c.status === 'ACTIVE'));
|
||||
|
||||
function toggleCustomer(customerId: string) {
|
||||
if (selectedCustomerIds.includes(customerId)) {
|
||||
selectedCustomerIds = selectedCustomerIds.filter((id) => id !== customerId);
|
||||
} else {
|
||||
selectedCustomerIds = [...selectedCustomerIds, customerId];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!firstName || !lastName) {
|
||||
error = 'Please provide first name and last name';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && profile) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: profile.id,
|
||||
firstName,
|
||||
lastName,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
status,
|
||||
notes: notes || null,
|
||||
customerIds: selectedCustomerIds.length > 0 ? selectedCustomerIds : null
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
firstName,
|
||||
lastName,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
status,
|
||||
notes: notes || null,
|
||||
customerIds: selectedCustomerIds.length > 0 ? selectedCustomerIds : null
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save customer profile';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
{#if isEdit && profile}
|
||||
<div>
|
||||
<span class="mb-1.5 block text-sm font-medium text-theme">Profile ID</span>
|
||||
<p class="font-mono text-sm text-theme-muted">{fromGlobalId(profile.id)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
First Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
bind:value={firstName}
|
||||
disabled={submitting}
|
||||
placeholder="Enter first name"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="lastName" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Last Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
bind:value={lastName}
|
||||
disabled={submitting}
|
||||
placeholder="Enter last name"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1.5 block text-sm font-medium text-theme"> Email </label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
disabled={submitting}
|
||||
placeholder="Enter email address"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="phone" class="mb-1.5 block text-sm font-medium text-theme"> Phone </label>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
bind:value={phone}
|
||||
disabled={submitting}
|
||||
placeholder="Enter phone number"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
|
||||
<select
|
||||
id="status"
|
||||
bind:value={status}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#each statusOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Customer Access -->
|
||||
<div>
|
||||
<span class="mb-1.5 block text-sm font-medium text-theme">Customer Access</span>
|
||||
<p class="mb-2 text-xs text-theme-muted">Select which customers this profile can access.</p>
|
||||
{#if activeCustomers.length > 0}
|
||||
<div
|
||||
class="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-theme bg-theme-secondary p-3"
|
||||
>
|
||||
{#each activeCustomers as customer (customer.id)}
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCustomerIds.includes(customer.id)}
|
||||
onchange={() => toggleCustomer(customer.id)}
|
||||
disabled={submitting}
|
||||
class="h-4 w-4 rounded border-theme text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-theme">{customer.name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-theme-muted italic">No active customers available.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="mb-1.5 block text-sm font-medium text-theme"> Notes </label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={notes}
|
||||
disabled={submitting}
|
||||
rows="3"
|
||||
placeholder="Additional notes (optional)"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Customer Profile'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
242
src/lib/components/admin/profiles/TeamProfileForm.svelte
Normal file
@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { CreateTeamProfileStore, UpdateTeamProfileStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
import type { AdminProfiles$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type TeamProfile = AdminProfiles$result['teamProfiles'][number];
|
||||
|
||||
interface Props {
|
||||
profile?: TeamProfile;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { profile, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!profile);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let firstName = $state(profile?.firstName ?? '');
|
||||
let lastName = $state(profile?.lastName ?? '');
|
||||
let email = $state(profile?.email ?? '');
|
||||
let phone = $state(profile?.phone ?? '');
|
||||
let role = $state(profile?.role ?? 'TEAM_MEMBER');
|
||||
let status = $state(profile?.status ?? 'ACTIVE');
|
||||
let notes = $state(profile?.notes ?? '');
|
||||
|
||||
const createStore = new CreateTeamProfileStore();
|
||||
const updateStore = new UpdateTeamProfileStore();
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'ADMIN', label: 'Admin' },
|
||||
{ value: 'TEAM_LEADER', label: 'Team Leader' },
|
||||
{ value: 'TEAM_MEMBER', label: 'Team Member' }
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: 'Active' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'INACTIVE', label: 'Inactive' }
|
||||
];
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!firstName || !lastName || !role) {
|
||||
error = 'Please provide first name, last name, and role';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && profile) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: profile.id,
|
||||
firstName,
|
||||
lastName,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
role,
|
||||
status,
|
||||
notes: notes || null
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
firstName,
|
||||
lastName,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
role,
|
||||
status,
|
||||
notes: notes || null
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save team member';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
{#if isEdit && profile}
|
||||
<div>
|
||||
<span class="mb-1.5 block text-sm font-medium text-theme">Profile ID</span>
|
||||
<p class="font-mono text-sm text-theme-muted">{fromGlobalId(profile.id)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
First Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
bind:value={firstName}
|
||||
disabled={submitting}
|
||||
placeholder="Enter first name"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="lastName" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Last Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
bind:value={lastName}
|
||||
disabled={submitting}
|
||||
placeholder="Enter last name"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1.5 block text-sm font-medium text-theme"> Email </label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
disabled={submitting}
|
||||
placeholder="Enter email address"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="phone" class="mb-1.5 block text-sm font-medium text-theme"> Phone </label>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
bind:value={phone}
|
||||
disabled={submitting}
|
||||
placeholder="Enter phone number"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="role" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Role <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
bind:value={role}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#each roleOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
|
||||
<select
|
||||
id="status"
|
||||
bind:value={status}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#each statusOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="mb-1.5 block text-sm font-medium text-theme"> Notes </label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={notes}
|
||||
disabled={submitting}
|
||||
rows="3"
|
||||
placeholder="Additional notes (optional)"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Team Member'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
126
src/lib/components/admin/projects/ProjectCategoryForm.svelte
Normal file
@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { createProjectScopeCategory, updateProjectScopeCategory } from '$lib/utils/scopes';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { ProjectScope$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Category = NonNullable<ProjectScope$result['projectScope']>['projectAreas'][number];
|
||||
|
||||
interface Props {
|
||||
scopeId: string;
|
||||
category?: Category;
|
||||
nextOrder?: number;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { scopeId, category, nextOrder = 0, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!category);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let name = $state(category?.name ?? '');
|
||||
let order = $state(category?.order ?? nextOrder);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!name.trim()) {
|
||||
error = 'Please provide a category name';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && category) {
|
||||
await updateProjectScopeCategory({
|
||||
id: category.id,
|
||||
name: name.trim(),
|
||||
order
|
||||
});
|
||||
} else {
|
||||
await createProjectScopeCategory({
|
||||
scopeId,
|
||||
name: name.trim(),
|
||||
order
|
||||
});
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save category';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Category Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="e.g., Kitchen, Exterior, Deep Clean"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="order" class="mb-1.5 block text-sm font-medium text-theme"> Display Order </label>
|
||||
<input
|
||||
id="order"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={order}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-theme-muted">Lower numbers appear first</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Add Category'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
298
src/lib/components/admin/projects/ProjectDetailsCard.svelte
Normal file
@ -0,0 +1,298 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
fullName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AccountInfo {
|
||||
accountName: string;
|
||||
addressName: string;
|
||||
}
|
||||
|
||||
interface CustomerInfo {
|
||||
customerName: string;
|
||||
}
|
||||
|
||||
interface WaveProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unitPrice: number | string;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName: string | null;
|
||||
status: string;
|
||||
date: string | null;
|
||||
amount: number | string | null;
|
||||
labor: number | string | null;
|
||||
address: string | null;
|
||||
teamMembers: TeamMember[];
|
||||
notes: string | null;
|
||||
calendarEventId?: string | null;
|
||||
waveServiceId?: string | null;
|
||||
waveProducts?: WaveProduct[];
|
||||
onScheduleEvent?: () => void;
|
||||
accountInfo?: AccountInfo | null;
|
||||
customerInfo?: CustomerInfo | null;
|
||||
isDispatched?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
projectId,
|
||||
projectName,
|
||||
status,
|
||||
date,
|
||||
amount,
|
||||
labor,
|
||||
address,
|
||||
teamMembers,
|
||||
notes,
|
||||
calendarEventId,
|
||||
waveServiceId,
|
||||
waveProducts = [],
|
||||
onScheduleEvent,
|
||||
accountInfo,
|
||||
customerInfo,
|
||||
isDispatched = false
|
||||
}: Props = $props();
|
||||
|
||||
// Find linked Wave product
|
||||
let linkedProduct = $derived(
|
||||
waveServiceId ? waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) : null
|
||||
);
|
||||
|
||||
// Non-admin team members for messaging (exclude ADMIN role)
|
||||
let messagableMembers = $derived(teamMembers.filter((m) => m.role !== 'ADMIN'));
|
||||
|
||||
// Build the subject line for messages
|
||||
let messageSubject = $derived(() => {
|
||||
const formattedDate = formatDate(date) || 'Unknown Date';
|
||||
const accountName = accountInfo?.accountName || customerInfo?.customerName || 'Unknown';
|
||||
const name = projectName || 'Project';
|
||||
return `${formattedDate} - Project: ${name} - ${accountName}`;
|
||||
});
|
||||
|
||||
// Navigate to new message page with pre-filled params
|
||||
function messageTeamMember(memberId: string) {
|
||||
const params = new URLSearchParams({
|
||||
entityType: 'Project',
|
||||
entityId: projectId,
|
||||
participantIds: memberId,
|
||||
conversationType: 'DIRECT',
|
||||
subject: messageSubject()
|
||||
});
|
||||
goto(`/messages/new?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Navigate to new message page with all team members
|
||||
function messageAllTeamMembers() {
|
||||
const memberIds = messagableMembers.map((m) => m.id).join(',');
|
||||
const params = new URLSearchParams({
|
||||
entityType: 'Project',
|
||||
entityId: projectId,
|
||||
participantIds: memberIds,
|
||||
conversationType: 'GROUP',
|
||||
subject: messageSubject()
|
||||
});
|
||||
goto(`/messages/new?${params.toString()}`);
|
||||
}
|
||||
|
||||
function formatCurrency(value: number | string | null | undefined): string {
|
||||
if (value == null) return '$0';
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(projectStatus: string): string {
|
||||
switch (projectStatus) {
|
||||
case 'SCHEDULED':
|
||||
return 'badge-primary';
|
||||
case 'IN_PROGRESS':
|
||||
return 'badge-accent';
|
||||
default:
|
||||
return 'badge-secondary';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 rounded-xl border border-theme bg-theme-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-theme">Project Details</h2>
|
||||
{#if calendarEventId}
|
||||
<a
|
||||
href="/admin/calendar/{calendarEventId}"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-accent7-500/10 px-3 py-1.5 text-sm font-medium text-accent7-600 transition-colors hover:bg-accent7-500/20 dark:text-accent7-400"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
View Event
|
||||
</a>
|
||||
{:else if onScheduleEvent}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onScheduleEvent}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-accent7-500/10 px-3 py-1.5 text-sm font-medium text-accent7-600 transition-colors hover:bg-accent7-500/20 dark:text-accent7-400"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Schedule Event
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<p class="detail-label">Status</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class="inline-block rounded-full px-3 py-1 text-xs font-medium {getStatusBadgeClass(
|
||||
status
|
||||
)}"
|
||||
>
|
||||
{status.replace('_', ' ')}
|
||||
</span>
|
||||
{#if isDispatched}
|
||||
<span
|
||||
class="inline-block rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
>
|
||||
Dispatched
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<p class="detail-label">Date</p>
|
||||
<p class="text-theme">{formatDate(date)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div>
|
||||
<p class="detail-label">Amount</p>
|
||||
<p class="font-semibold text-theme">{formatCurrency(amount)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Labor -->
|
||||
<div>
|
||||
<p class="detail-label">Labor</p>
|
||||
<p class="text-theme">{formatCurrency(labor)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
{#if address}
|
||||
<div class="mt-4 border-t border-theme pt-4">
|
||||
<p class="detail-label">Address</p>
|
||||
<p class="text-theme">{address}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Team Members -->
|
||||
{#if teamMembers.length > 0}
|
||||
<div class="mt-4 border-t border-theme pt-4">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<p class="detail-label">Team</p>
|
||||
{#if messagableMembers.length >= 2}
|
||||
<button
|
||||
type="button"
|
||||
onclick={messageAllTeamMembers}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
Message Team
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each teamMembers as member (member.id)}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-6 w-6 items-center justify-center rounded text-xs font-bold {member.role ===
|
||||
'TEAM_LEADER'
|
||||
? 'bg-accent-500/20 text-accent-600 dark:text-accent-400'
|
||||
: 'bg-secondary-500/20 text-secondary-600 dark:text-secondary-400'}"
|
||||
>
|
||||
{member.role === 'TEAM_LEADER' ? 'TL' : 'TM'}
|
||||
</span>
|
||||
{#if member.role !== 'ADMIN'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => messageTeamMember(member.id)}
|
||||
class="rounded-full p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Message {member.fullName}"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Spacer to maintain alignment for admin members -->
|
||||
<span class="h-6 w-6"></span>
|
||||
{/if}
|
||||
<span class="text-theme">{member.fullName}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Wave Integration -->
|
||||
{#if waveServiceId}
|
||||
<div class="mt-4 border-t border-theme pt-4">
|
||||
<p class="detail-label">Wave Integration</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge-primary">Linked</span>
|
||||
{#if linkedProduct}
|
||||
<span class="text-theme">{linkedProduct.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xs text-theme-muted">{waveServiceId}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes -->
|
||||
{#if notes}
|
||||
<div class="mt-4 border-t border-theme pt-4">
|
||||
<p class="detail-label">Notes</p>
|
||||
<p class="whitespace-pre-wrap text-theme-secondary">{notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
769
src/lib/components/admin/projects/ProjectForm.svelte
Normal file
@ -0,0 +1,769 @@
|
||||
<script lang="ts">
|
||||
import { CreateProjectStore, UpdateProjectStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
import type { GetProject$result, Customers$result, Accounts$result, Team$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Project = NonNullable<GetProject$result['project']>;
|
||||
type Customer = Customers$result['customers'][number];
|
||||
type Account = Accounts$result['accounts'][number];
|
||||
type TeamProfile = Team$result['teamProfiles'][number];
|
||||
|
||||
interface WaveProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unitPrice: number | string;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project?: Project;
|
||||
customers: Customer[];
|
||||
accounts: Account[];
|
||||
teamProfiles: TeamProfile[];
|
||||
waveProducts?: WaveProduct[];
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
project,
|
||||
customers,
|
||||
accounts,
|
||||
teamProfiles,
|
||||
waveProducts = [],
|
||||
onSuccess,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!project);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Helper to find customer Relay Global ID from UUID
|
||||
function findCustomerGlobalId(customerUuid: string | null | undefined): string {
|
||||
if (!customerUuid) return '';
|
||||
// Find customer whose Global ID decodes to this UUID
|
||||
const customer = customers.find((c) => fromGlobalId(c.id) === customerUuid);
|
||||
return customer?.id ?? '';
|
||||
}
|
||||
|
||||
// Helper to find address Relay Global ID from UUID
|
||||
function findAddressGlobalId(addressUuid: string | null | undefined): string {
|
||||
if (!addressUuid) return '';
|
||||
// Search through all accounts for this address UUID
|
||||
for (const account of accounts) {
|
||||
const address = account.addresses?.find((a) => fromGlobalId(a.id) === addressUuid);
|
||||
if (address) return address.id;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper to find account that contains an address UUID
|
||||
function findAccountForAddressUuid(addressUuid: string | null | undefined): string {
|
||||
if (!addressUuid) return '';
|
||||
for (const account of accounts) {
|
||||
const hasAddress = account.addresses?.some((a) => fromGlobalId(a.id) === addressUuid);
|
||||
if (hasAddress) return account.id;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper to convert team member UUIDs (pk) to Relay Global IDs
|
||||
function findTeamMemberGlobalIds(memberPks: { pk: string }[] | null | undefined): string[] {
|
||||
if (!memberPks) return [];
|
||||
return memberPks
|
||||
.map((m) => {
|
||||
// Find team profile whose Global ID decodes to this pk UUID
|
||||
const profile = teamProfiles.find((p) => fromGlobalId(p.id) === m.pk);
|
||||
return profile?.id;
|
||||
})
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
|
||||
// Form fields - convert project UUIDs to Relay Global IDs for form
|
||||
let customerId = $state(findCustomerGlobalId(project?.customerId));
|
||||
let selectedAccountId = $state(findAccountForAddressUuid(project?.accountAddressId));
|
||||
let accountAddressId = $state(findAddressGlobalId(project?.accountAddressId));
|
||||
let useManualAddress = $state(
|
||||
!project?.accountAddressId && !!(project?.streetAddress || project?.city)
|
||||
);
|
||||
let name = $state(project?.name ?? '');
|
||||
let date = $state(project?.date ?? '');
|
||||
let amount = $state(project?.amount?.toString() ?? '');
|
||||
let labor = $state(project?.labor?.toString() ?? '');
|
||||
let status = $state(project?.status ?? 'SCHEDULED');
|
||||
let streetAddress = $state(project?.streetAddress ?? '');
|
||||
let city = $state(project?.city ?? '');
|
||||
let stateCode = $state(project?.state ?? '');
|
||||
let zipCode = $state(project?.zipCode ?? '');
|
||||
let notes = $state(project?.notes ?? '');
|
||||
let selectedTeamMemberIds = $state<string[]>(findTeamMemberGlobalIds(project?.teamMembers));
|
||||
let waveServiceId = $state(project?.waveServiceId ?? '');
|
||||
|
||||
// Find linked Wave product for display
|
||||
let linkedProduct = $derived(
|
||||
waveServiceId ? waveProducts.find((p) => fromGlobalId(p.id) === waveServiceId) : null
|
||||
);
|
||||
|
||||
// Filter accounts for selected customer
|
||||
// customerId state is now a Relay Global ID
|
||||
// account.customerId is a UUID, so we need to convert for comparison
|
||||
let customerAccounts = $derived(() => {
|
||||
if (!customerId) return [];
|
||||
const customerUuid = fromGlobalId(customerId);
|
||||
return accounts.filter((a) => a.customerId === customerUuid);
|
||||
});
|
||||
|
||||
// Get addresses for selected account
|
||||
let accountAddresses = $derived(() => {
|
||||
if (!selectedAccountId) return [];
|
||||
const account = customerAccounts().find((a) => a.id === selectedAccountId);
|
||||
return account?.addresses ?? [];
|
||||
});
|
||||
|
||||
// Get currently selected address details for display
|
||||
let selectedAddressDetails = $derived(() => {
|
||||
if (!accountAddressId) return null;
|
||||
for (const account of customerAccounts()) {
|
||||
const address = account.addresses?.find((a) => a.id === accountAddressId);
|
||||
if (address) {
|
||||
return { account, address };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// When customer changes, reset account and address
|
||||
function handleCustomerChange() {
|
||||
selectedAccountId = '';
|
||||
accountAddressId = '';
|
||||
useManualAddress = false;
|
||||
}
|
||||
|
||||
// When account changes, auto-select if only one address, otherwise reset
|
||||
function handleAccountChange() {
|
||||
const addresses = accountAddresses();
|
||||
if (addresses.length === 1) {
|
||||
accountAddressId = addresses[0].id;
|
||||
} else {
|
||||
accountAddressId = '';
|
||||
}
|
||||
useManualAddress = false;
|
||||
}
|
||||
|
||||
// Toggle manual address mode
|
||||
function toggleManualAddress() {
|
||||
useManualAddress = !useManualAddress;
|
||||
if (useManualAddress) {
|
||||
selectedAccountId = '';
|
||||
accountAddressId = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Non-admin team members only
|
||||
let nonAdminTeamMembers = $derived(teamProfiles.filter((p) => p.role !== 'ADMIN'));
|
||||
|
||||
// Get admin profile ID
|
||||
let adminProfileId = $derived(teamProfiles.find((p) => p.role === 'ADMIN')?.id ?? null);
|
||||
|
||||
// Track if project is dispatched (admin profile is in team members)
|
||||
let isDispatched = $derived(
|
||||
adminProfileId ? selectedTeamMemberIds.includes(adminProfileId) : false
|
||||
);
|
||||
|
||||
function toggleDispatched() {
|
||||
if (!adminProfileId) return;
|
||||
if (isDispatched) {
|
||||
// When un-dispatching, remove admin and clear all team members
|
||||
selectedTeamMemberIds = [];
|
||||
} else {
|
||||
selectedTeamMemberIds = [...selectedTeamMemberIds, adminProfileId];
|
||||
}
|
||||
}
|
||||
|
||||
const createStore = new CreateProjectStore();
|
||||
const updateStore = new UpdateProjectStore();
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!customerId || !name || !date || !amount) {
|
||||
error = 'Please provide customer, name, date, and amount';
|
||||
return;
|
||||
}
|
||||
|
||||
// Require either accountAddressId OR manual address
|
||||
const hasAccountAddress = !!accountAddressId;
|
||||
const hasManualAddress = !!(streetAddress && city && stateCode);
|
||||
if (!hasAccountAddress && !hasManualAddress) {
|
||||
error = 'Please select an account address or enter a manual address';
|
||||
return;
|
||||
}
|
||||
|
||||
const baseInput = {
|
||||
customerId,
|
||||
accountAddressId: accountAddressId || null,
|
||||
name,
|
||||
date,
|
||||
amount: parseFloat(amount),
|
||||
labor: labor ? parseFloat(labor) : 0,
|
||||
status,
|
||||
streetAddress: accountAddressId ? null : streetAddress || null,
|
||||
city: accountAddressId ? null : city || null,
|
||||
state: accountAddressId ? null : stateCode || null,
|
||||
zipCode: accountAddressId ? null : zipCode || null,
|
||||
notes: notes || null,
|
||||
teamMemberIds: selectedTeamMemberIds.length > 0 ? selectedTeamMemberIds : null,
|
||||
waveServiceId: waveServiceId || ''
|
||||
};
|
||||
|
||||
if (isEdit && project) {
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: project.id,
|
||||
...baseInput
|
||||
}
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const res = await createStore.mutate({
|
||||
input: baseInput
|
||||
});
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save project';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
|
||||
function toggleTeamMember(id: string) {
|
||||
if (selectedTeamMemberIds.includes(id)) {
|
||||
selectedTeamMemberIds = selectedTeamMemberIds.filter((m) => m !== id);
|
||||
} else {
|
||||
selectedTeamMemberIds = [...selectedTeamMemberIds, id];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<!-- Customer -->
|
||||
<div>
|
||||
<label for="customerId" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Customer <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="customerId"
|
||||
bind:value={customerId}
|
||||
onchange={handleCustomerChange}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select a customer</option>
|
||||
{#each customers as customer (customer.id)}
|
||||
<option value={customer.id}>{customer.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Selection Section -->
|
||||
{#if customerId}
|
||||
<div class="space-y-4 rounded-lg border border-theme bg-theme-card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-theme">Project Location</p>
|
||||
{#if customerAccounts().length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleManualAddress}
|
||||
class="text-xs text-primary-500 hover:text-primary-600"
|
||||
>
|
||||
{useManualAddress ? 'Use account address' : 'Enter address manually'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !useManualAddress && customerAccounts().length > 0}
|
||||
<!-- Account Selection -->
|
||||
<div>
|
||||
<label for="selectedAccountId" class="mb-1.5 block text-sm text-theme-secondary">
|
||||
Account
|
||||
</label>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="selectedAccountId"
|
||||
bind:value={selectedAccountId}
|
||||
onchange={handleAccountChange}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select an account</option>
|
||||
{#each customerAccounts() as account (account.id)}
|
||||
<option value={account.id}>{account.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Selection (shown when account is selected) -->
|
||||
{#if selectedAccountId && accountAddresses().length > 0}
|
||||
<div>
|
||||
<label for="accountAddressId" class="mb-1.5 block text-sm text-theme-secondary">
|
||||
Address
|
||||
</label>
|
||||
{#if accountAddresses().length === 1}
|
||||
<!-- Single address - show as confirmation -->
|
||||
{@const address = accountAddresses()[0]}
|
||||
<div
|
||||
class="rounded-lg border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary-500"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-theme">{address.streetAddress}</p>
|
||||
<p class="text-theme-secondary">
|
||||
{address.city}, {address.state}
|
||||
{address.zipCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Multiple addresses - show dropdown -->
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="accountAddressId"
|
||||
bind:value={accountAddressId}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select an address</option>
|
||||
{#each accountAddresses() as address (address.id)}
|
||||
<option value={address.id}>
|
||||
{address.streetAddress}, {address.city}, {address.state}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Show selected address confirmation (for multi-address accounts) -->
|
||||
{#if accountAddresses().length > 1 && selectedAddressDetails()}
|
||||
{@const details = selectedAddressDetails()}
|
||||
<div
|
||||
class="rounded-lg border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary-500"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-theme">{details?.address.streetAddress}</p>
|
||||
<p class="text-theme-secondary">
|
||||
{details?.address.city}, {details?.address.state}
|
||||
{details?.address.zipCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if selectedAccountId && accountAddresses().length === 0}
|
||||
<p class="text-sm text-theme-muted italic">
|
||||
This account has no addresses. Please enter address manually.
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Manual Address Entry -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="streetAddress" class="mb-1 block text-sm text-theme-secondary"
|
||||
>Street</label
|
||||
>
|
||||
<input
|
||||
id="streetAddress"
|
||||
type="text"
|
||||
bind:value={streetAddress}
|
||||
disabled={submitting}
|
||||
placeholder="123 Main St"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-6 gap-3">
|
||||
<div class="col-span-3">
|
||||
<label for="city" class="mb-1 block text-sm text-theme-secondary">City</label>
|
||||
<input
|
||||
id="city"
|
||||
type="text"
|
||||
bind:value={city}
|
||||
disabled={submitting}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<label for="stateCode" class="mb-1 block text-sm text-theme-secondary">State</label>
|
||||
<input
|
||||
id="stateCode"
|
||||
type="text"
|
||||
bind:value={stateCode}
|
||||
disabled={submitting}
|
||||
maxlength="2"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="zipCode" class="mb-1 block text-sm text-theme-secondary">Zip</label>
|
||||
<input
|
||||
id="zipCode"
|
||||
type="text"
|
||||
bind:value={zipCode}
|
||||
disabled={submitting}
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Project Name -->
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Project Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="e.g., Deep Clean - March"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label for="date" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Date <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="date"
|
||||
type="date"
|
||||
bind:value={date}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Amount + Labor -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="amount" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Amount <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={amount}
|
||||
disabled={submitting}
|
||||
placeholder="0.00"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="labor" class="mb-1.5 block text-sm font-medium text-theme"> Labor </label>
|
||||
<div class="relative">
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
|
||||
<input
|
||||
id="labor"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={labor}
|
||||
disabled={submitting}
|
||||
placeholder="0.00"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme py-2 pr-3 pl-7 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status (only for edit) -->
|
||||
{#if isEdit}
|
||||
<div>
|
||||
<label for="status" class="mb-1.5 block text-sm font-medium text-theme"> Status </label>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="status"
|
||||
bind:value={status}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="SCHEDULED">Scheduled</option>
|
||||
<option value="IN_PROGRESS">In Progress</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dispatched Toggle -->
|
||||
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-card p-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme">Dispatched</p>
|
||||
<p class="text-xs text-theme-muted">Required before assigning team members</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleDispatched}
|
||||
disabled={submitting}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 {isDispatched
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-gray-200 dark:bg-gray-700'}"
|
||||
role="switch"
|
||||
aria-checked={isDispatched}
|
||||
aria-label="Toggle dispatched status"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {isDispatched
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Team Members -->
|
||||
<div>
|
||||
<p class="mb-1.5 text-sm font-medium text-theme">Team Members</p>
|
||||
<div
|
||||
class="space-y-2 rounded-lg border border-theme bg-theme-card p-3 {!isDispatched
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
{#if !isDispatched}
|
||||
<p class="text-sm text-theme-muted italic">Enable "Dispatched" to assign team members</p>
|
||||
{:else if nonAdminTeamMembers.length > 0}
|
||||
{#each nonAdminTeamMembers as member (member.id)}
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-lg interactive p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTeamMemberIds.includes(member.id)}
|
||||
onchange={() => toggleTeamMember(member.id)}
|
||||
disabled={submitting}
|
||||
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-theme">{member.fullName}</span>
|
||||
{#if member.role === 'TEAM_LEADER'}
|
||||
<span
|
||||
class="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
|
||||
>
|
||||
TL
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-sm text-theme-muted">No team members available</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label for="notes" class="mb-1.5 block text-sm font-medium text-theme"> Notes </label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={notes}
|
||||
disabled={submitting}
|
||||
rows="3"
|
||||
placeholder="Optional notes about the project"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Wave Product -->
|
||||
{#if waveProducts.length > 0}
|
||||
<div class="border-t border-theme pt-4">
|
||||
<label for="waveProduct" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Wave Product
|
||||
</label>
|
||||
<select
|
||||
id="waveProduct"
|
||||
bind:value={waveServiceId}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">No linked product</option>
|
||||
{#each waveProducts as product (product.id)}
|
||||
{@const decodedId = fromGlobalId(product.id)}
|
||||
<option value={decodedId}>
|
||||
{product.name} (${Number(product.unitPrice).toFixed(2)})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if linkedProduct}
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
Currently linked to: {linkedProduct.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Project'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
142
src/lib/components/admin/projects/ProjectScopeForm.svelte
Normal file
@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { createProjectScope, updateProjectScope } from '$lib/utils/scopes';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { ProjectScope$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Scope = NonNullable<ProjectScope$result['projectScope']>;
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
scope?: Scope;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { projectId, scope, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!scope);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let name = $state(scope?.name ?? 'Project Scope');
|
||||
let description = $state(scope?.description ?? '');
|
||||
let isActive = $state(scope?.isActive ?? true);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!name.trim()) {
|
||||
error = 'Please provide a scope name';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && scope) {
|
||||
await updateProjectScope({
|
||||
id: scope.id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
isActive
|
||||
});
|
||||
} else {
|
||||
await createProjectScope({
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
isActive
|
||||
});
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save scope';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="name" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Scope Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={submitting}
|
||||
placeholder="e.g., Project Scope"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
disabled={submitting}
|
||||
rows="3"
|
||||
placeholder="Optional description of this project scope"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="isActive"
|
||||
type="checkbox"
|
||||
bind:checked={isActive}
|
||||
disabled={submitting}
|
||||
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<label for="isActive" class="text-sm text-theme">Active scope</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Scope'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
365
src/lib/components/admin/projects/ProjectScopeSection.svelte
Normal file
@ -0,0 +1,365 @@
|
||||
<script lang="ts">
|
||||
import type { ProjectScope$result } from '$houdini';
|
||||
|
||||
// Types derived from the ProjectScope query
|
||||
export type ProjectScopeType = NonNullable<ProjectScope$result['projectScope']>;
|
||||
export type ProjectCategoryType = ProjectScopeType['projectAreas'][number];
|
||||
export type ProjectTaskType = ProjectCategoryType['projectTasks'][number];
|
||||
|
||||
interface Props {
|
||||
scope: ProjectScopeType;
|
||||
expandedCategories: Set<string>;
|
||||
editable?: boolean;
|
||||
onToggleCategory: (categoryId: string) => void;
|
||||
onEditScope?: (scope: ProjectScopeType) => void;
|
||||
onDeleteScope?: (scopeId: string) => void;
|
||||
onAddCategory?: (scopeId: string, nextOrder: number) => void;
|
||||
onEditCategory?: (scopeId: string, category: ProjectCategoryType) => void;
|
||||
onDeleteCategory?: (categoryId: string) => void;
|
||||
onAddTask?: (categoryId: string, nextOrder: number) => void;
|
||||
onEditTask?: (categoryId: string, task: ProjectTaskType) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
scope,
|
||||
expandedCategories,
|
||||
editable = true,
|
||||
onToggleCategory,
|
||||
onEditScope,
|
||||
onDeleteScope,
|
||||
onAddCategory,
|
||||
onEditCategory,
|
||||
onDeleteCategory,
|
||||
onAddTask,
|
||||
onEditTask,
|
||||
onDeleteTask
|
||||
}: Props = $props();
|
||||
|
||||
function getTotalTasks(): number {
|
||||
return scope.projectAreas?.reduce((acc, cat) => acc + (cat.projectTasks?.length ?? 0), 0) ?? 0;
|
||||
}
|
||||
|
||||
function sortedCategories(categories: ProjectCategoryType[]): ProjectCategoryType[] {
|
||||
return [...categories].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
function sortedTasks(tasks: ProjectTaskType[]): ProjectTaskType[] {
|
||||
return [...tasks].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Scope Header -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium text-theme">{scope.name}</h3>
|
||||
{#if scope.isActive}
|
||||
<span
|
||||
class="rounded bg-accent-100 px-1.5 py-0.5 text-xs font-medium text-accent-700 dark:bg-accent-900/40 dark:text-accent-400"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if scope.description}
|
||||
<p class="mt-1 text-sm text-theme-muted">{scope.description}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
{scope.projectAreas?.length ?? 0}
|
||||
{(scope.projectAreas?.length ?? 0) === 1 ? 'category' : 'categories'}, {getTotalTasks()}
|
||||
{getTotalTasks() === 1 ? 'task' : 'tasks'}
|
||||
</p>
|
||||
</div>
|
||||
{#if editable && onEditScope && onDeleteScope}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEditScope(scope)}
|
||||
class="rounded p-1.5 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Edit scope"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteScope(scope.id)}
|
||||
class="rounded p-1.5 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete scope"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="border-t border-theme pt-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<p class="text-xs font-medium tracking-wide text-theme-muted uppercase">Categories</p>
|
||||
{#if editable && onAddCategory}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onAddCategory(scope.id, scope.projectAreas?.length ?? 0)}
|
||||
class="text-xs font-medium text-accent-500 hover:text-accent-600"
|
||||
>
|
||||
+ Add Category
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if scope.projectAreas && scope.projectAreas.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each sortedCategories(scope.projectAreas) as category (category.id)}
|
||||
{@const taskCount = category.projectTasks?.length ?? 0}
|
||||
{@const isCategoryExpanded = expandedCategories.has(category.id)}
|
||||
<div
|
||||
class="rounded-lg border border-l-2 border-theme border-l-accent-400 bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<!-- Category Header -->
|
||||
<div class="flex items-start justify-between gap-1 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onToggleCategory(category.id)}
|
||||
class="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 text-theme-muted transition-transform {isCategoryExpanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-theme">{category.name}</span>
|
||||
</span>
|
||||
<span class="mt-0.5 block pl-6 text-xs text-theme-muted">
|
||||
{taskCount}
|
||||
{taskCount === 1 ? 'task' : 'tasks'}
|
||||
</span>
|
||||
</button>
|
||||
{#if editable && onAddTask && onEditCategory && onDeleteCategory}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onAddTask(category.id, taskCount)}
|
||||
class="rounded p-1 text-accent-500 hover:bg-accent-500/10"
|
||||
aria-label="Add task"
|
||||
title="Add task"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEditCategory(scope.id, category)}
|
||||
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Edit category"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteCategory(category.id)}
|
||||
class="rounded p-1 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete category"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tasks (expanded content) -->
|
||||
{#if isCategoryExpanded}
|
||||
<div class="border-t border-theme px-2 pb-2">
|
||||
{#if category.projectTasks && category.projectTasks.length > 0}
|
||||
<div class="mt-2 space-y-2">
|
||||
{#each sortedTasks(category.projectTasks) as task (task.id)}
|
||||
<div class="rounded bg-gray-50 p-2 text-xs dark:bg-gray-800/50">
|
||||
<!-- Task actions -->
|
||||
{#if editable && onEditTask && onDeleteTask}
|
||||
<div class="mb-2 flex items-center justify-end gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEditTask(category.id, task)}
|
||||
class="rounded p-0.5 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Edit task"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteTask(task.id)}
|
||||
class="rounded p-0.5 text-theme-muted hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete task"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Task fields stacked vertically -->
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
|
||||
>
|
||||
Scope Description
|
||||
<button
|
||||
type="button"
|
||||
class="group relative"
|
||||
aria-label="What is scope description?"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3 text-theme-muted hover:text-theme"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-[10px] text-white normal-case group-hover:block dark:bg-gray-700"
|
||||
>
|
||||
The customer-facing description of the task
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
<p class="text-theme">{task.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-[10px] font-medium text-theme-muted uppercase"
|
||||
>
|
||||
Checklist Description
|
||||
<button
|
||||
type="button"
|
||||
class="group relative"
|
||||
aria-label="What is checklist description?"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3 text-theme-muted hover:text-theme"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden w-48 -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-[10px] text-white normal-case group-hover:block dark:bg-gray-700"
|
||||
>
|
||||
What team members see during a session
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
<p class="text-theme">{task.checklistDescription || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-center text-xs text-theme-muted">No tasks yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-theme-muted">No categories defined for this scope.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
164
src/lib/components/admin/projects/ProjectTaskForm.svelte
Normal file
@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { createProjectScopeTask, updateProjectScopeTask } from '$lib/utils/scopes';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { ProjectScope$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Task = NonNullable<
|
||||
ProjectScope$result['projectScope']
|
||||
>['projectAreas'][number]['projectTasks'][number];
|
||||
|
||||
interface Props {
|
||||
categoryId: string;
|
||||
task?: Task;
|
||||
nextOrder?: number;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { categoryId, task, nextOrder = 0, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!task);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let description = $state(task?.description ?? '');
|
||||
let checklistDescription = $state(task?.checklistDescription ?? '');
|
||||
let estimatedMinutes = $state<number | null>(task?.estimatedMinutes ?? null);
|
||||
let order = $state(task?.order ?? nextOrder);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!description.trim()) {
|
||||
error = 'Please provide a task description';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && task) {
|
||||
await updateProjectScopeTask({
|
||||
id: task.id,
|
||||
description: description.trim(),
|
||||
checklistDescription: checklistDescription.trim() || null,
|
||||
estimatedMinutes: estimatedMinutes || null,
|
||||
order
|
||||
});
|
||||
} else {
|
||||
await createProjectScopeTask({
|
||||
categoryId,
|
||||
description: description.trim(),
|
||||
checklistDescription: checklistDescription.trim() || '',
|
||||
estimatedMinutes: estimatedMinutes || null,
|
||||
order
|
||||
});
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save task';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<div>
|
||||
<label for="description" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Task Description <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
disabled={submitting}
|
||||
rows="2"
|
||||
placeholder="e.g., Deep clean all kitchen appliances"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="checklistDescription" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Checklist Note
|
||||
</label>
|
||||
<input
|
||||
id="checklistDescription"
|
||||
type="text"
|
||||
bind:value={checklistDescription}
|
||||
disabled={submitting}
|
||||
placeholder="Optional short note for checklist"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-theme-muted">Shortened version shown on project checklists</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="estimatedMinutes" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Estimated Time (minutes)
|
||||
</label>
|
||||
<input
|
||||
id="estimatedMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={estimatedMinutes}
|
||||
disabled={submitting}
|
||||
placeholder="e.g., 30"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-theme-muted">Estimated time to complete this task</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="order" class="mb-1.5 block text-sm font-medium text-theme"> Display Order </label>
|
||||
<input
|
||||
id="order"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={order}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Add Task'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
templates: Template[];
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onCreateBlank: () => void;
|
||||
onCreateFromTemplate: (templateId: string) => void;
|
||||
}
|
||||
|
||||
let { templates, isOpen, onToggle, onCreateBlank, onCreateFromTemplate }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
{#if templates.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>Create Scope</span>
|
||||
<svg class="h-3 w-3" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if isOpen}
|
||||
<!-- Backdrop to close dropdown -->
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-10"
|
||||
onclick={onToggle}
|
||||
aria-label="Close dropdown"
|
||||
></button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
class="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-theme bg-theme-card py-1 shadow-theme-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
onToggle();
|
||||
onCreateBlank();
|
||||
}}
|
||||
class="block w-full interactive px-3 py-2 text-left text-sm text-theme"
|
||||
>
|
||||
Blank Scope
|
||||
</button>
|
||||
<div class="my-1 border-t border-theme"></div>
|
||||
<p class="px-3 py-1 text-[10px] font-medium text-theme-muted uppercase">From Template</p>
|
||||
{#each templates as template (template.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
onToggle();
|
||||
onCreateFromTemplate(template.id);
|
||||
}}
|
||||
class="block w-full interactive px-3 py-2 text-left text-sm text-theme"
|
||||
>
|
||||
{template.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCreateBlank}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>Create Scope</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
297
src/lib/components/admin/reports/AddProjectsModal.svelte
Normal file
@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import { AdminProjectsStore, UpdateReportStore } from '$houdini';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { GetReport$result } from '$houdini';
|
||||
|
||||
type Report = NonNullable<GetReport$result['report']>;
|
||||
|
||||
interface AccountInfo {
|
||||
accountName: string;
|
||||
addressName?: string | null;
|
||||
}
|
||||
|
||||
interface CustomerInfo {
|
||||
customerName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
report: Report;
|
||||
teamMemberId: string;
|
||||
accountLookup: Map<string, AccountInfo>;
|
||||
customerLookup: Map<string, CustomerInfo>;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
report,
|
||||
teamMemberId,
|
||||
accountLookup,
|
||||
customerLookup,
|
||||
onSuccess
|
||||
}: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let availableProjects = $state<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
accountAddressId: string | null;
|
||||
customerId: string | null;
|
||||
}[]
|
||||
>([]);
|
||||
let selectedProjectIds = new SvelteSet<string>();
|
||||
|
||||
const projectsStore = new AdminProjectsStore();
|
||||
const updateStore = new UpdateReportStore();
|
||||
|
||||
// Get already included project IDs
|
||||
let includedProjectIds = $derived(new Set(report.projects.map((p) => p.id)));
|
||||
|
||||
// Load available projects when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
loadAvailableProjects();
|
||||
selectedProjectIds.clear();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAvailableProjects() {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
// Extract year-month from report date (YYYY-MM)
|
||||
const [year, month] = report.date.split('-');
|
||||
const startOfMonth = `${year}-${month}-01`;
|
||||
const endOfMonth = new Date(parseInt(year), parseInt(month), 0).toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const result = await projectsStore.fetch({
|
||||
variables: {
|
||||
filters: {
|
||||
status: { exact: 'COMPLETED' },
|
||||
date: { gte: startOfMonth, lte: endOfMonth }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Filter to only projects assigned to this team member and not already included
|
||||
availableProjects = (result.data?.projects ?? []).filter((project) => {
|
||||
const isAssigned = project.teamMembers?.some((m) => m.pk === teamMemberId);
|
||||
const notIncluded = !includedProjectIds.has(project.id);
|
||||
return isAssigned && notIncluded;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err);
|
||||
error = 'Failed to load projects';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatItemDate(dateStr: string): string {
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function getProjectDisplayName(project: {
|
||||
date: string;
|
||||
name: string;
|
||||
accountAddressId: string | null;
|
||||
customerId: string | null;
|
||||
}): string {
|
||||
const dateStr = formatItemDate(project.date);
|
||||
|
||||
let entityName = 'Unknown';
|
||||
if (project.accountAddressId) {
|
||||
const accountInfo = accountLookup.get(project.accountAddressId);
|
||||
if (accountInfo) {
|
||||
entityName = accountInfo.accountName;
|
||||
}
|
||||
} else if (project.customerId) {
|
||||
const customerInfo = customerLookup.get(project.customerId);
|
||||
if (customerInfo) {
|
||||
entityName = customerInfo.customerName;
|
||||
}
|
||||
}
|
||||
|
||||
return `${dateStr}, ${entityName} - ${project.name}`;
|
||||
}
|
||||
|
||||
function toggleProject(projectId: string) {
|
||||
if (selectedProjectIds.has(projectId)) {
|
||||
selectedProjectIds.delete(projectId);
|
||||
} else {
|
||||
selectedProjectIds.add(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
for (const project of availableProjects) {
|
||||
selectedProjectIds.add(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedProjectIds.clear();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (selectedProjectIds.size === 0) return;
|
||||
|
||||
saving = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
// Combine existing project IDs with newly selected ones
|
||||
const existingIds = report.projects.map((p) => p.id);
|
||||
const newIds = [...selectedProjectIds];
|
||||
const allIds = [...existingIds, ...newIds];
|
||||
|
||||
await updateStore.mutate({
|
||||
input: {
|
||||
id: report.id,
|
||||
projectIds: allIds
|
||||
}
|
||||
});
|
||||
|
||||
open = false;
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to add projects:', err);
|
||||
error = 'Failed to add projects';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onmousedown={handleBackdropClick}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
>
|
||||
<div
|
||||
class="max-h-[90vh] w-full max-w-lg overflow-hidden rounded-xl bg-theme-card shadow-xl"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-theme px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-theme">Add Projects</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Select completed projects to add to this report.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[60vh] overflow-y-auto p-6">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<svg
|
||||
class="h-8 w-8 animate-spin text-accent-500"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{:else if availableProjects.length === 0}
|
||||
<p class="py-8 text-center text-sm text-theme-muted italic">
|
||||
No additional projects available for this team member in this month.
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Select All / None -->
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<button
|
||||
onclick={selectAll}
|
||||
class="text-sm font-medium text-accent-600 hover:underline dark:text-accent-400"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onclick={selectNone}
|
||||
class="text-sm font-medium text-theme-muted hover:underline"
|
||||
>
|
||||
Select None
|
||||
</button>
|
||||
<span class="ml-auto text-sm text-theme-muted">
|
||||
{selectedProjectIds.size} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each availableProjects as project (project.id)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme interactive p-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProjectIds.has(project.id)}
|
||||
onchange={() => toggleProject(project.id)}
|
||||
class="border-theme-muted h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-theme">
|
||||
{getProjectDisplayName(project)}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 rounded-lg bg-error-500/10 p-3">
|
||||
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 border-t border-theme px-6 py-4">
|
||||
<button onclick={handleClose} class="btn-ghost" disabled={saving}> Cancel </button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
class="btn-primary"
|
||||
disabled={saving || selectedProjectIds.size === 0}
|
||||
>
|
||||
{#if saving}
|
||||
Adding...
|
||||
{:else}
|
||||
Add {selectedProjectIds.size} Project{selectedProjectIds.size !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
267
src/lib/components/admin/reports/AddServicesModal.svelte
Normal file
@ -0,0 +1,267 @@
|
||||
<script lang="ts">
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import { AdminServicesStore, UpdateReportStore } from '$houdini';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { GetReport$result } from '$houdini';
|
||||
|
||||
type Report = NonNullable<GetReport$result['report']>;
|
||||
|
||||
interface AccountInfo {
|
||||
accountName: string;
|
||||
addressName?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
report: Report;
|
||||
teamMemberId: string;
|
||||
accountLookup: Map<string, AccountInfo>;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), report, teamMemberId, accountLookup, onSuccess }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let availableServices = $state<{ id: string; date: string; accountAddressId: string | null }[]>(
|
||||
[]
|
||||
);
|
||||
let selectedServiceIds = new SvelteSet<string>();
|
||||
|
||||
const servicesStore = new AdminServicesStore();
|
||||
const updateStore = new UpdateReportStore();
|
||||
|
||||
// Get already included service IDs
|
||||
let includedServiceIds = $derived(new Set(report.services.map((s) => s.id)));
|
||||
|
||||
// Load available services when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
loadAvailableServices();
|
||||
selectedServiceIds.clear();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAvailableServices() {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
// Extract year-month from report date (YYYY-MM)
|
||||
const [year, month] = report.date.split('-');
|
||||
const startOfMonth = `${year}-${month}-01`;
|
||||
const endOfMonth = new Date(parseInt(year), parseInt(month), 0).toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const result = await servicesStore.fetch({
|
||||
variables: {
|
||||
filters: {
|
||||
status: { exact: 'COMPLETED' },
|
||||
date: { gte: startOfMonth, lte: endOfMonth }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Filter to only services assigned to this team member and not already included
|
||||
availableServices = (result.data?.services ?? []).filter((service) => {
|
||||
const isAssigned = service.teamMembers?.some((m) => m.pk === teamMemberId);
|
||||
const notIncluded = !includedServiceIds.has(service.id);
|
||||
return isAssigned && notIncluded;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load services:', err);
|
||||
error = 'Failed to load services';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatItemDate(dateStr: string): string {
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function getServiceDisplayName(service: {
|
||||
date: string;
|
||||
accountAddressId: string | null;
|
||||
}): string {
|
||||
const dateStr = formatItemDate(service.date);
|
||||
const accountInfo = service.accountAddressId
|
||||
? accountLookup.get(service.accountAddressId)
|
||||
: null;
|
||||
const accountName = accountInfo?.accountName ?? 'Unknown Account';
|
||||
return `${dateStr}, ${accountName}`;
|
||||
}
|
||||
|
||||
function toggleService(serviceId: string) {
|
||||
if (selectedServiceIds.has(serviceId)) {
|
||||
selectedServiceIds.delete(serviceId);
|
||||
} else {
|
||||
selectedServiceIds.add(serviceId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
for (const service of availableServices) {
|
||||
selectedServiceIds.add(service.id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedServiceIds.clear();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (selectedServiceIds.size === 0) return;
|
||||
|
||||
saving = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
// Combine existing service IDs with newly selected ones
|
||||
const existingIds = report.services.map((s) => s.id);
|
||||
const newIds = [...selectedServiceIds];
|
||||
const allIds = [...existingIds, ...newIds];
|
||||
|
||||
await updateStore.mutate({
|
||||
input: {
|
||||
id: report.id,
|
||||
serviceIds: allIds
|
||||
}
|
||||
});
|
||||
|
||||
open = false;
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to add services:', err);
|
||||
error = 'Failed to add services';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onmousedown={handleBackdropClick}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
>
|
||||
<div
|
||||
class="max-h-[90vh] w-full max-w-lg overflow-hidden rounded-xl bg-theme-card shadow-xl"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-theme px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-theme">Add Services</h2>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Select completed services to add to this report.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[60vh] overflow-y-auto p-6">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<svg
|
||||
class="h-8 w-8 animate-spin text-secondary-500"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{:else if availableServices.length === 0}
|
||||
<p class="py-8 text-center text-sm text-theme-muted italic">
|
||||
No additional services available for this team member in this month.
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Select All / None -->
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<button
|
||||
onclick={selectAll}
|
||||
class="text-sm font-medium text-secondary-600 hover:underline dark:text-secondary-400"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onclick={selectNone}
|
||||
class="text-sm font-medium text-theme-muted hover:underline"
|
||||
>
|
||||
Select None
|
||||
</button>
|
||||
<span class="ml-auto text-sm text-theme-muted">
|
||||
{selectedServiceIds.size} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each availableServices as service (service.id)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme interactive p-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedServiceIds.has(service.id)}
|
||||
onchange={() => toggleService(service.id)}
|
||||
class="border-theme-muted h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-theme">
|
||||
{getServiceDisplayName(service)}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 rounded-lg bg-error-500/10 p-3">
|
||||
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 border-t border-theme px-6 py-4">
|
||||
<button onclick={handleClose} class="btn-ghost" disabled={saving}> Cancel </button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
class="btn-primary"
|
||||
disabled={saving || selectedServiceIds.size === 0}
|
||||
>
|
||||
{#if saving}
|
||||
Adding...
|
||||
{:else}
|
||||
Add {selectedServiceIds.size} Service{selectedServiceIds.size !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
134
src/lib/components/admin/reports/ReportForm.svelte
Normal file
@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { CreateReportStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import type { Team$result } from '$houdini';
|
||||
|
||||
type TeamProfile = Team$result['teamProfiles'][number];
|
||||
|
||||
interface Props {
|
||||
teamProfiles: TeamProfile[];
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { teamProfiles, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let teamMemberId = $state('');
|
||||
let date = $state('');
|
||||
|
||||
// Non-admin team members only
|
||||
let nonAdminTeamMembers = $derived(teamProfiles.filter((p) => p.role !== 'ADMIN'));
|
||||
|
||||
const createStore = new CreateReportStore();
|
||||
|
||||
// Format date for display (Dec 15, 2024)
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!teamMemberId || !date) {
|
||||
error = 'Please select a team member and date';
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
teamMemberId,
|
||||
date
|
||||
}
|
||||
});
|
||||
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to create report';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="flex h-full flex-col">
|
||||
<div class="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
<!-- Team Member -->
|
||||
<div>
|
||||
<label for="teamMember" class="mb-1.5 block text-sm font-medium text-theme">Team Member</label
|
||||
>
|
||||
<select
|
||||
id="teamMember"
|
||||
bind:value={teamMemberId}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
<option value="">Select team member...</option>
|
||||
{#each nonAdminTeamMembers as profile (profile.id)}
|
||||
<option value={profile.id}>{profile.fullName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label for="date" class="mb-1.5 block text-sm font-medium text-theme">Report Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
bind:value={date}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
{#if date}
|
||||
<p class="mt-1 text-sm text-theme-secondary">{formatDate(date)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="rounded-lg bg-accent2-500/10 p-4">
|
||||
<p class="text-sm text-accent2-600 dark:text-accent2-400">
|
||||
After creating the report, you can add completed services and projects from the report
|
||||
detail page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg bg-error-500/10 p-4">
|
||||
<p class="text-sm text-error-600 dark:text-error-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 border-t border-theme bg-theme p-6">
|
||||
<button type="button" onclick={handleCancel} class="btn-ghost" disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" disabled={submitting || !teamMemberId || !date}>
|
||||
{#if submitting}
|
||||
Creating...
|
||||
{:else}
|
||||
Create Report
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
266
src/lib/components/admin/scopes/AreaSection.svelte
Normal file
@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import TaskTemplateCard from './TaskTemplateCard.svelte';
|
||||
|
||||
interface TaskTemplate {
|
||||
id: string;
|
||||
description: string;
|
||||
checklistDescription: string | null;
|
||||
frequency?: string | null;
|
||||
estimatedMinutes: number | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface AreaOrCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
taskTemplates: TaskTemplate[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
area: AreaOrCategory;
|
||||
variant: 'service' | 'project';
|
||||
isExpanded: boolean;
|
||||
editingAreaName: string | null;
|
||||
editingTaskId: string | null;
|
||||
showNewTaskInput: string | null;
|
||||
newTaskDescription: string;
|
||||
onToggle: () => void;
|
||||
onUpdateName: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onStartEditName: () => void;
|
||||
onCancelEditName: () => void;
|
||||
onShowNewTask: () => void;
|
||||
onHideNewTask: () => void;
|
||||
onNewTaskDescriptionChange: (desc: string) => void;
|
||||
onCreateTask: () => void;
|
||||
onStartEditTask: (taskId: string) => void;
|
||||
onCancelEditTask: () => void;
|
||||
onUpdateTask: (
|
||||
taskId: string,
|
||||
updates: {
|
||||
description: string;
|
||||
checklistDescription: string;
|
||||
frequency?: string;
|
||||
estimatedMinutes: number | null;
|
||||
}
|
||||
) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
onMoveTaskUp: (taskId: string) => void;
|
||||
onMoveTaskDown: (taskId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
area,
|
||||
variant,
|
||||
isExpanded,
|
||||
editingAreaName,
|
||||
editingTaskId,
|
||||
showNewTaskInput,
|
||||
newTaskDescription,
|
||||
onToggle,
|
||||
onUpdateName,
|
||||
onDelete,
|
||||
onStartEditName,
|
||||
onCancelEditName,
|
||||
onShowNewTask,
|
||||
onHideNewTask,
|
||||
onNewTaskDescriptionChange,
|
||||
onCreateTask,
|
||||
onStartEditTask,
|
||||
onCancelEditTask,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onMoveTaskUp,
|
||||
onMoveTaskDown
|
||||
}: Props = $props();
|
||||
|
||||
let isEditingName = $derived(editingAreaName === area.id);
|
||||
let isShowingNewTask = $derived(showNewTaskInput === area.id);
|
||||
let sortedTasks = $derived([...area.taskTemplates].sort((a, b) => a.order - b.order));
|
||||
let areaLabel = $derived(variant === 'service' ? 'area' : 'category');
|
||||
</script>
|
||||
|
||||
<div class="mb-4 rounded-lg border border-theme bg-theme-card">
|
||||
<div class="flex items-center justify-between border-b border-theme px-4 py-3">
|
||||
<button onclick={onToggle} class="flex flex-1 items-center gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{#if isEditingName}
|
||||
<input
|
||||
type="text"
|
||||
value={area.name}
|
||||
class="rounded border border-theme bg-theme px-2 py-1 text-sm font-semibold text-theme focus:border-secondary-500 focus:outline-none"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onUpdateName(e.currentTarget.value);
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancelEditName();
|
||||
}
|
||||
}}
|
||||
onblur={(e) => onUpdateName(e.currentTarget.value)}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="font-semibold text-theme"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondblclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartEditName();
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
onStartEditName();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{area.name}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-theme-muted">
|
||||
({area.taskTemplates.length} tasks)
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={onShowNewTask}
|
||||
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Add task"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if onMoveUp}
|
||||
<button
|
||||
onclick={onMoveUp}
|
||||
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Move {areaLabel} up"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onMoveDown}
|
||||
<button
|
||||
onclick={onMoveDown}
|
||||
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Move {areaLabel} down"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onDelete}
|
||||
class="rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete {areaLabel}"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="p-3" transition:slide={{ duration: 150 }}>
|
||||
<!-- New Task Input -->
|
||||
{#if isShowingNewTask}
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-dashed border-theme bg-theme p-3"
|
||||
transition:slide={{ duration: 150 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newTaskDescription}
|
||||
oninput={(e) => onNewTaskDescriptionChange(e.currentTarget.value)}
|
||||
placeholder="Task description..."
|
||||
class="placeholder-theme-muted w-full rounded border border-theme bg-theme-card px-3 py-2 text-sm text-theme focus:border-secondary-500 focus:outline-none"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onCreateTask();
|
||||
} else if (e.key === 'Escape') {
|
||||
onHideNewTask();
|
||||
onNewTaskDescriptionChange('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
onHideNewTask();
|
||||
onNewTaskDescriptionChange('');
|
||||
}}
|
||||
class="rounded px-2 py-1 text-xs text-theme-muted hover:text-theme"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onCreateTask}
|
||||
class="rounded bg-secondary-500 px-2 py-1 text-xs text-white hover:bg-secondary-600"
|
||||
>
|
||||
Add Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tasks -->
|
||||
{#each sortedTasks as task, index (task.id)}
|
||||
<TaskTemplateCard
|
||||
{task}
|
||||
{variant}
|
||||
isEditing={editingTaskId === task.id}
|
||||
onStartEdit={() => onStartEditTask(task.id)}
|
||||
onCancelEdit={onCancelEditTask}
|
||||
onSave={(updates) => onUpdateTask(task.id, updates)}
|
||||
onDelete={() => onDeleteTask(task.id)}
|
||||
onMoveUp={index === 0 ? undefined : () => onMoveTaskUp(task.id)}
|
||||
onMoveDown={index === sortedTasks.length - 1 ? undefined : () => onMoveTaskDown(task.id)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if area.taskTemplates.length === 0 && !isShowingNewTask}
|
||||
<div class="py-4 text-center text-sm text-theme-muted">
|
||||
No tasks yet. Click + to add one.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
287
src/lib/components/admin/scopes/TaskTemplateCard.svelte
Normal file
@ -0,0 +1,287 @@
|
||||
<script lang="ts">
|
||||
interface TaskTemplate {
|
||||
id: string;
|
||||
description: string;
|
||||
checklistDescription: string | null;
|
||||
frequency?: string | null;
|
||||
estimatedMinutes: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
task: TaskTemplate;
|
||||
variant: 'service' | 'project';
|
||||
isEditing: boolean;
|
||||
onStartEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onSave: (updates: {
|
||||
description: string;
|
||||
checklistDescription: string;
|
||||
frequency?: string;
|
||||
estimatedMinutes: number | null;
|
||||
}) => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
}
|
||||
|
||||
let { task, variant, isEditing, onStartEdit, onCancelEdit, onSave, onDelete, onMoveUp, onMoveDown }: Props = $props();
|
||||
|
||||
function formatFrequency(frequency: string | null | undefined): string {
|
||||
const lower = frequency?.toLowerCase();
|
||||
switch (lower) {
|
||||
case 'daily':
|
||||
return 'Daily';
|
||||
case 'weekly':
|
||||
return 'Weekly';
|
||||
case 'monthly':
|
||||
return 'Monthly';
|
||||
case 'quarterly':
|
||||
return 'Quarterly';
|
||||
case 'triannual':
|
||||
return '3x/Year';
|
||||
case 'annual':
|
||||
return 'Annual';
|
||||
case 'as_needed':
|
||||
return 'As Needed';
|
||||
default:
|
||||
return frequency ?? 'Weekly';
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const updates: Parameters<typeof onSave>[0] = {
|
||||
description: String(formData.get('description') ?? ''),
|
||||
checklistDescription: String(formData.get('checklistDescription') ?? ''),
|
||||
estimatedMinutes: formData.get('estimatedMinutes')
|
||||
? parseInt(String(formData.get('estimatedMinutes')))
|
||||
: null
|
||||
};
|
||||
if (variant === 'service') {
|
||||
updates.frequency = String(formData.get('frequency') ?? 'weekly');
|
||||
}
|
||||
onSave(updates);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-2 rounded-lg border border-theme bg-theme p-3 last:mb-0">
|
||||
{#if isEditing}
|
||||
<!-- Edit Mode -->
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="task-desc-{task.id}" class="mb-1 block text-xs font-medium text-theme-muted"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
id="task-desc-{task.id}"
|
||||
type="text"
|
||||
name="description"
|
||||
value={task.description}
|
||||
class="w-full rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="task-checklist-{task.id}"
|
||||
class="mb-1 block text-xs font-medium text-theme-muted">Checklist Description</label
|
||||
>
|
||||
<input
|
||||
id="task-checklist-{task.id}"
|
||||
type="text"
|
||||
name="checklistDescription"
|
||||
value={task.checklistDescription ?? ''}
|
||||
class="w-full rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
|
||||
placeholder="Optional checklist text..."
|
||||
/>
|
||||
</div>
|
||||
{#if variant === 'service'}
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
for="task-freq-{task.id}"
|
||||
class="mb-1 block text-xs font-medium text-theme-muted">Frequency</label
|
||||
>
|
||||
<select
|
||||
id="task-freq-{task.id}"
|
||||
name="frequency"
|
||||
class="w-full rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
|
||||
>
|
||||
<option value="daily" selected={task.frequency?.toLowerCase() === 'daily'}
|
||||
>Daily</option
|
||||
>
|
||||
<option
|
||||
value="weekly"
|
||||
selected={(task.frequency?.toLowerCase() ?? 'weekly') === 'weekly'}>Weekly</option
|
||||
>
|
||||
<option value="monthly" selected={task.frequency?.toLowerCase() === 'monthly'}
|
||||
>Monthly</option
|
||||
>
|
||||
<option value="quarterly" selected={task.frequency?.toLowerCase() === 'quarterly'}
|
||||
>Quarterly</option
|
||||
>
|
||||
<option value="triannual" selected={task.frequency?.toLowerCase() === 'triannual'}
|
||||
>3x/Year</option
|
||||
>
|
||||
<option value="annual" selected={task.frequency?.toLowerCase() === 'annual'}
|
||||
>Annual</option
|
||||
>
|
||||
<option value="as_needed" selected={task.frequency?.toLowerCase() === 'as_needed'}
|
||||
>As Needed</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label
|
||||
for="task-mins-{task.id}"
|
||||
class="mb-1 block text-xs font-medium text-theme-muted">Est. Min</label
|
||||
>
|
||||
<input
|
||||
id="task-mins-{task.id}"
|
||||
type="number"
|
||||
name="estimatedMinutes"
|
||||
value={task.estimatedMinutes ?? ''}
|
||||
min="0"
|
||||
class="w-full rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<label for="task-mins-{task.id}" class="mb-1 block text-xs font-medium text-theme-muted"
|
||||
>Est. Minutes</label
|
||||
>
|
||||
<input
|
||||
id="task-mins-{task.id}"
|
||||
type="number"
|
||||
name="estimatedMinutes"
|
||||
value={task.estimatedMinutes ?? ''}
|
||||
min="0"
|
||||
class="w-24 rounded border border-theme bg-theme-card px-2 py-1.5 text-sm text-theme focus:border-secondary-500 focus:outline-none"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancelEdit}
|
||||
class="rounded px-3 py-1 text-xs text-theme-muted hover:text-theme"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-secondary-500 px-3 py-1 text-xs text-white hover:bg-secondary-600"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Display Mode -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 cursor-pointer items-start gap-2 text-left"
|
||||
onclick={onStartEdit}
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-theme-muted transition-transform"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="flex-1">
|
||||
<span class="text-sm font-medium text-theme">
|
||||
{task.description}
|
||||
</span>
|
||||
{#if task.checklistDescription}
|
||||
<span class="mt-1 block text-xs text-theme-muted">
|
||||
{task.checklistDescription}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="mt-2 flex flex-wrap gap-2">
|
||||
{#if variant === 'service' && task.frequency}
|
||||
<span
|
||||
class="rounded-full bg-primary-500/20 px-2 py-0.5 text-xs text-primary-600 dark:text-primary-400"
|
||||
>
|
||||
{formatFrequency(task.frequency)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.estimatedMinutes}
|
||||
<span
|
||||
class="rounded-full bg-surface-100 px-2 py-0.5 text-xs text-surface-600 dark:bg-surface-700 dark:text-surface-300"
|
||||
>
|
||||
{task.estimatedMinutes} min
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if onMoveUp}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveUp?.();
|
||||
}}
|
||||
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Move task up"
|
||||
>
|
||||
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onMoveDown}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveDown?.();
|
||||
}}
|
||||
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
|
||||
aria-label="Move task down"
|
||||
>
|
||||
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onDelete}
|
||||
class="rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete task"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
278
src/lib/components/admin/scopes/TemplateEditor.svelte
Normal file
@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import AreaSection from './AreaSection.svelte';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface TaskTemplate {
|
||||
id: string;
|
||||
description: string;
|
||||
checklistDescription: string | null;
|
||||
frequency?: string | null;
|
||||
estimatedMinutes: number | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface AreaOrCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
taskTemplates: TaskTemplate[];
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
areaTemplates?: AreaOrCategory[];
|
||||
categoryTemplates?: AreaOrCategory[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
template: Template;
|
||||
variant: 'service' | 'project';
|
||||
expandedAreas: SvelteSet<string>;
|
||||
editingTemplateName: string | null;
|
||||
editingAreaName: string | null;
|
||||
editingTaskId: string | null;
|
||||
showNewAreaInput: string | null;
|
||||
showNewTaskInput: string | null;
|
||||
newAreaName: string;
|
||||
newTaskDescription: string;
|
||||
onToggleArea: (areaId: string) => void;
|
||||
onStartEditTemplateName: () => void;
|
||||
onCancelEditTemplateName: () => void;
|
||||
onUpdateTemplateName: (name: string) => void;
|
||||
onUpdateTemplateDescription: (description: string) => void;
|
||||
onDeleteTemplate: () => void;
|
||||
onStartEditAreaName: (areaId: string) => void;
|
||||
onCancelEditAreaName: () => void;
|
||||
onUpdateAreaName: (areaId: string, name: string) => void;
|
||||
onDeleteArea: (areaId: string) => void;
|
||||
onShowNewArea: () => void;
|
||||
onHideNewArea: () => void;
|
||||
onNewAreaNameChange: (name: string) => void;
|
||||
onCreateArea: () => void;
|
||||
onShowNewTask: (areaId: string) => void;
|
||||
onHideNewTask: () => void;
|
||||
onNewTaskDescriptionChange: (desc: string) => void;
|
||||
onCreateTask: (areaId: string) => void;
|
||||
onStartEditTask: (taskId: string) => void;
|
||||
onCancelEditTask: () => void;
|
||||
onUpdateTask: (
|
||||
taskId: string,
|
||||
updates: {
|
||||
description: string;
|
||||
checklistDescription: string;
|
||||
frequency?: string;
|
||||
estimatedMinutes: number | null;
|
||||
}
|
||||
) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onMoveAreaUp: (areaId: string) => void;
|
||||
onMoveAreaDown: (areaId: string) => void;
|
||||
onMoveTaskUp: (taskId: string, areaId: string) => void;
|
||||
onMoveTaskDown: (taskId: string, areaId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
template,
|
||||
variant,
|
||||
expandedAreas,
|
||||
editingTemplateName,
|
||||
editingAreaName,
|
||||
editingTaskId,
|
||||
showNewAreaInput,
|
||||
showNewTaskInput,
|
||||
newAreaName,
|
||||
newTaskDescription,
|
||||
onToggleArea,
|
||||
onStartEditTemplateName,
|
||||
onCancelEditTemplateName,
|
||||
onUpdateTemplateName,
|
||||
onUpdateTemplateDescription,
|
||||
onDeleteTemplate,
|
||||
onStartEditAreaName,
|
||||
onCancelEditAreaName,
|
||||
onUpdateAreaName,
|
||||
onDeleteArea,
|
||||
onShowNewArea,
|
||||
onHideNewArea,
|
||||
onNewAreaNameChange,
|
||||
onCreateArea,
|
||||
onShowNewTask,
|
||||
onHideNewTask,
|
||||
onNewTaskDescriptionChange,
|
||||
onCreateTask,
|
||||
onStartEditTask,
|
||||
onCancelEditTask,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
onMoveAreaUp,
|
||||
onMoveAreaDown,
|
||||
onMoveTaskUp,
|
||||
onMoveTaskDown
|
||||
}: Props = $props();
|
||||
|
||||
let areas = $derived(
|
||||
(template.areaTemplates ?? template.categoryTemplates ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
);
|
||||
let isEditingName = $derived(editingTemplateName === template.id);
|
||||
let isShowingNewArea = $derived(showNewAreaInput === template.id);
|
||||
let areaLabel = $derived(variant === 'service' ? 'Area' : 'Category');
|
||||
</script>
|
||||
|
||||
<!-- Template Header -->
|
||||
<div class="border-b border-theme bg-theme-card px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if isEditingName}
|
||||
<input
|
||||
type="text"
|
||||
value={template.name}
|
||||
class="rounded-lg border border-theme bg-theme px-3 py-1 text-lg font-bold text-theme focus:border-secondary-500 focus:outline-none"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onUpdateTemplateName(e.currentTarget.value);
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancelEditTemplateName();
|
||||
}
|
||||
}}
|
||||
onblur={(e) => onUpdateTemplateName(e.currentTarget.value)}
|
||||
/>
|
||||
{:else}
|
||||
<h2
|
||||
class="cursor-pointer text-xl font-bold text-theme"
|
||||
ondblclick={onStartEditTemplateName}
|
||||
>
|
||||
{template.name}
|
||||
</h2>
|
||||
{/if}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {template.isActive
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
|
||||
>
|
||||
{template.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={onDeleteTemplate}
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
aria-label="Delete template"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="mt-3">
|
||||
<textarea
|
||||
placeholder="Add a description..."
|
||||
rows="2"
|
||||
class="placeholder-theme-muted w-full resize-none rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme-secondary focus:border-secondary-500 focus:text-theme focus:outline-none"
|
||||
onblur={(e) => {
|
||||
const newDesc = e.currentTarget.value;
|
||||
if (newDesc !== (template.description ?? '')) {
|
||||
onUpdateTemplateDescription(newDesc);
|
||||
}
|
||||
}}>{template.description ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Areas/Categories -->
|
||||
{#each areas as area, index (area.id)}
|
||||
<AreaSection
|
||||
{area}
|
||||
{variant}
|
||||
isExpanded={expandedAreas.has(area.id)}
|
||||
{editingAreaName}
|
||||
{editingTaskId}
|
||||
{showNewTaskInput}
|
||||
{newTaskDescription}
|
||||
onToggle={() => onToggleArea(area.id)}
|
||||
onUpdateName={(name) => onUpdateAreaName(area.id, name)}
|
||||
onDelete={() => onDeleteArea(area.id)}
|
||||
onStartEditName={() => onStartEditAreaName(area.id)}
|
||||
onCancelEditName={onCancelEditAreaName}
|
||||
onShowNewTask={() => onShowNewTask(area.id)}
|
||||
{onHideNewTask}
|
||||
{onNewTaskDescriptionChange}
|
||||
onCreateTask={() => onCreateTask(area.id)}
|
||||
{onStartEditTask}
|
||||
{onCancelEditTask}
|
||||
{onUpdateTask}
|
||||
{onDeleteTask}
|
||||
onMoveUp={index === 0 ? undefined : () => onMoveAreaUp(area.id)}
|
||||
onMoveDown={index === areas.length - 1 ? undefined : () => onMoveAreaDown(area.id)}
|
||||
onMoveTaskUp={(taskId) => onMoveTaskUp(taskId, area.id)}
|
||||
onMoveTaskDown={(taskId) => onMoveTaskDown(taskId, area.id)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Add Area/Category Button / Input -->
|
||||
{#if isShowingNewArea}
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-theme bg-theme-card p-4"
|
||||
transition:slide={{ duration: 150 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newAreaName}
|
||||
oninput={(e) => onNewAreaNameChange(e.currentTarget.value)}
|
||||
placeholder="{areaLabel} name..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme focus:border-secondary-500 focus:outline-none"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onCreateArea();
|
||||
} else if (e.key === 'Escape') {
|
||||
onHideNewArea();
|
||||
onNewAreaNameChange('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
onHideNewArea();
|
||||
onNewAreaNameChange('');
|
||||
}}
|
||||
class="rounded px-3 py-1 text-sm text-theme-muted hover:text-theme"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onCreateArea}
|
||||
class="rounded bg-secondary-500 px-3 py-1 text-sm text-white hover:bg-secondary-600"
|
||||
>
|
||||
Add {areaLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={onShowNewArea}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-theme p-4 text-sm text-theme-muted transition-colors hover:border-secondary-500 hover:bg-theme-card hover:text-secondary-500"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
Add {areaLabel}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
129
src/lib/components/admin/scopes/TemplateList.svelte
Normal file
@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface AreaOrCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
taskTemplates: { id: string }[];
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
areaTemplates?: AreaOrCategory[];
|
||||
categoryTemplates?: AreaOrCategory[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
templates: Template[];
|
||||
selectedId: string | null;
|
||||
variant: 'service' | 'project';
|
||||
showNewInput: boolean;
|
||||
newName: string;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onShowNewInput: (show: boolean) => void;
|
||||
onNewNameChange: (name: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
templates,
|
||||
selectedId,
|
||||
variant,
|
||||
showNewInput,
|
||||
newName,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onShowNewInput,
|
||||
onNewNameChange
|
||||
}: Props = $props();
|
||||
|
||||
let borderColorClass = $derived(
|
||||
variant === 'service' ? 'border-l-secondary-500' : 'border-l-accent-500'
|
||||
);
|
||||
let bgColorClass = $derived(variant === 'service' ? 'bg-secondary-500/10' : 'bg-accent-500/10');
|
||||
let areaLabel = $derived(variant === 'service' ? 'areas' : 'categories');
|
||||
|
||||
function getAreas(template: Template): AreaOrCategory[] {
|
||||
return template.areaTemplates ?? template.categoryTemplates ?? [];
|
||||
}
|
||||
|
||||
function getTaskCount(template: Template): number {
|
||||
return getAreas(template).reduce((sum, a) => sum + a.taskTemplates.length, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- New Template Input -->
|
||||
{#if showNewInput}
|
||||
<div class="border-b border-theme p-3" transition:slide={{ duration: 150 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
oninput={(e) => onNewNameChange(e.currentTarget.value)}
|
||||
placeholder="Template name..."
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm text-theme focus:border-secondary-500 focus:ring-1 focus:ring-secondary-500 focus:outline-none"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onCreate();
|
||||
} else if (e.key === 'Escape') {
|
||||
onShowNewInput(false);
|
||||
onNewNameChange('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
onShowNewInput(false);
|
||||
onNewNameChange('');
|
||||
}}
|
||||
class="rounded px-3 py-1 text-sm text-theme-muted hover:text-theme"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onCreate}
|
||||
class="rounded bg-secondary-500 px-3 py-1 text-sm text-white hover:bg-secondary-600"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Template List -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
{#each templates as template (template.id)}
|
||||
<button
|
||||
onclick={() => onSelect(template.id)}
|
||||
class="mb-1 w-full rounded-lg border-l-4 p-3 text-left transition-colors {selectedId ===
|
||||
template.id
|
||||
? `${borderColorClass} ${bgColorClass}`
|
||||
: 'border-l-transparent hover:bg-black/5 dark:hover:bg-white/10'}"
|
||||
>
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="font-medium text-theme">{template.name}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {template.isActive
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
|
||||
>
|
||||
{template.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-xs text-theme-muted">
|
||||
{getAreas(template).length}
|
||||
{areaLabel},
|
||||
{getTaskCount(template)} tasks
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if templates.length === 0}
|
||||
<div class="py-8 text-center text-theme-muted">
|
||||
<p>No {variant} templates yet</p>
|
||||
<p class="text-sm">Click + to create one</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
523
src/lib/components/admin/services/GenerateServicesModal.svelte
Normal file
@ -0,0 +1,523 @@
|
||||
<script lang="ts">
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import { GenerateServicesByMonthStore } from '$houdini';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
name: string | null;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
mondayService: boolean;
|
||||
tuesdayService: boolean;
|
||||
wednesdayService: boolean;
|
||||
thursdayService: boolean;
|
||||
fridayService: boolean;
|
||||
saturdayService: boolean;
|
||||
sundayService: boolean;
|
||||
weekendService: boolean;
|
||||
}
|
||||
|
||||
interface Address {
|
||||
id: string;
|
||||
name: string | null;
|
||||
streetAddress: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
isActive: boolean;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
addresses: Address[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
accounts: Account[];
|
||||
preselectedAddressId?: string;
|
||||
preselectedScheduleId?: string;
|
||||
initialMonth?: number;
|
||||
initialYear?: number;
|
||||
onClose: () => void;
|
||||
onSuccess: (totalCount: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
accounts,
|
||||
preselectedAddressId,
|
||||
preselectedScheduleId,
|
||||
initialMonth,
|
||||
initialYear,
|
||||
onClose,
|
||||
onSuccess
|
||||
}: Props = $props();
|
||||
|
||||
// State
|
||||
let now = new Date();
|
||||
// Default to next month if no initial values provided
|
||||
let defaultMonth = now.getMonth() + 2 > 12 ? 1 : now.getMonth() + 2;
|
||||
let defaultYear = now.getMonth() + 2 > 12 ? now.getFullYear() + 1 : now.getFullYear();
|
||||
let selectedMonth = $state(initialMonth ?? defaultMonth);
|
||||
let selectedYear = $state(initialYear ?? defaultYear);
|
||||
|
||||
// Update selected month/year when initial values change
|
||||
$effect(() => {
|
||||
if (initialMonth !== undefined) {
|
||||
selectedMonth = initialMonth;
|
||||
}
|
||||
if (initialYear !== undefined) {
|
||||
selectedYear = initialYear;
|
||||
}
|
||||
});
|
||||
let selectedItems = new SvelteSet<string>(); // "addressId:scheduleId"
|
||||
let generating = $state(false);
|
||||
let results = $state<
|
||||
{ key: string; accountName: string; success: boolean; count?: number; error?: string }[]
|
||||
>([]);
|
||||
let showResults = $state(false);
|
||||
|
||||
const generateStore = new GenerateServicesByMonthStore();
|
||||
|
||||
// Filter accounts to only show those with active schedules
|
||||
// A schedule is active if it has no endDate OR if the endDate is in the future
|
||||
let accountsWithActiveSchedules = $derived(
|
||||
accounts
|
||||
.map((account) => ({
|
||||
...account,
|
||||
addresses: account.addresses
|
||||
.filter((addr) => addr.isActive)
|
||||
.map((addr) => ({
|
||||
...addr,
|
||||
activeSchedules: addr.schedules.filter((s) => {
|
||||
if (!s.endDate) return true;
|
||||
// Compare endDate with today
|
||||
const endDate = new Date(s.endDate + 'T00:00:00');
|
||||
const today = new SvelteDate();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return endDate >= today;
|
||||
})
|
||||
}))
|
||||
.filter((addr) => addr.activeSchedules.length > 0)
|
||||
}))
|
||||
.filter((account) => account.addresses.length > 0)
|
||||
);
|
||||
|
||||
// Initialize preselection
|
||||
$effect(() => {
|
||||
if (open && preselectedAddressId && preselectedScheduleId) {
|
||||
selectedItems.add(`${preselectedAddressId}:${preselectedScheduleId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset state when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
results = [];
|
||||
showResults = false;
|
||||
if (!preselectedAddressId) {
|
||||
selectedItems.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function toggleSelection(addressId: string, scheduleId: string) {
|
||||
const key = `${addressId}:${scheduleId}`;
|
||||
if (selectedItems.has(key)) {
|
||||
selectedItems.delete(key);
|
||||
} else {
|
||||
selectedItems.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
for (const account of accountsWithActiveSchedules) {
|
||||
for (const address of account.addresses) {
|
||||
for (const schedule of address.activeSchedules) {
|
||||
selectedItems.add(`${address.id}:${schedule.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedItems.clear();
|
||||
}
|
||||
|
||||
function getScheduleDays(schedule: Schedule): string[] {
|
||||
const days: string[] = [];
|
||||
if (schedule.mondayService) days.push('Mon');
|
||||
if (schedule.tuesdayService) days.push('Tue');
|
||||
if (schedule.wednesdayService) days.push('Wed');
|
||||
if (schedule.thursdayService) days.push('Thu');
|
||||
if (schedule.fridayService) days.push('Fri');
|
||||
if (schedule.saturdayService) days.push('Sat');
|
||||
if (schedule.sundayService) days.push('Sun');
|
||||
if (schedule.weekendService && !days.includes('Fri')) days.push('Weekend');
|
||||
return days;
|
||||
}
|
||||
|
||||
// Calculate estimated service count for a schedule
|
||||
// Mirrors backend logic: Mon-Thu use day flags, Fri uses weekend_service OR friday flag,
|
||||
// Sat-Sun use day flags only if weekend_service is false
|
||||
function estimateServiceCount(schedule: Schedule): number {
|
||||
const year = selectedYear;
|
||||
const month = selectedMonth;
|
||||
const firstDay = new Date(year, month - 1, 1);
|
||||
const lastDay = new Date(year, month, 0);
|
||||
|
||||
// Map JS getDay() (Sun=0..Sat=6) to day flags
|
||||
// Index: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||
const dayFlags = [
|
||||
schedule.sundayService,
|
||||
schedule.mondayService,
|
||||
schedule.tuesdayService,
|
||||
schedule.wednesdayService,
|
||||
schedule.thursdayService,
|
||||
schedule.fridayService,
|
||||
schedule.saturdayService
|
||||
];
|
||||
|
||||
let count = 0;
|
||||
for (let d = new SvelteDate(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) {
|
||||
const jsDay = d.getDay(); // Sun=0, Mon=1, ..., Sat=6
|
||||
|
||||
let scheduleToday = false;
|
||||
|
||||
if (jsDay >= 1 && jsDay <= 4) {
|
||||
// Mon-Thu: use the day flag
|
||||
scheduleToday = dayFlags[jsDay];
|
||||
} else if (jsDay === 5) {
|
||||
// Friday: weekend_service takes precedence, otherwise use friday flag
|
||||
scheduleToday = schedule.weekendService || dayFlags[5];
|
||||
} else {
|
||||
// Sat (6) or Sun (0): only use day flag if weekend_service is OFF
|
||||
if (!schedule.weekendService) {
|
||||
scheduleToday = dayFlags[jsDay];
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleToday) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
generating = true;
|
||||
results = [];
|
||||
|
||||
for (const key of selectedItems) {
|
||||
const [addressId, scheduleId] = key.split(':');
|
||||
|
||||
// Find account name for display
|
||||
let accountName = 'Unknown';
|
||||
for (const account of accounts) {
|
||||
if (account.addresses.some((a) => a.id === addressId)) {
|
||||
accountName = account.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await generateStore.mutate({
|
||||
input: {
|
||||
accountAddressId: addressId,
|
||||
scheduleId: scheduleId,
|
||||
year: selectedYear,
|
||||
month: selectedMonth
|
||||
}
|
||||
});
|
||||
|
||||
const count = res.data?.generateServicesByMonth?.length ?? 0;
|
||||
results = [...results, { key, accountName, success: true, count }];
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to generate';
|
||||
results = [...results, { key, accountName, success: false, error: errorMessage }];
|
||||
}
|
||||
}
|
||||
|
||||
generating = false;
|
||||
showResults = true;
|
||||
|
||||
// Calculate total and notify
|
||||
const totalCount = results.reduce((sum, r) => sum + (r.count ?? 0), 0);
|
||||
if (totalCount > 0) {
|
||||
onSuccess(totalCount);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (generating) return;
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open && !generating) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget && !generating) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
// Month names
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
];
|
||||
|
||||
// Year options (current year and next 2)
|
||||
let yearOptions = $derived([now.getFullYear(), now.getFullYear() + 1, now.getFullYear() + 2]);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onclick={handleBackdropClick}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[90vh] w-full max-w-2xl flex-col rounded-xl border border-theme bg-theme shadow-theme-lg"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<h2 id="modal-title" class="text-lg font-semibold text-theme">Generate Services</h2>
|
||||
<button
|
||||
onclick={handleClose}
|
||||
disabled={generating}
|
||||
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme disabled:opacity-50 dark:hover:bg-white/10"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
{#if showResults}
|
||||
<!-- Results View -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium text-theme">Generation Results</h3>
|
||||
<div class="space-y-2">
|
||||
{#each results as result (result.key)}
|
||||
<div class="flex items-center justify-between rounded-lg border border-theme p-3">
|
||||
<span class="text-theme">{result.accountName}</span>
|
||||
{#if result.success}
|
||||
<span
|
||||
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
>
|
||||
{result.count} service{result.count !== 1 ? 's' : ''} created
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{result.error}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if results.length > 0}
|
||||
{@const successCount = results.filter((r) => r.success).length}
|
||||
{@const totalServices = results.reduce((sum, r) => sum + (r.count ?? 0), 0)}
|
||||
<div class="rounded-lg bg-theme-card p-4 text-center">
|
||||
<p class="text-lg font-semibold text-theme">
|
||||
{totalServices} service{totalServices !== 1 ? 's' : ''} generated
|
||||
</p>
|
||||
<p class="text-sm text-theme-muted">
|
||||
{successCount} of {results.length} address{results.length !== 1 ? 'es' : ''} succeeded
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Selection View -->
|
||||
<div class="space-y-6">
|
||||
<!-- Month/Year Selection -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<label for="month-select" class="mb-1 block text-sm font-medium text-theme"
|
||||
>Month</label
|
||||
>
|
||||
<select
|
||||
id="month-select"
|
||||
bind:value={selectedMonth}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme"
|
||||
>
|
||||
{#each months as month, i (month)}
|
||||
<option value={i + 1}>{month}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<label for="year-select" class="mb-1 block text-sm font-medium text-theme"
|
||||
>Year</label
|
||||
>
|
||||
<select
|
||||
id="year-select"
|
||||
bind:value={selectedYear}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme"
|
||||
>
|
||||
{#each yearOptions as year (year)}
|
||||
<option value={year}>{year}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selection Controls -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-theme-muted">
|
||||
{selectedItems.size} address{selectedItems.size !== 1 ? 'es' : ''} selected
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={selectAll}
|
||||
class="text-sm text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<span class="text-theme-muted">|</span>
|
||||
<button
|
||||
onclick={selectNone}
|
||||
class="text-sm text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
Select None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address List -->
|
||||
<div class="space-y-3">
|
||||
{#if accountsWithActiveSchedules.length === 0}
|
||||
<div class="rounded-lg border border-theme bg-theme-card p-8 text-center">
|
||||
<p class="text-theme-muted">No accounts with active schedules found</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each accountsWithActiveSchedules as account (account.id)}
|
||||
<div class="rounded-lg border border-theme">
|
||||
<div class="border-b border-theme bg-theme-card px-4 py-2">
|
||||
<h4 class="font-medium text-theme">{account.name}</h4>
|
||||
</div>
|
||||
<div class="divide-theme divide-y">
|
||||
{#each account.addresses as address (address.id)}
|
||||
{#each address.activeSchedules as schedule (schedule.id)}
|
||||
{@const key = `${address.id}:${schedule.id}`}
|
||||
{@const isSelected = selectedItems.has(key)}
|
||||
{@const days = getScheduleDays(schedule)}
|
||||
{@const estimatedCount = estimateServiceCount(schedule)}
|
||||
<label
|
||||
class="flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onchange={() => toggleSelection(address.id, schedule.id)}
|
||||
class="mt-1 h-4 w-4 rounded border-theme text-primary-500"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="text-sm font-medium text-theme">
|
||||
{address.name || address.streetAddress}
|
||||
</span>
|
||||
<span class="text-xs text-theme-muted">
|
||||
{address.streetAddress}, {address.city}, {address.state}
|
||||
</span>
|
||||
<span class="mt-1 flex flex-wrap items-center gap-1">
|
||||
{#each days as day (day)}
|
||||
<span
|
||||
class="rounded bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
{/each}
|
||||
<span class="ml-2 text-xs text-theme-muted">
|
||||
~{estimatedCount} service{estimatedCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
|
||||
{#if showResults}
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleClose}
|
||||
disabled={generating}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleGenerate}
|
||||
disabled={generating || selectedItems.size === 0}
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if generating}
|
||||
<span class="flex items-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
Generating...
|
||||
</span>
|
||||
{:else}
|
||||
Generate Services
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
559
src/lib/components/admin/services/ServiceForm.svelte
Normal file
@ -0,0 +1,559 @@
|
||||
<script lang="ts">
|
||||
import { CreateServiceStore, UpdateServiceStore } from '$houdini';
|
||||
import { offCanvas } from '$lib/stores/offCanvas.svelte';
|
||||
import { fromGlobalId } from '$lib/utils/relay';
|
||||
import type { GetService$result, Customers$result, Accounts$result, Team$result } from '$houdini';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
type Service = NonNullable<GetService$result['service']>;
|
||||
type Customer = Customers$result['customers'][number];
|
||||
type Account = Accounts$result['accounts'][number];
|
||||
type TeamProfile = Team$result['teamProfiles'][number];
|
||||
|
||||
interface Props {
|
||||
service?: Service;
|
||||
customers: Customer[];
|
||||
accounts: Account[];
|
||||
teamProfiles: TeamProfile[];
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
let { service, customers, accounts, teamProfiles, onSuccess, onCancel }: Props = $props();
|
||||
|
||||
let isEdit = $derived(!!service);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Helper to find customer Relay Global ID from UUID
|
||||
function findCustomerGlobalId(accountAddressUuid: string | null | undefined): string {
|
||||
if (!accountAddressUuid) return '';
|
||||
// Find account that contains this address, then get its customer
|
||||
for (const account of accounts) {
|
||||
const hasAddress = account.addresses?.some((a) => fromGlobalId(a.id) === accountAddressUuid);
|
||||
if (hasAddress && account.customerId) {
|
||||
const customer = customers.find((c) => fromGlobalId(c.id) === account.customerId);
|
||||
return customer?.id ?? '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper to find address Relay Global ID from UUID
|
||||
function findAddressGlobalId(addressUuid: string | null | undefined): string {
|
||||
if (!addressUuid) return '';
|
||||
for (const account of accounts) {
|
||||
const address = account.addresses?.find((a) => fromGlobalId(a.id) === addressUuid);
|
||||
if (address) return address.id;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper to find account that contains an address UUID
|
||||
function findAccountForAddressUuid(addressUuid: string | null | undefined): string {
|
||||
if (!addressUuid) return '';
|
||||
for (const account of accounts) {
|
||||
const hasAddress = account.addresses?.some((a) => fromGlobalId(a.id) === addressUuid);
|
||||
if (hasAddress) return account.id;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper to convert team member UUIDs (pk) to Relay Global IDs
|
||||
function findTeamMemberGlobalIds(memberPks: { pk: string }[] | null | undefined): string[] {
|
||||
if (!memberPks) return [];
|
||||
return memberPks
|
||||
.map((m) => {
|
||||
const profile = teamProfiles.find((p) => fromGlobalId(p.id) === m.pk);
|
||||
return profile?.id;
|
||||
})
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
|
||||
// Form fields
|
||||
let customerId = $state(findCustomerGlobalId(service?.accountAddressId));
|
||||
let selectedAccountId = $state(findAccountForAddressUuid(service?.accountAddressId));
|
||||
let accountAddressId = $state(findAddressGlobalId(service?.accountAddressId));
|
||||
let date = $state(service?.date ?? '');
|
||||
let notes = $state(service?.notes ?? '');
|
||||
let selectedTeamMemberIds = $state<string[]>(findTeamMemberGlobalIds(service?.teamMembers));
|
||||
|
||||
// Filter accounts for selected customer
|
||||
let customerAccounts = $derived(() => {
|
||||
if (!customerId) return [];
|
||||
const customerUuid = fromGlobalId(customerId);
|
||||
return accounts.filter((a) => a.customerId === customerUuid);
|
||||
});
|
||||
|
||||
// Get addresses for selected account
|
||||
let accountAddresses = $derived(() => {
|
||||
if (!selectedAccountId) return [];
|
||||
const account = customerAccounts().find((a) => a.id === selectedAccountId);
|
||||
return account?.addresses ?? [];
|
||||
});
|
||||
|
||||
// Get currently selected address details for display
|
||||
let selectedAddressDetails = $derived(() => {
|
||||
if (!accountAddressId) return null;
|
||||
for (const account of customerAccounts()) {
|
||||
const address = account.addresses?.find((a) => a.id === accountAddressId);
|
||||
if (address) {
|
||||
return { account, address };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// When customer changes, reset account and address
|
||||
function handleCustomerChange() {
|
||||
selectedAccountId = '';
|
||||
accountAddressId = '';
|
||||
}
|
||||
|
||||
// When account changes, auto-select if only one address, otherwise reset
|
||||
function handleAccountChange() {
|
||||
const addresses = accountAddresses();
|
||||
if (addresses.length === 1) {
|
||||
accountAddressId = addresses[0].id;
|
||||
} else {
|
||||
accountAddressId = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Non-admin team members only (for display)
|
||||
let nonAdminTeamMembers = $derived(teamProfiles.filter((p) => p.role !== 'ADMIN'));
|
||||
|
||||
// Get admin profile ID to auto-include on create
|
||||
let adminProfileId = $derived(teamProfiles.find((p) => p.role === 'ADMIN')?.id ?? null);
|
||||
|
||||
// Track if service is dispatched (admin profile is in team members)
|
||||
let isDispatched = $derived(
|
||||
adminProfileId ? selectedTeamMemberIds.includes(adminProfileId) : false
|
||||
);
|
||||
|
||||
function toggleDispatched() {
|
||||
if (!adminProfileId) return;
|
||||
if (isDispatched) {
|
||||
// When un-dispatching, remove admin and clear all team members
|
||||
selectedTeamMemberIds = [];
|
||||
} else {
|
||||
selectedTeamMemberIds = [...selectedTeamMemberIds, adminProfileId];
|
||||
}
|
||||
}
|
||||
|
||||
const createStore = new CreateServiceStore();
|
||||
const updateStore = new UpdateServiceStore();
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
if (!date) {
|
||||
error = 'Please provide a date';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEdit && !accountAddressId) {
|
||||
error = 'Please select an account and address';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && service) {
|
||||
// Update existing service - only date, notes, and team members
|
||||
const res = await updateStore.mutate({
|
||||
input: {
|
||||
id: service.id,
|
||||
date,
|
||||
notes: notes.trim() || null,
|
||||
teamMemberIds: selectedTeamMemberIds
|
||||
}
|
||||
});
|
||||
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Create new service
|
||||
// Get account ID from the selected account
|
||||
const selectedAccount = customerAccounts().find((a) => a.id === selectedAccountId);
|
||||
|
||||
// Build team member IDs, always including admin profile
|
||||
const teamMemberIds = [...selectedTeamMemberIds];
|
||||
if (adminProfileId && !teamMemberIds.includes(adminProfileId)) {
|
||||
teamMemberIds.push(adminProfileId);
|
||||
}
|
||||
|
||||
const res = await createStore.mutate({
|
||||
input: {
|
||||
accountId: selectedAccount?.id ?? null,
|
||||
accountAddressId,
|
||||
date,
|
||||
status: 'SCHEDULED',
|
||||
notes: notes.trim() || null,
|
||||
teamMemberIds: teamMemberIds.length > 0 ? teamMemberIds : null
|
||||
}
|
||||
});
|
||||
|
||||
if (res.errors?.length) {
|
||||
error = res.errors.map((e) => e.message).join(', ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
offCanvas.closeRight();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save service';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
offCanvas.closeRight();
|
||||
onCancel?.();
|
||||
}
|
||||
|
||||
function toggleTeamMember(id: string) {
|
||||
if (selectedTeamMemberIds.includes(id)) {
|
||||
selectedTeamMemberIds = selectedTeamMemberIds.filter((m) => m !== id);
|
||||
} else {
|
||||
selectedTeamMemberIds = [...selectedTeamMemberIds, id];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
{#if error}
|
||||
<div class="alert-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<!-- Customer (only for create) -->
|
||||
{#if !isEdit}
|
||||
<div>
|
||||
<label for="customerId" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Customer <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="customerId"
|
||||
bind:value={customerId}
|
||||
onchange={handleCustomerChange}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select a customer</option>
|
||||
{#each customers as customer (customer.id)}
|
||||
<option value={customer.id}>{customer.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account/Address Selection (only for create) -->
|
||||
{#if customerId}
|
||||
<div class="space-y-4 rounded-lg border border-theme bg-theme-card p-4">
|
||||
<p class="text-sm font-medium text-theme">Service Location</p>
|
||||
|
||||
{#if customerAccounts().length > 0}
|
||||
<!-- Account Selection -->
|
||||
<div>
|
||||
<label for="selectedAccountId" class="mb-1.5 block text-sm text-theme-secondary">
|
||||
Account <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="selectedAccountId"
|
||||
bind:value={selectedAccountId}
|
||||
onchange={handleAccountChange}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select an account</option>
|
||||
{#each customerAccounts() as account (account.id)}
|
||||
<option value={account.id}>{account.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Selection (shown when account is selected) -->
|
||||
{#if selectedAccountId && accountAddresses().length > 0}
|
||||
<div>
|
||||
<label for="accountAddressId" class="mb-1.5 block text-sm text-theme-secondary">
|
||||
Address <span class="text-red-500">*</span>
|
||||
</label>
|
||||
{#if accountAddresses().length === 1}
|
||||
<!-- Single address - show as confirmation -->
|
||||
{@const address = accountAddresses()[0]}
|
||||
<div
|
||||
class="rounded-lg border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary-500"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-theme">{address.streetAddress}</p>
|
||||
<p class="text-theme-secondary">
|
||||
{address.city}, {address.state}
|
||||
{address.zipCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Multiple addresses - show dropdown -->
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="accountAddressId"
|
||||
bind:value={accountAddressId}
|
||||
disabled={submitting}
|
||||
class="w-full appearance-none rounded-lg border border-theme bg-theme py-2 pr-10 pl-3 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select an address</option>
|
||||
{#each accountAddresses() as address (address.id)}
|
||||
<option value={address.id}>
|
||||
{address.streetAddress}, {address.city}, {address.state}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Show selected address confirmation (for multi-address accounts) -->
|
||||
{#if accountAddresses().length > 1 && selectedAddressDetails()}
|
||||
{@const details = selectedAddressDetails()}
|
||||
<div
|
||||
class="rounded-lg border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary-500"
|
||||
style="fill: none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-theme">{details?.address.streetAddress}</p>
|
||||
<p class="text-theme-secondary">
|
||||
{details?.address.city}, {details?.address.state}
|
||||
{details?.address.zipCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if selectedAccountId && accountAddresses().length === 0}
|
||||
<p class="text-sm text-warning">
|
||||
This account has no addresses. Please select a different account or add an address
|
||||
to this account first.
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm text-warning">
|
||||
This customer has no accounts. Please create an account first.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label for="date" class="mb-1.5 block text-sm font-medium text-theme">
|
||||
Date <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="date"
|
||||
type="date"
|
||||
bind:value={date}
|
||||
disabled={submitting}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Dispatched Toggle -->
|
||||
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme-card p-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme">Dispatched</p>
|
||||
<p class="text-xs text-theme-muted">Required before assigning team members</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleDispatched}
|
||||
disabled={submitting}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 {isDispatched
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-gray-200 dark:bg-gray-700'}"
|
||||
role="switch"
|
||||
aria-checked={isDispatched}
|
||||
aria-label="Toggle dispatched status"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {isDispatched
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Team Members -->
|
||||
<div>
|
||||
<p class="mb-1.5 text-sm font-medium text-theme">Team Members</p>
|
||||
<div
|
||||
class="space-y-2 rounded-lg border border-theme bg-theme-card p-3 {!isDispatched
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
{#if !isDispatched}
|
||||
<p class="text-sm text-theme-muted italic">Enable "Dispatched" to assign team members</p>
|
||||
{:else if nonAdminTeamMembers.length > 0}
|
||||
{#each nonAdminTeamMembers as member (member.id)}
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-lg interactive p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTeamMemberIds.includes(member.id)}
|
||||
onchange={() => toggleTeamMember(member.id)}
|
||||
disabled={submitting}
|
||||
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-theme">{member.fullName}</span>
|
||||
{#if member.role === 'TEAM_LEADER'}
|
||||
<span
|
||||
class="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
|
||||
>
|
||||
TL
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-sm text-theme-muted">No team members available</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label for="notes" class="mb-1.5 block text-sm font-medium text-theme">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={notes}
|
||||
disabled={submitting}
|
||||
rows="3"
|
||||
placeholder="Optional notes about this service"
|
||||
class="placeholder-theme-muted w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<IconSpinner class="h-4 w-4" />
|
||||
<span>{isEdit ? 'Saving...' : 'Creating...'}</span>
|
||||
</span>
|
||||
{:else}
|
||||
{isEdit ? 'Save Changes' : 'Create Service'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCancel}
|
||||
disabled={submitting}
|
||||
class="rounded-lg border border-theme bg-theme px-4 py-2 font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
328
src/lib/components/admin/services/assign/AssignColumn.svelte
Normal file
@ -0,0 +1,328 @@
|
||||
<script lang="ts">
|
||||
import AssignColumnHeader from './AssignColumnHeader.svelte';
|
||||
import AssignServiceGroup from './AssignServiceGroup.svelte';
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type EnrichedService = {
|
||||
id: string;
|
||||
date: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
teamMembers: { pk: string }[];
|
||||
accountAddressId: string;
|
||||
accountName: string;
|
||||
addressName: string | null;
|
||||
address: string | null;
|
||||
};
|
||||
|
||||
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
|
||||
|
||||
interface Props {
|
||||
column: ColumnType;
|
||||
services: EnrichedService[];
|
||||
groupedServices: Map<string, EnrichedService[]>;
|
||||
expandedGroups: SvelteSet<string>;
|
||||
updatingServices: Set<string>;
|
||||
openTeamMemberDropdown: string | null;
|
||||
editingDateServiceId?: string | null;
|
||||
// Selection props
|
||||
selectedServices: SvelteSet<string>;
|
||||
isBulkOperating?: boolean;
|
||||
// For Ready to Assign bulk operations
|
||||
nonAdminTeamMembers?: { id: string; fullName: string }[];
|
||||
bulkSelectedTeamMember?: string | null;
|
||||
showBulkTeamMemberDropdown?: boolean;
|
||||
// Callbacks
|
||||
onToggleGroup: (groupKey: string) => void;
|
||||
getTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
|
||||
getNonDispatchTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
|
||||
getAvailableTeamMembers: (service: EnrichedService) => { pk: string; name: string }[];
|
||||
getStagedTeamMemberDetails: (serviceId: string) => { pk: string; name: string }[];
|
||||
hasStagedMembers: (serviceId: string) => boolean;
|
||||
onAddDispatch: (service: EnrichedService) => void;
|
||||
onRemoveDispatch: (service: EnrichedService) => void;
|
||||
onSubmitStaged: (service: EnrichedService) => void;
|
||||
onRemoveNonDispatch: (service: EnrichedService) => void;
|
||||
onStageTeamMember: (serviceId: string, memberPk: string) => void;
|
||||
onUnstageTeamMember: (serviceId: string, memberPk: string) => void;
|
||||
onToggleDropdown: (serviceId: string | null) => void;
|
||||
// Selection callbacks
|
||||
onToggleSelection: (serviceId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onBulkAction: () => void;
|
||||
// For Ready to Assign
|
||||
onBulkTeamMemberSelect?: (memberPk: string) => void;
|
||||
onToggleBulkTeamMemberDropdown?: () => void;
|
||||
// Unassigned column specific
|
||||
onUpdateDate?: (service: EnrichedService, newDate: string) => void;
|
||||
onDeleteService?: (service: EnrichedService) => void;
|
||||
onStartEditDate?: (serviceId: string) => void;
|
||||
onCancelEditDate?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
column,
|
||||
services,
|
||||
groupedServices,
|
||||
expandedGroups,
|
||||
updatingServices,
|
||||
openTeamMemberDropdown,
|
||||
editingDateServiceId = null,
|
||||
selectedServices,
|
||||
isBulkOperating = false,
|
||||
nonAdminTeamMembers = [],
|
||||
bulkSelectedTeamMember = null,
|
||||
showBulkTeamMemberDropdown = false,
|
||||
onToggleGroup,
|
||||
getTeamMemberNames,
|
||||
getNonDispatchTeamMemberNames,
|
||||
getAvailableTeamMembers,
|
||||
getStagedTeamMemberDetails,
|
||||
hasStagedMembers,
|
||||
onAddDispatch,
|
||||
onRemoveDispatch,
|
||||
onSubmitStaged,
|
||||
onRemoveNonDispatch,
|
||||
onStageTeamMember,
|
||||
onUnstageTeamMember,
|
||||
onToggleDropdown,
|
||||
onToggleSelection,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onBulkAction,
|
||||
onBulkTeamMemberSelect,
|
||||
onToggleBulkTeamMemberDropdown,
|
||||
onUpdateDate,
|
||||
onDeleteService,
|
||||
onStartEditDate,
|
||||
onCancelEditDate
|
||||
}: Props = $props();
|
||||
|
||||
let hasSelection = $derived(selectedServices.size > 0);
|
||||
let allSelected = $derived(services.length > 0 && selectedServices.size === services.length);
|
||||
|
||||
function getEmptyIcon(): string {
|
||||
switch (column) {
|
||||
case 'unassigned':
|
||||
case 'assigned':
|
||||
return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
case 'readyToAssign':
|
||||
return 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z';
|
||||
}
|
||||
}
|
||||
|
||||
function getEmptyMessage(): string {
|
||||
switch (column) {
|
||||
case 'unassigned':
|
||||
return 'No unassigned services';
|
||||
case 'readyToAssign':
|
||||
return 'No services ready to assign';
|
||||
case 'assigned':
|
||||
return 'No assigned services';
|
||||
}
|
||||
}
|
||||
|
||||
function getBulkActionLabel(): string {
|
||||
switch (column) {
|
||||
case 'unassigned':
|
||||
return 'Add Dispatch';
|
||||
case 'readyToAssign':
|
||||
return 'Assign';
|
||||
case 'assigned':
|
||||
return 'Remove Team';
|
||||
}
|
||||
}
|
||||
|
||||
function getBulkActionDisabled(): boolean {
|
||||
if (isBulkOperating) return true;
|
||||
if (selectedServices.size === 0) return true;
|
||||
return column === 'readyToAssign' && !bulkSelectedTeamMember;
|
||||
}
|
||||
|
||||
function getSelectedTeamMemberName(): string {
|
||||
if (!bulkSelectedTeamMember) return 'Select team member...';
|
||||
const member = nonAdminTeamMembers.find((m) => {
|
||||
// Extract pk from global id
|
||||
const parts = atob(m.id).split(':');
|
||||
return parts[1] === bulkSelectedTeamMember;
|
||||
});
|
||||
return member?.fullName ?? 'Unknown';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-1/3 flex-col overflow-hidden rounded-xl bg-theme-card shadow-md">
|
||||
<AssignColumnHeader {column} count={services.length} />
|
||||
|
||||
<!-- Bulk Action Bar -->
|
||||
{#if services.length > 0}
|
||||
<div class="border-b border-theme bg-theme-card px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- Select All / Clear -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => (allSelected ? onClearSelection() : onSelectAll())}
|
||||
class="flex items-center gap-1.5 rounded interactive px-2 py-1 text-xs font-medium {hasSelection
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-theme-muted'}"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if allSelected}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else if hasSelection}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else}
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
{/if}
|
||||
</svg>
|
||||
{#if hasSelection}
|
||||
<span>{selectedServices.size} selected</span>
|
||||
{:else}
|
||||
<span>Select all</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if hasSelection}
|
||||
<button onclick={onClearSelection} class="text-xs text-theme-muted hover:text-theme">
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Bulk Action -->
|
||||
{#if hasSelection}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if column === 'readyToAssign'}
|
||||
<!-- Team member dropdown for bulk assignment -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleBulkTeamMemberDropdown?.();
|
||||
}}
|
||||
class="flex items-center gap-1 rounded border border-theme interactive bg-theme px-2 py-1 text-xs font-medium text-theme"
|
||||
>
|
||||
<span class="max-w-[100px] truncate">{getSelectedTeamMemberName()}</span>
|
||||
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showBulkTeamMemberDropdown}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="absolute top-full right-0 z-30 mt-1 max-h-48 w-48 overflow-y-auto rounded-lg border border-theme bg-theme-card py-1 shadow-lg"
|
||||
>
|
||||
{#each nonAdminTeamMembers as member (member.id)}
|
||||
{@const memberPk = atob(member.id).split(':')[1]}
|
||||
<button
|
||||
onclick={() => onBulkTeamMemberSelect?.(memberPk)}
|
||||
class="block w-full px-3 py-2 text-left text-sm text-theme hover:bg-black/5 dark:hover:bg-white/10 {bulkSelectedTeamMember ===
|
||||
memberPk
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: ''}"
|
||||
>
|
||||
{member.fullName}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onBulkAction}
|
||||
disabled={getBulkActionDisabled()}
|
||||
class="flex items-center gap-1 rounded bg-primary-600 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if isBulkOperating}
|
||||
<IconSpinner class="h-3 w-3" />
|
||||
{:else}
|
||||
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{getBulkActionLabel()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 space-y-2 overflow-y-auto bg-theme p-4">
|
||||
{#each [...groupedServices.entries()] as [groupKey, groupServices] (groupKey)}
|
||||
<AssignServiceGroup
|
||||
{groupKey}
|
||||
services={groupServices}
|
||||
{column}
|
||||
isExpanded={expandedGroups.has(groupKey)}
|
||||
{updatingServices}
|
||||
{openTeamMemberDropdown}
|
||||
{editingDateServiceId}
|
||||
{selectedServices}
|
||||
onToggle={() => onToggleGroup(groupKey)}
|
||||
{getTeamMemberNames}
|
||||
{getNonDispatchTeamMemberNames}
|
||||
{getAvailableTeamMembers}
|
||||
{getStagedTeamMemberDetails}
|
||||
{hasStagedMembers}
|
||||
{onAddDispatch}
|
||||
{onRemoveDispatch}
|
||||
{onSubmitStaged}
|
||||
{onRemoveNonDispatch}
|
||||
{onStageTeamMember}
|
||||
{onUnstageTeamMember}
|
||||
{onToggleDropdown}
|
||||
{onToggleSelection}
|
||||
{onUpdateDate}
|
||||
{onDeleteService}
|
||||
{onStartEditDate}
|
||||
{onCancelEditDate}
|
||||
/>
|
||||
{/each}
|
||||
{#if services.length === 0}
|
||||
<div class="py-12 text-center text-theme-muted">
|
||||
<svg
|
||||
class="mx-auto mb-3 h-12 w-12"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d={getEmptyIcon()}
|
||||
/>
|
||||
</svg>
|
||||
{getEmptyMessage()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
|
||||
|
||||
interface Props {
|
||||
column: ColumnType;
|
||||
count: number;
|
||||
}
|
||||
|
||||
let { column, count }: Props = $props();
|
||||
|
||||
function getConfig() {
|
||||
switch (column) {
|
||||
case 'unassigned':
|
||||
return {
|
||||
title: 'Unassigned',
|
||||
description: 'Click the arrow to mark as ready for assignment.',
|
||||
bgClass: 'bg-red-50 dark:bg-red-900/20',
|
||||
dotClass: 'bg-red-500',
|
||||
badgeClass: 'bg-red-500'
|
||||
};
|
||||
case 'readyToAssign':
|
||||
return {
|
||||
title: 'Ready to Assign',
|
||||
description: 'Add team members, then click the arrow to assign.',
|
||||
bgClass: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
dotClass: 'bg-yellow-500',
|
||||
badgeClass: 'bg-yellow-500'
|
||||
};
|
||||
case 'assigned':
|
||||
return {
|
||||
title: 'Assigned',
|
||||
description: 'These services are good to go.',
|
||||
bgClass: 'bg-green-50 dark:bg-green-900/20',
|
||||
dotClass: 'bg-green-500',
|
||||
badgeClass: 'bg-green-500'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let config = $derived(getConfig());
|
||||
</script>
|
||||
|
||||
<div class="border-b border-theme {config.bgClass} px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full {config.dotClass}"></div>
|
||||
<h2 class="font-semibold text-theme">{config.title}</h2>
|
||||
</div>
|
||||
<span class="rounded-full {config.badgeClass} px-2.5 py-0.5 text-sm font-semibold text-white">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-theme-muted">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
@ -0,0 +1,380 @@
|
||||
<script lang="ts">
|
||||
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
||||
|
||||
interface ServiceDisplay {
|
||||
date: string;
|
||||
accountName: string;
|
||||
addressName: string | null;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
|
||||
|
||||
interface Props {
|
||||
service: ServiceDisplay;
|
||||
column: ColumnType;
|
||||
isUpdating: boolean;
|
||||
// For unassigned column
|
||||
teamMemberNames?: string[];
|
||||
isEditingDate?: boolean;
|
||||
// For readyToAssign column
|
||||
availableMembers?: { pk: string; name: string }[];
|
||||
stagedMembers?: { pk: string; name: string }[];
|
||||
hasStaged?: boolean;
|
||||
isDropdownOpen?: boolean;
|
||||
// For assigned column
|
||||
nonDispatchTeamMemberNames?: string[];
|
||||
// Selection
|
||||
isSelected?: boolean;
|
||||
showCheckbox?: boolean;
|
||||
// Callbacks
|
||||
onAddDispatch?: () => void;
|
||||
onRemoveDispatch?: () => void;
|
||||
onSubmitStaged?: () => void;
|
||||
onRemoveNonDispatch?: () => void;
|
||||
onStageTeamMember?: (memberPk: string) => void;
|
||||
onUnstageTeamMember?: (memberPk: string) => void;
|
||||
onToggleDropdown?: () => void;
|
||||
onToggleSelection?: () => void;
|
||||
// Unassigned column specific callbacks
|
||||
onUpdateDate?: (newDate: string) => void;
|
||||
onDelete?: () => void;
|
||||
onStartEditDate?: () => void;
|
||||
onCancelEditDate?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
service,
|
||||
column,
|
||||
isUpdating,
|
||||
teamMemberNames = [],
|
||||
isEditingDate = false,
|
||||
availableMembers = [],
|
||||
stagedMembers = [],
|
||||
hasStaged = false,
|
||||
isDropdownOpen = false,
|
||||
nonDispatchTeamMemberNames = [],
|
||||
isSelected = false,
|
||||
showCheckbox = false,
|
||||
onAddDispatch,
|
||||
onRemoveDispatch,
|
||||
onSubmitStaged,
|
||||
onRemoveNonDispatch,
|
||||
onStageTeamMember,
|
||||
onUnstageTeamMember,
|
||||
onToggleDropdown,
|
||||
onToggleSelection,
|
||||
onUpdateDate,
|
||||
onDelete,
|
||||
onStartEditDate,
|
||||
onCancelEditDate
|
||||
}: Props = $props();
|
||||
|
||||
// Local state for date editing - initialized fresh when editing starts
|
||||
let editDateValue = $state('');
|
||||
|
||||
// Reset edit value when editing starts
|
||||
$effect(() => {
|
||||
if (isEditingDate) {
|
||||
editDateValue = service.date;
|
||||
}
|
||||
});
|
||||
|
||||
function formatServiceDate(dateString: string): string {
|
||||
const date = new Date(dateString + 'T00:00:00');
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function handleDateSubmit() {
|
||||
if (editDateValue && editDateValue !== service.date) {
|
||||
onUpdateDate?.(editDateValue);
|
||||
} else {
|
||||
onCancelEditDate?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
handleDateSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancelEditDate?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCheckboxClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
onToggleSelection?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative rounded-lg border bg-theme p-3 transition-all hover:shadow-md {isSelected
|
||||
? 'border-primary-500 ring-1 ring-primary-500'
|
||||
: 'border-theme hover:border-theme'}"
|
||||
>
|
||||
{#if isUpdating}
|
||||
<div class="bg-theme/80 absolute inset-0 z-10 flex items-center justify-center rounded-lg">
|
||||
<IconSpinner class="h-6 w-6 text-secondary-500" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Checkbox for bulk selection -->
|
||||
{#if showCheckbox}
|
||||
<button
|
||||
onclick={handleCheckboxClick}
|
||||
class="absolute top-2 right-2 z-10 flex h-5 w-5 items-center justify-center rounded border transition-colors {isSelected
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-gray-300 bg-white hover:border-primary-400 dark:border-gray-600 dark:bg-gray-800 dark:hover:border-primary-500'}"
|
||||
aria-label={isSelected ? 'Deselect service' : 'Select service'}
|
||||
>
|
||||
{#if isSelected}
|
||||
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Date display/edit for unassigned column -->
|
||||
{#if column === 'unassigned' && isEditingDate}
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
bind:value={editDateValue}
|
||||
onkeydown={handleDateKeydown}
|
||||
class="w-full rounded border border-theme bg-theme px-2 py-1 text-sm text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onclick={handleDateSubmit}
|
||||
class="rounded bg-green-500 p-1 text-white hover:bg-green-600"
|
||||
aria-label="Save date"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
editDateValue = service.date;
|
||||
onCancelEditDate?.();
|
||||
}}
|
||||
class="rounded bg-gray-500 p-1 text-white hover:bg-gray-600"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else if column === 'unassigned'}
|
||||
<button
|
||||
onclick={onStartEditDate}
|
||||
class="mb-1 flex items-center gap-1 text-sm font-bold text-theme hover:text-primary-600 dark:hover:text-primary-400"
|
||||
title="Click to edit date"
|
||||
>
|
||||
{formatServiceDate(service.date)}
|
||||
<svg
|
||||
class="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="mb-1 text-sm font-bold text-theme">{formatServiceDate(service.date)}</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="mb-1 text-sm font-semibold text-secondary-600 dark:text-secondary-400 {showCheckbox
|
||||
? 'pr-6'
|
||||
: ''}"
|
||||
>
|
||||
{service.accountName}
|
||||
</div>
|
||||
<div class="mb-2 text-xs text-theme-muted">{service.addressName ?? service.address}</div>
|
||||
|
||||
{#if column === 'unassigned'}
|
||||
<!-- Unassigned column footer -->
|
||||
<div class="flex items-center justify-between border-t border-theme pt-2">
|
||||
<button
|
||||
onclick={onDelete}
|
||||
disabled={isUpdating}
|
||||
class="rounded-full bg-red-100 p-1.5 text-red-600 transition-colors hover:bg-red-200 disabled:opacity-50 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||
aria-label="Delete service"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex-1 px-2 text-xs text-theme-muted">
|
||||
{#if teamMemberNames.length === 0}
|
||||
<span class="italic">No team assigned</span>
|
||||
{:else}
|
||||
{teamMemberNames.join(', ')}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={onAddDispatch}
|
||||
disabled={isUpdating}
|
||||
class="rounded-full bg-green-100 p-1.5 text-green-600 transition-colors hover:bg-green-200 disabled:opacity-50 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
|
||||
aria-label="Add dispatch"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else if column === 'readyToAssign'}
|
||||
<!-- Ready to Assign: Staged team members + Add button -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-1.5">
|
||||
{#each stagedMembers as member (member.pk)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
{member.name}
|
||||
<button
|
||||
onclick={() => onUnstageTeamMember?.(member.pk)}
|
||||
class="ml-0.5 rounded-full p-0.5 hover:bg-blue-200 dark:hover:bg-blue-800"
|
||||
aria-label="Remove {member.name}"
|
||||
>
|
||||
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<!-- Add team member button -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleDropdown?.();
|
||||
}}
|
||||
class="inline-flex items-center gap-0.5 rounded-full border border-dashed border-blue-300 px-2 py-0.5 text-xs font-medium text-blue-500 transition-colors hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 dark:border-blue-600 dark:text-blue-400 dark:hover:border-blue-500 dark:hover:bg-blue-900/20"
|
||||
aria-label="Add team member"
|
||||
>
|
||||
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
{#if isDropdownOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="absolute top-full left-0 z-20 mt-1 w-48 rounded-lg border border-theme bg-theme-card py-1 shadow-lg"
|
||||
>
|
||||
{#if availableMembers.length === 0}
|
||||
<div class="px-3 py-2 text-sm text-theme-muted italic">No available members</div>
|
||||
{:else}
|
||||
{#each availableMembers as member (member.pk)}
|
||||
<button
|
||||
onclick={() => onStageTeamMember?.(member.pk)}
|
||||
class="block w-full interactive px-3 py-2 text-left text-sm text-theme"
|
||||
>
|
||||
{member.name}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-theme pt-2">
|
||||
<button
|
||||
onclick={onRemoveDispatch}
|
||||
disabled={isUpdating}
|
||||
class="rounded-full bg-red-100 p-1.5 text-red-600 transition-colors hover:bg-red-200 disabled:opacity-50 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||
aria-label="Remove dispatch"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={onSubmitStaged}
|
||||
disabled={!hasStaged || isUpdating}
|
||||
class="rounded-full p-1.5 transition-colors disabled:opacity-30 {hasStaged
|
||||
? 'bg-green-100 text-green-600 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||
: 'bg-theme-secondary text-theme-muted'}"
|
||||
aria-label="Assign team members"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Assigned column footer -->
|
||||
<div class="flex items-center justify-between border-t border-theme pt-2">
|
||||
<button
|
||||
onclick={onRemoveNonDispatch}
|
||||
disabled={isUpdating}
|
||||
class="rounded-full bg-red-100 p-1.5 text-red-600 transition-colors hover:bg-red-200 disabled:opacity-50 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||
aria-label="Remove team members"
|
||||
>
|
||||
<svg class="h-4 w-4" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="text-xs text-theme-muted">
|
||||
<span class="font-medium">Team:</span>
|
||||
{nonDispatchTeamMemberNames.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import AssignServiceCard from './AssignServiceCard.svelte';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type EnrichedService = {
|
||||
id: string;
|
||||
date: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
teamMembers: { pk: string }[];
|
||||
accountAddressId: string;
|
||||
accountName: string;
|
||||
addressName: string | null;
|
||||
address: string | null;
|
||||
};
|
||||
|
||||
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
|
||||
|
||||
interface Props {
|
||||
groupKey: string;
|
||||
services: EnrichedService[];
|
||||
column: ColumnType;
|
||||
isExpanded: boolean;
|
||||
updatingServices: Set<string>;
|
||||
openTeamMemberDropdown: string | null;
|
||||
editingDateServiceId?: string | null;
|
||||
selectedServices?: SvelteSet<string>;
|
||||
// Callbacks
|
||||
onToggle: () => void;
|
||||
getTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
|
||||
getNonDispatchTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
|
||||
getAvailableTeamMembers: (service: EnrichedService) => { pk: string; name: string }[];
|
||||
getStagedTeamMemberDetails: (serviceId: string) => { pk: string; name: string }[];
|
||||
hasStagedMembers: (serviceId: string) => boolean;
|
||||
onAddDispatch: (service: EnrichedService) => void;
|
||||
onRemoveDispatch: (service: EnrichedService) => void;
|
||||
onSubmitStaged: (service: EnrichedService) => void;
|
||||
onRemoveNonDispatch: (service: EnrichedService) => void;
|
||||
onStageTeamMember: (serviceId: string, memberPk: string) => void;
|
||||
onUnstageTeamMember: (serviceId: string, memberPk: string) => void;
|
||||
onToggleDropdown: (serviceId: string | null) => void;
|
||||
onToggleSelection?: (serviceId: string) => void;
|
||||
// Unassigned column specific
|
||||
onUpdateDate?: (service: EnrichedService, newDate: string) => void;
|
||||
onDeleteService?: (service: EnrichedService) => void;
|
||||
onStartEditDate?: (serviceId: string) => void;
|
||||
onCancelEditDate?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
groupKey,
|
||||
services,
|
||||
column,
|
||||
isExpanded,
|
||||
updatingServices,
|
||||
openTeamMemberDropdown,
|
||||
editingDateServiceId = null,
|
||||
selectedServices,
|
||||
onToggle,
|
||||
getTeamMemberNames,
|
||||
getNonDispatchTeamMemberNames,
|
||||
getAvailableTeamMembers,
|
||||
getStagedTeamMemberDetails,
|
||||
hasStagedMembers,
|
||||
onAddDispatch,
|
||||
onRemoveDispatch,
|
||||
onSubmitStaged,
|
||||
onRemoveNonDispatch,
|
||||
onStageTeamMember,
|
||||
onUnstageTeamMember,
|
||||
onToggleDropdown,
|
||||
onToggleSelection,
|
||||
onUpdateDate,
|
||||
onDeleteService,
|
||||
onStartEditDate,
|
||||
onCancelEditDate
|
||||
}: Props = $props();
|
||||
|
||||
function getBadgeClass(): string {
|
||||
switch (column) {
|
||||
case 'unassigned':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400';
|
||||
case 'readyToAssign':
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400';
|
||||
case 'assigned':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400';
|
||||
}
|
||||
}
|
||||
|
||||
// Count selected services in this group
|
||||
let selectedCount = $derived(
|
||||
selectedServices ? services.filter((s) => selectedServices.has(s.id)).length : 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-theme bg-theme-card">
|
||||
<button
|
||||
onclick={onToggle}
|
||||
class="flex w-full items-center justify-between interactive px-3 py-2 text-left"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-muted transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||||
style="fill: none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-theme">{groupKey}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
{#if selectedCount > 0}
|
||||
<span
|
||||
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
|
||||
>
|
||||
{selectedCount}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-semibold {getBadgeClass()}">
|
||||
{services.length}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if isExpanded}
|
||||
<div class="space-y-2 border-t border-theme p-2" transition:slide={{ duration: 150 }}>
|
||||
{#each services as service (service.id)}
|
||||
{@const availableMembers =
|
||||
column === 'readyToAssign' ? getAvailableTeamMembers(service) : []}
|
||||
{@const stagedMembers =
|
||||
column === 'readyToAssign' ? getStagedTeamMemberDetails(service.id) : []}
|
||||
{@const hasStaged = column === 'readyToAssign' ? hasStagedMembers(service.id) : false}
|
||||
{@const isSelected = selectedServices?.has(service.id) ?? false}
|
||||
<AssignServiceCard
|
||||
{service}
|
||||
{column}
|
||||
isUpdating={updatingServices.has(service.id)}
|
||||
teamMemberNames={column === 'unassigned' ? getTeamMemberNames(service.teamMembers) : []}
|
||||
isEditingDate={column === 'unassigned' && editingDateServiceId === service.id}
|
||||
{availableMembers}
|
||||
{stagedMembers}
|
||||
{hasStaged}
|
||||
isDropdownOpen={openTeamMemberDropdown === service.id}
|
||||
nonDispatchTeamMemberNames={column === 'assigned'
|
||||
? getNonDispatchTeamMemberNames(service.teamMembers)
|
||||
: []}
|
||||
{isSelected}
|
||||
showCheckbox={!!selectedServices}
|
||||
onAddDispatch={() => onAddDispatch(service)}
|
||||
onRemoveDispatch={() => onRemoveDispatch(service)}
|
||||
onSubmitStaged={() => onSubmitStaged(service)}
|
||||
onRemoveNonDispatch={() => onRemoveNonDispatch(service)}
|
||||
onStageTeamMember={(pk) => onStageTeamMember(service.id, pk)}
|
||||
onUnstageTeamMember={(pk) => onUnstageTeamMember(service.id, pk)}
|
||||
onToggleDropdown={() =>
|
||||
onToggleDropdown(openTeamMemberDropdown === service.id ? null : service.id)}
|
||||
onToggleSelection={() => onToggleSelection?.(service.id)}
|
||||
onUpdateDate={(newDate) => onUpdateDate?.(service, newDate)}
|
||||
onDelete={() => onDeleteService?.(service)}
|
||||
onStartEditDate={() => onStartEditDate?.(service.id)}
|
||||
{onCancelEditDate}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
113
src/lib/components/chat/ChatPanel.svelte
Normal file
@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { chat } from '$lib/stores/chat.svelte';
|
||||
import MessageList from './MessageList.svelte';
|
||||
import MessageInput from './MessageInput.svelte';
|
||||
import ConversationList from './ConversationList.svelte';
|
||||
|
||||
let showConversations = $state(false);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && chat.isOpen) {
|
||||
if (showConversations) {
|
||||
showConversations = false;
|
||||
} else {
|
||||
chat.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConversations() {
|
||||
showConversations = !showConversations;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if chat.isOpen}
|
||||
<!-- Backdrop for mobile -->
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-40 bg-black/30 sm:hidden"
|
||||
onclick={() => chat.close()}
|
||||
aria-label="Close chat"
|
||||
></button>
|
||||
|
||||
<!-- Chat panel -->
|
||||
<div
|
||||
class="fixed right-0 bottom-0 z-50 flex h-[calc(100dvh-3.5rem)] w-full flex-col bg-white shadow-xl sm:right-4 sm:bottom-20 sm:h-[600px] sm:max-h-[calc(100dvh-8rem)] sm:w-96 sm:rounded-2xl dark:bg-gray-800"
|
||||
transition:fly={{ y: 20, duration: 200 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Chat"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleConversations}
|
||||
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
aria-label="Show conversations"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{chat.activeConversation?.title || 'New Chat'}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if chat.connectionStatus === 'connected'}
|
||||
AI Assistant
|
||||
{:else if chat.connectionStatus === 'connecting'}
|
||||
Connecting...
|
||||
{:else}
|
||||
Disconnected
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => chat.close()}
|
||||
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="relative flex min-h-0 flex-1 flex-col">
|
||||
<!-- Messages -->
|
||||
<MessageList />
|
||||
|
||||
<!-- Input -->
|
||||
<MessageInput />
|
||||
|
||||
<!-- Conversation list overlay -->
|
||||
{#if showConversations}
|
||||
<div
|
||||
class="absolute inset-0 bg-white dark:bg-gray-800"
|
||||
transition:fly={{ x: -100, duration: 200 }}
|
||||
>
|
||||
<ConversationList onClose={() => (showConversations = false)} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
41
src/lib/components/chat/ChatToggle.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { chat } from '$lib/stores/chat.svelte';
|
||||
|
||||
const statusColors = {
|
||||
disconnected: 'bg-gray-400',
|
||||
connecting: 'bg-yellow-400 animate-pulse',
|
||||
connected: 'bg-green-400',
|
||||
error: 'bg-red-400'
|
||||
};
|
||||
|
||||
let statusColor = $derived(statusColors[chat.connectionStatus]);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => chat.toggle()}
|
||||
class="fixed right-4 bottom-24 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-primary-600 text-white shadow-lg transition-transform hover:scale-105 hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:outline-none md:bottom-6"
|
||||
aria-label={chat.isOpen ? 'Close chat' : 'Open chat'}
|
||||
>
|
||||
{#if chat.isOpen}
|
||||
<!-- X icon -->
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Chat bubble icon -->
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<!-- Connection status indicator -->
|
||||
<span
|
||||
class="absolute top-0 right-0 h-3 w-3 rounded-full border-2 border-white {statusColor}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
117
src/lib/components/chat/ConversationList.svelte
Normal file
@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { chat } from '$lib/stores/chat.svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
function selectConversation(id: string) {
|
||||
chat.selectConversation(id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
chat.startNewConversation();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diffDays < 7) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Conversations</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-1 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- New conversation button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={startNew}
|
||||
class="flex w-full items-center gap-3 border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">New Conversation</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Start fresh</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Conversation list -->
|
||||
{#each chat.conversations as conversation (conversation.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectConversation(conversation.id)}
|
||||
class="flex w-full items-center gap-3 border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-700/50 {conversation.id ===
|
||||
chat.activeConversationId
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: ''}"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
{conversation.title || 'New Conversation'}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDate(conversation.updated_at || conversation.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if chat.conversations.length === 0}
|
||||
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No conversations yet
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
39
src/lib/components/chat/Message.svelte
Normal file
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { ChatMessage } from '$lib/stores/chat.svelte';
|
||||
import ToolCallCard from './ToolCallCard.svelte';
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
let { message }: Props = $props();
|
||||
|
||||
let isUser = $derived(message.role === 'user');
|
||||
</script>
|
||||
|
||||
<div class="flex {isUser ? 'justify-end' : 'justify-start'}">
|
||||
<div
|
||||
class="max-w-[85%] rounded-2xl px-4 py-2 {isUser
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'}"
|
||||
>
|
||||
<!-- Tool calls (for assistant messages) -->
|
||||
{#if message.tool_calls && message.tool_calls.length > 0}
|
||||
<div class="mb-2 space-y-2">
|
||||
{#each message.tool_calls as tool, i}
|
||||
<ToolCallCard
|
||||
{tool}
|
||||
result={message.tool_results?.find((r) => r.tool_use_id === tool.id)?.result}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Message content -->
|
||||
{#if message.content}
|
||||
<div class="prose prose-sm max-w-none {isUser ? 'prose-invert' : 'dark:prose-invert'}">
|
||||
{@html message.content.replace(/\n/g, '<br>')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||