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>
|
||||||