public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 11:28:04 -05:00
commit 1324f9259f
163 changed files with 25843 additions and 0 deletions

13
.dockerignore Normal file
View File

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

19
.env.example Normal file
View File

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

26
.gitignore vendored Normal file
View File

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

9
.graphqlrc.yaml Normal file
View File

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

1
.npmrc Normal file
View File

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

12
.prettierignore Normal file
View File

@ -0,0 +1,12 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
# Generated files
schema.graphql

16
.prettierrc Normal file
View File

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

439
CLAUDE.md Normal file
View File

@ -0,0 +1,439 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **SvelteKit 5** frontend application for Nexus, built with:
- **SvelteKit** as the meta-framework
- **Houdini** for GraphQL client operations
- **Tailwind CSS v4** for styling
- **TypeScript** with strict mode enabled
- **Node adapter** for production deployment
### Relationship to nexus-5-frontend-1
This project (nexus-5-frontend-2) is a **streamlined team-focused application** that abstracts functionality from nexus-5-frontend-1 (the admin interface).
**Key Differences**:
- **nexus-5-frontend-1**: Admin-focused dashboard with comprehensive controls
- **nexus-5-frontend-2**: Team-focused app with simplified workflows for field/service operations
**Using nexus-5-frontend-1 as Reference**:
- ✅ **DO** reference nexus-5-frontend-1 for:
- Understanding business logic and data flow
- GraphQL query structures and data models
- UI component patterns and layouts
- Understanding feature requirements and scope
- ❌ **DO NOT** replicate from nexus-5-frontend-1:
- Dashboard patterns (this app has no dashboard)
- Admin-specific features
- Complex data loading patterns (this app uses simpler SvelteKit patterns)
- Overly complex UI components
**When to Reference**:
1. When understanding what data a feature needs (check the GraphQL queries)
2. When understanding business logic (how tasks, sessions, scopes work)
3. When you need UI inspiration for similar features
4. When clarifying requirements for features that exist in both apps
**Important**: Always ask the user if you're unclear whether a pattern from nexus-5-frontend-1 should be replicated or simplified for this team-focused app.
## Development Commands
```bash
# Start development server (runs on https://local.example.com)
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Type checking
npm run check
# Type checking in watch mode
npm run check:watch
# Format code
npm run format
# Lint code (runs prettier check + eslint)
npm run lint
```
## Docker Deployment
The application is containerized with Docker for production deployment.
### Docker Files
- **`Dockerfile`**: Multi-stage build (Node 22 Alpine)
- Stage 1: Install dependencies and build the SvelteKit app
- Stage 2: Production image with only built output and production dependencies
- **`docker-compose.yml`**: Simple deployment configuration
- **`.dockerignore`**: Excludes node_modules, .houdini, build artifacts, etc.
### Running with Docker
```bash
# Build and run with docker-compose
docker-compose up --build
# Run in detached mode
docker-compose up -d --build
# Stop the container
docker-compose down
```
### Configuration
The container exposes port 3000 internally, mapped to port 7000 on the host by default. Environment variables:
- `NODE_ENV=production`
- `PORT=3000`
- `HOST=0.0.0.0`
To change the external port, modify `docker-compose.yml`:
```yaml
ports:
- 'YOUR_PORT:3000'
```
## Architecture
### GraphQL Integration (Houdini)
This project uses **Houdini** for type-safe GraphQL operations:
- **Client Configuration**: `src/client.ts` - Houdini client connects to GraphQL endpoint at `http://192.168.100.174:5500/graphql/`
- **Schema**: `schema.graphql` at project root
- **Generated Code**: `.houdini/` directory (gitignored, auto-generated)
- **GraphQL Documents**: Write queries/mutations in `.gql` files or inline in `.svelte` components
- **Config**: `houdini.config.js` - Includes authentication headers for schema watching:
- `X-USER-ID`
- `X-USER-PROFILE-TYPE`
- `X-OATHKEEPER-SECRET`
- `X-DJANGO-PROFILE-ID`
Authentication headers are read from environment variables in `.env` file.
### Path Aliases
- `$houdini``.houdini/` - Access Houdini generated types and runtime
- Standard SvelteKit aliases (`$lib`, `$app`, etc.) are available
### Development Server
The dev server is configured to run on `local.example.com` with HTTPS enabled via `vite-plugin-mkcert`.
### TypeScript Configuration
- **Strict mode** enabled
- **Root dirs** include `.houdini/types` for Houdini type integration
- Module resolution set to `bundler`
### Vite Plugins
Configured in `vite.config.ts`:
1. `houdini` - GraphQL client integration
2. `tailwindcss` - Tailwind CSS v4
3. `sveltekit` - SvelteKit integration
4. `devtoolsJson` - Development tools
5. `mkcert` - Local HTTPS certificates
### Environment Variables
The `.env` file contains:
- API endpoints and keys for Calendar and Email services
- Authentication credentials for GraphQL schema introspection
- All Houdini auth headers (USER_ID, USER_PROFILE_TYPE, OATHKEEPER_SECRET, DJANGO_PROFILE_ID)
**Important**: These credentials are for development only.
### ESLint Configuration
Uses the new flat config format (`eslint.config.js`) with:
- TypeScript ESLint recommended rules
- Svelte plugin with recommended rules
- Prettier integration
- `no-undef` disabled for TypeScript files (as recommended by typescript-eslint)
## Working with GraphQL
### Schema Updates
When the GraphQL schema changes:
1. Update `schema.graphql` manually, or
2. Houdini's `watchSchema` will auto-update during development
3. Run `npm run prepare` or restart dev server to regenerate types
### Writing Queries
Queries can be written:
- In separate `.gql` files
- Inline in `.svelte` components using Houdini's Svelte integration
GraphQL documents are automatically discovered in:
- `**/*.gql` files
- `**/*.svelte` components
- `.houdini/graphql/documents.gql`
## Project Structure
```
src/
├── app.d.ts # SvelteKit app types
├── app.css # Global styles (Tailwind imports)
├── app.html # HTML template
├── client.ts # Houdini client setup
├── lib/
│ ├── assets/ # Static assets (favicon, etc.)
│ ├── components/ # Reusable Svelte components
│ │ ├── entity/ # Entity-related components (StatusBadge, TeamMemberList, etc.)
│ │ ├── layout/ # Layout components (Container, Navigation, etc.)
│ │ └── ui/ # Generic UI components
│ ├── graphql/ # GraphQL documents
│ │ ├── queries/ # Query .gql files organized by entity
│ │ └── mutations/ # Mutation .gql files
│ └── utils/ # Utility functions (date formatting, relay helpers, etc.)
└── routes/ # SvelteKit file-based routing
├── +layout.svelte # Root layout with navigation
├── +page.svelte # Home page
├── services/ # Services list and detail pages
├── projects/ # Projects list and detail pages
├── reports/ # Reports list and detail pages
├── messages/ # Messaging pages
└── sessions/ # Session pages (services/projects)
```
### Key Directories
- **`src/lib/components/entity/`**: Shared components for displaying entity data (services, projects). Includes `StatusBadge`, `TeamMemberList`, `AddressCard`, `IdSidebar`, scope cards, etc.
- **`src/lib/graphql/`**: GraphQL documents organized by entity type. Queries and mutations are in separate `.gql` files.
- **`src/lib/utils/`**: Helper functions like `formatDate()`, `fromGlobalId()` (Relay ID conversion), etc.
## Important Notes
- This project uses **Svelte 5** - use modern Svelte 5 syntax (runes, snippets, etc.)
- **Tailwind CSS v4** is configured - use the latest v4 features and syntax
- The project uses **adapter-node** for production builds
- HTTPS is enabled in development - the server runs on `local.example.com`
## Coding Standards
### Svelte 5 Specific Rules
#### SVG `fill` Attribute
**IMPORTANT**: Never use the `fill` attribute directly on SVG elements in Svelte 5. Always use the `style` attribute instead.
**Incorrect**:
```svelte
<svg fill="none" viewBox="0 0 24 24">
<svg fill="currentColor" viewBox="0 0 24 24">
```
**Correct**:
```svelte
<svg style="fill: none" viewBox="0 0 24 24">
<svg style="fill: currentColor" viewBox="0 0 24 24">
```
**Reason**: The `fill` attribute is considered obsolete in Svelte 5 and will trigger warnings. Use inline styles instead.
#### HTML Semantics - Button Content
**IMPORTANT**: Buttons can only contain phrasing content (inline elements). Never place block-level elements like `<div>` inside `<button>` elements.
**Incorrect**:
```svelte
<button>
<div class="flex items-center gap-3">
<svg>...</svg>
<span>Text</span>
</div>
</button>
```
**Correct**:
```svelte
<button>
<span class="flex items-center gap-3">
<svg>...</svg>
<span>Text</span>
</span>
</button>
```
**Valid phrasing content elements** (safe to use in buttons):
- `<span>`, `<strong>`, `<em>`, `<b>`, `<i>`, `<small>`, `<mark>`
- `<svg>`, `<img>`, `<abbr>`, `<code>`, `<kbd>`, `<sup>`, `<sub>`
**Invalid block elements** (never use in buttons):
- `<div>`, `<p>`, `<h1-h6>`, `<ul>`, `<ol>`, `<section>`, `<article>`
**Reason**: HTML5 semantic rules require button elements to only contain phrasing content. Block elements will trigger validation warnings and may cause accessibility issues.
### TypeScript Standards
#### Use Houdini Generated Types
**IMPORTANT**: Never use `any` or `unknown` when Houdini provides generated types.
**Incorrect**:
```typescript
let scopeData = $state<{ scope?: any } | null>(null);
tasks.filter((t: any) => ...)
```
**Correct**:
```typescript
import type { GetScope$result } from '$houdini';
let scopeData = $state<GetScope$result | null>(null);
tasks.filter((t) => ...)
```
**Available Houdini types**:
- Import result types: `GetQueryName$result`
- Import input types: `GetQueryName$input`
- Types are automatically generated in `.houdini/artifacts/`
**Reason**: Houdini generates complete TypeScript types from GraphQL schema. Using these types provides full type safety and autocomplete.
## UI Patterns
### Responsive Design
The app targets mobile devices (360px minimum width) as well as desktop. Common patterns:
**Mobile Cards / Desktop Table Pattern:**
For data-heavy pages like reports, use separate layouts:
```svelte
<!-- Mobile cards: visible below md breakpoint -->
<div class="space-y-4 md:hidden">
{#each items as item}
<a href="/item/{item.id}" class="block rounded-lg border p-4">
<!-- Card content -->
</a>
{/each}
</div>
<!-- Desktop table: hidden below md breakpoint -->
<div class="hidden md:block">
<table class="w-full">
<!-- Table content -->
</table>
</div>
```
**Clickable Table Rows:**
Make entire rows clickable by wrapping each cell's content in anchor tags:
```svelte
<tr class="group cursor-pointer transition-colors hover:bg-[rgb(var(--bg-tertiary))]">
<td class="px-4 py-3">
<a href="/item/{id}" class="block text-primary-500 group-hover:underline">
{name}
</a>
</td>
<td class="px-4 py-3">
<a href="/item/{id}" class="block">
{otherValue}
</a>
</td>
</tr>
```
## Data Patterns
### Pagination Pattern (Services & Projects)
The services (`/services`) and projects (`/projects`) pages use a hybrid server/client pagination pattern:
**How it works:**
1. **Server Load** (`+page.ts`):
- Fetches first 20 items per tab (scheduled, in_progress, completed)
- Applies month/year filter from URL params (`?month=X&year=Y`)
- Passes `teamProfileId`, `dateFilter`, and `pageSize` to client for subsequent fetches
2. **Client State** (`+page.svelte`):
- Uses `$state` to accumulate items (initialized from server data via `$effect`)
- Tracks cursor and `hasMore` state per tab
- "Load More" button triggers client-side fetch using Houdini store classes
3. **Tab-specific behavior:**
- **Scheduled**: `SCHEDULED` + `CANCELLED` statuses, ascending date order
- **In Progress**: `IN_PROGRESS` status only, no pagination (typically few items)
- **Completed**: `COMPLETED` status only, descending date order
**Client-side load more pattern:**
```typescript
import { GetServicesByTeamMemberStore } from '$houdini';
import { ServiceChoices, DateOrdering } from '$houdini';
async function loadMoreScheduled() {
if (!scheduledCursor || loadingMoreScheduled) return;
loadingMoreScheduled = true;
try {
const store = new GetServicesByTeamMemberStore();
const result = await store.fetch({
variables: {
teamProfileId: data.teamProfileId,
first: data.pageSize,
after: scheduledCursor,
filters: {
date: data.dateFilter,
status: { inList: [ServiceChoices.SCHEDULED, ServiceChoices.CANCELLED] }
},
ordering: DateOrdering.ASC
}
});
if (result.data?.getServicesByTeamMember) {
const connection = result.data.getServicesByTeamMember;
scheduledItems = [...scheduledItems, ...connection.edges.map((e) => e.node)];
scheduledCursor = connection.pageInfo.endCursor;
scheduledHasMore = connection.pageInfo.hasNextPage;
}
} finally {
loadingMoreScheduled = false;
}
}
```
**Key points:**
- Month/year filter determines the data set; pagination loads more within that set
- Tab counts show "20+" when more pages exist
- No URL navigation on "Load More" - results append in place

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "build"]

170
README.md Normal file
View File

@ -0,0 +1,170 @@
# Nexus 5 Frontend 2 - Team App
Streamlined team-focused mobile application for the Nexus 5 platform, designed for field operations and simplified workflows.
## Overview
This is the second iteration of the Nexus 5 frontend, built as a lightweight team-focused application that abstracts functionality from the admin dashboard (frontend-1). It's optimized for mobile devices and field workers who need quick access to their assigned work.
## Tech Stack
- **SvelteKit 5** - Latest SvelteKit with Svelte 5 runes
- **Houdini** - Type-safe GraphQL client
- **Tailwind CSS v4** - Next-generation Tailwind
- **TypeScript** - Strict mode enabled
- **Node adapter** - Production deployment
## Evolution
| Feature | nexus-5-frontend-1 | nexus-5-frontend-2 |
|---------|---------------------|---------------------|
| **Focus** | Admin dashboard | Team mobile app |
| **Complexity** | Comprehensive | Streamlined |
| **Target Users** | Admins, Team Leaders | Field technicians |
| **Svelte Version** | Svelte 4 | Svelte 5 (runes) |
| **Tailwind** | v3 | v4 |
| **UI Library** | Flowbite | Custom components |
## Features
- **Service List** - View and manage assigned services
- **Project List** - Track assigned projects
- **Work Sessions** - Open, manage, and close work sessions
- **Task Completion** - Mark tasks complete during sessions
- **Photo/Video Upload** - Document work with media
- **Session Notes** - Add notes during work sessions
- **Reports** - View and access reports
- **Messages** - Team communication
- **Notifications** - Real-time notifications
- **Profile** - Personal profile management
## Getting Started
### Prerequisites
- Node.js 18+
- Access to Nexus 5 GraphQL API
### Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Type checking
npm run check
```
### Docker Deployment
```bash
# Build and run with docker-compose
docker-compose up --build
# Run in detached mode
docker-compose up -d --build
```
### Environment Variables
See `.env.example` for required configuration.
## Project Structure
```
src/
├── lib/
│ ├── components/
│ │ ├── entity/ # Entity display components
│ │ ├── layout/ # Layout components
│ │ └── session/ # Session management
│ ├── graphql/
│ │ ├── mutations/ # GraphQL mutations
│ │ └── queries/ # GraphQL queries
│ ├── stores/ # Svelte stores
│ └── utils/ # Utility functions
└── routes/
├── accounts/ # Account viewing
├── messages/ # Team messages
├── notifications/ # Notifications
├── profile/ # User profile
├── projects/ # Project list
├── reports/ # Reports
├── services/ # Service list
└── sessions/ # Work sessions
```
## Key Patterns
### Svelte 5 Runes
Uses modern Svelte 5 syntax with runes:
```svelte
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log('Count changed:', count);
});
</script>
```
### Mobile-First Design
Optimized for mobile devices with responsive layouts:
```svelte
<!-- Mobile cards -->
<div class="space-y-4 md:hidden">
{#each items as item}
<Card {item} />
{/each}
</div>
<!-- Desktop table -->
<div class="hidden md:block">
<Table {items} />
</div>
```
### Tab-Based Pagination
Services and projects use tab-based organization with load-more pagination:
- **Scheduled** - Upcoming work
- **In Progress** - Active work
- **Completed** - Finished work
## Comparison to Frontend-1
### What's Different
- **No dashboard** - Direct access to work items
- **Simplified navigation** - Fewer menu options
- **Mobile-optimized** - Touch-friendly UI
- **Reduced features** - Only what field workers need
### Shared Patterns
- GraphQL queries and data models
- Session management workflows
- Entity relationships
## Related Repositories
- **nexus-5** - Django GraphQL API backend
- **nexus-5-auth** - Ory Kratos/Oathkeeper authentication
- **nexus-5-frontend-1** - Admin dashboard
- **nexus-5-frontend-3** - Full-featured portal
## License
MIT License - See LICENSE file for details.

9
docker-compose.yml Normal file
View File

@ -0,0 +1,9 @@
services:
app:
build: .
ports:
- '7000:3000'
environment:
- NODE_ENV=production
- PORT=3000
- HOST=0.0.0.0

42
eslint.config.js Normal file
View File

@ -0,0 +1,42 @@
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),
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',
'svelte/no-navigation-without-resolve': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

25
houdini.config.js Normal file
View File

@ -0,0 +1,25 @@
/// <references types="houdini-svelte">
/** @type {import('houdini').ConfigFile} */
const config = {
watchSchema: {
url: 'http://192.168.100.174:5500/graphql/',
headers: {
'X-USER-ID': (env) => env.USER_ID,
'X-USER-PROFILE-TYPE': (env) => env.USER_PROFILE_TYPE,
'X-OATHKEEPER-SECRET': (env) => env.OATHKEEPER_SECRET,
'X-DJANGO-PROFILE-ID': (env) => env.DJANGO_PROFILE_ID
}
},
schemaPath: './schema.graphql',
runtimeDir: '.houdini',
defaultCachePolicy: 'NetworkOnly',
plugins: {
'houdini-svelte': {
client: './src/lib/graphql/client.ts',
forceRunesMode: true
}
}
};
export default config;

6333
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "nexus-5-frontend-2",
"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 ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.38.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^24",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.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.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"vite": "^7.1.10",
"vite-plugin-devtools-json": "^1.0.0",
"vite-plugin-mkcert": "^1.17.9"
},
"dependencies": {
"date-fns": "^4.1.0"
}
}

4331
schema.graphql Normal file

File diff suppressed because it is too large Load Diff

70
src/app.css Normal file
View File

@ -0,0 +1,70 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@theme {
/* Blue (Primary) and Green (Accent) color scheme with Orange and Purple complements */
--color-primary-50: #e0f2fe;
--color-primary-100: #bae6fd;
--color-primary-200: #7dd3fc;
--color-primary-300: #38bdf8;
--color-primary-400: #0ea5e9;
--color-primary-500: #0284c7;
--color-primary-600: #0369a1;
--color-primary-700: #075985;
--color-primary-800: #0c4a6e;
--color-primary-900: #082f49;
--color-accent-50: #d1fae5;
--color-accent-100: #a7f3d0;
--color-accent-200: #6ee7b7;
--color-accent-300: #34d399;
--color-accent-400: #10b981;
--color-accent-500: #059669;
--color-accent-600: #047857;
--color-accent-700: #065f46;
--color-accent-800: #064e3b;
--color-accent-900: #022c22;
--color-secondary-50: #fff7ed;
--color-secondary-100: #ffedd5;
--color-secondary-200: #fed7aa;
--color-secondary-300: #fdba74;
--color-secondary-400: #fb923c;
--color-secondary-500: #f97316;
--color-secondary-600: #ea580c;
--color-secondary-700: #c2410c;
--color-secondary-800: #9a3412;
--color-secondary-900: #7c2d12;
--color-tertiary-50: #faf5ff;
--color-tertiary-100: #f3e8ff;
--color-tertiary-200: #e9d5ff;
--color-tertiary-300: #d8b4fe;
--color-tertiary-400: #c084fc;
--color-tertiary-500: #a855f7;
--color-tertiary-600: #9333ea;
--color-tertiary-700: #7e22ce;
--color-tertiary-800: #6b21a8;
--color-tertiary-900: #581c87;
}
:root {
--bg-primary: 255 255 255;
--bg-secondary: 249 250 251;
--text-primary: 17 24 39;
--text-secondary: 107 114 128;
--border: 229 231 235;
}
:root.dark {
--bg-primary: 17 24 39;
--bg-secondary: 31 41 55;
--text-primary: 249 250 251;
--text-secondary: 156 163 175;
--border: 55 65 81;
}
body {
@apply bg-[rgb(var(--bg-primary))] text-[rgb(var(--text-primary))] transition-colors duration-200;
}

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

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

12
src/app.html Normal file
View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

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

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

View File

@ -0,0 +1,41 @@
<script lang="ts">
import type { AddressInfo } from './types';
interface Props {
title: string;
address: AddressInfo;
}
let { title, address }: Props = $props();
// Check if we have any address data to display
let hasAddressData = $derived(
address.name || address.streetAddress || address.city || address.state || address.zipCode
);
</script>
{#if hasAddressData}
<div class="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--bg-primary))] p-6">
<h2 class="mb-4 text-xl font-semibold text-primary-500">{title}</h2>
<div class="space-y-2">
{#if address.name}
<p class="font-semibold text-[rgb(var(--text-primary))]">{address.name}</p>
{/if}
{#if address.streetAddress}
<p class="text-[rgb(var(--text-secondary))]">{address.streetAddress}</p>
{/if}
{#if address.city || address.state || address.zipCode}
<p class="text-[rgb(var(--text-secondary))]">
{#if address.city}{address.city}{/if}{#if address.state}, {address.state}{/if}{#if address.zipCode}
{address.zipCode}{/if}
</p>
{/if}
{#if address.notes}
<div class="mt-3 border-t border-[rgb(var(--border))] pt-3">
<p class="text-sm text-[rgb(var(--text-secondary))] italic">{address.notes}</p>
</div>
{/if}
</div>
</div>
{/if}

View File

@ -0,0 +1,42 @@
<script lang="ts">
import type { ContactInfo } from './types';
interface Props {
contact: ContactInfo;
showDivider?: boolean;
}
let { contact, showDivider = false }: Props = $props();
</script>
<div class="pb-4 {showDivider ? 'border-b border-[rgb(var(--border))]' : ''}">
<div class="mb-2 flex items-start justify-between">
<h3 class="font-semibold text-[rgb(var(--text-primary))]">
{contact.fullName}
</h3>
{#if contact.isPrimary}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs text-primary-500">Primary</span>
{/if}
</div>
<div class="space-y-1 text-sm">
{#if contact.email}
<p>
<a href="mailto:{contact.email}" class="text-primary-500 hover:text-primary-600">
{contact.email}
</a>
</p>
{/if}
{#if contact.phone}
<p>
<a href="tel:{contact.phone}" class="text-primary-500 hover:text-primary-600">
{contact.phone}
</a>
</p>
{/if}
</div>
{#if contact.notes}
<p class="mt-2 text-sm text-[rgb(var(--text-secondary))] italic">
{contact.notes}
</p>
{/if}
</div>

View File

@ -0,0 +1,150 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import type { EntityType, EntityTab } from './types';
interface Props {
entityType: EntityType;
date: string | null | undefined;
status: string | null | undefined;
primaryText: string;
secondaryText?: string;
activeTab: EntityTab;
detailUrl: string;
sessionAction?: {
label: string;
onClick: () => void;
};
}
let {
entityType,
date,
status,
primaryText,
secondaryText,
activeTab,
detailUrl,
sessionAction
}: Props = $props();
function getStatusColor(status: string | null | undefined): string {
switch (status?.toLowerCase()) {
case 'completed':
return 'bg-accent-500/20 text-accent-500';
case 'in_progress':
case 'active':
return 'bg-secondary-500/20 text-secondary-500';
case 'scheduled':
case 'pending':
return 'bg-primary-500/20 text-primary-500';
case 'cancelled':
return 'bg-red-500/20 text-red-500';
default:
return 'bg-[rgb(var(--text-secondary))]/20 text-[rgb(var(--text-secondary))]';
}
}
function getHoverBorderColor(tab: EntityTab): string {
switch (tab) {
case 'scheduled':
return 'hover:border-primary-500';
case 'in_progress':
return 'hover:border-yellow-500';
case 'completed':
return 'hover:border-green-500';
}
}
function getPrimaryTextColor(tab: EntityTab): string {
switch (tab) {
case 'scheduled':
return 'text-primary-500';
case 'in_progress':
return 'text-secondary-500';
case 'completed':
return 'text-accent-500';
}
}
function getViewButtonColor(tab: EntityTab): string {
switch (tab) {
case 'scheduled':
return 'bg-primary-500 hover:bg-primary-600';
case 'in_progress':
return 'bg-secondary-500 hover:bg-secondary-600';
case 'completed':
return 'bg-accent-500 hover:bg-accent-600';
}
}
function getSessionButtonColor(tab: EntityTab): string {
switch (tab) {
case 'scheduled':
case 'in_progress':
return 'bg-accent-500 hover:bg-accent-600';
case 'completed':
return 'bg-gray-500 hover:bg-gray-600';
}
}
let viewLabel = $derived(entityType === 'service' ? 'View Service' : 'View Project');
</script>
<div
class="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--bg-primary))] p-6 transition-colors {getHoverBorderColor(
activeTab
)}"
>
<!-- Badges -->
<div class="mb-3 flex items-center gap-2">
{#if date}
<span
class="rounded-full border border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))] px-3 py-1 text-xs font-medium text-[rgb(var(--text-primary))]"
>
{formatDate(date)}
</span>
{/if}
{#if status}
<span class="px-3 py-1 {getStatusColor(status)} rounded-full text-xs font-medium">
{status}
</span>
{/if}
</div>
<!-- Primary & Secondary Text -->
<div class="mb-2">
<p class="text-xl font-semibold {getPrimaryTextColor(activeTab)}">
{primaryText}
</p>
{#if secondaryText}
<p class="text-sm text-[rgb(var(--text-secondary))]">
{secondaryText}
</p>
{/if}
</div>
<!-- Buttons -->
<div class="mt-4 flex gap-2">
<a
href={detailUrl}
class="inline-flex items-center gap-2 rounded-lg {getViewButtonColor(
activeTab
)} px-4 py-2 text-sm font-medium text-white transition-colors"
>
<span>{viewLabel}</span>
<svg class="h-4 w-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
{#if sessionAction}
<button
onclick={sessionAction.onClick}
class="inline-flex items-center gap-2 rounded-lg {getSessionButtonColor(
activeTab
)} px-4 py-2 text-sm font-medium text-white transition-colors"
>
<span>{sessionAction.label}</span>
</button>
{/if}
</div>
</div>

View File

@ -0,0 +1,103 @@
<script lang="ts">
import type { EntityTab, EntityTabCounts } from './types';
interface Props {
activeTab: EntityTab;
counts: EntityTabCounts;
onTabChange: (tab: EntityTab) => void;
}
let { activeTab, counts, onTabChange }: Props = $props();
const tabs: EntityTab[] = ['scheduled', 'in_progress', 'completed'];
function getTabColor(tab: EntityTab, isActive: boolean): string {
if (!isActive) return 'text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]';
switch (tab) {
case 'scheduled':
return 'text-primary-500';
case 'in_progress':
return 'text-secondary-500';
case 'completed':
return 'text-accent-500';
}
}
function getTabBorderColor(tab: EntityTab): string {
switch (tab) {
case 'scheduled':
return 'border-blue-500';
case 'in_progress':
return 'border-yellow-500';
case 'completed':
return 'border-green-500';
}
}
function getTabLabel(tab: EntityTab): string {
switch (tab) {
case 'scheduled':
return `Scheduled (${counts.scheduled})`;
case 'in_progress':
return `In Progress (${counts.inProgress})`;
case 'completed':
return `Completed (${counts.completed})`;
}
}
function prevTab() {
const currentIndex = tabs.indexOf(activeTab);
const prevIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
onTabChange(tabs[prevIndex]);
}
function nextTab() {
const currentIndex = tabs.indexOf(activeTab);
const nextIndex = currentIndex === tabs.length - 1 ? 0 : currentIndex + 1;
onTabChange(tabs[nextIndex]);
}
</script>
<!-- Mobile: Carousel style -->
<div class="mb-6 border-b border-[rgb(var(--border))] md:hidden">
<div class="flex items-center justify-between px-4 py-2">
<button
onclick={prevTab}
class="text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]"
aria-label="Previous tab"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex-1 text-center font-semibold {getTabColor(activeTab, true)}">
{getTabLabel(activeTab)}
</div>
<button
onclick={nextTab}
class="text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]"
aria-label="Next tab"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
<!-- Tablet/Desktop: Regular tabs -->
<div class="mb-6 hidden border-b border-[rgb(var(--border))] md:block">
<div class="flex gap-2">
{#each tabs as tab (tab)}
<button
onclick={() => onTabChange(tab)}
class="border-b-2 px-4 py-2 font-semibold whitespace-nowrap transition-colors {activeTab ===
tab
? `${getTabBorderColor(tab)} ${getTabColor(tab, true)}`
: `border-transparent ${getTabColor(tab, false)}`}"
>
{getTabLabel(tab)}
</button>
{/each}
</div>
</div>

View File

@ -0,0 +1,264 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import type { AccountAddressInfo } from './types';
interface Props {
address: AccountAddressInfo;
expanded: boolean;
onToggle: () => void;
showPrimaryBadge?: boolean;
// Scope expansion state (managed by parent)
expandedScopes?: Record<string, boolean>;
expandedAreas?: Record<string, boolean>;
onToggleScope?: (addressId: string) => void;
onToggleArea?: (areaId: string) => void;
}
let {
address,
expanded,
onToggle,
showPrimaryBadge = true,
expandedScopes = {},
expandedAreas = {},
onToggleScope,
onToggleArea
}: Props = $props();
</script>
<div
class="rounded-lg border-2 border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))]/30 p-4 transition-colors hover:border-primary-500/30"
>
<button
onclick={onToggle}
class="flex w-full items-start justify-between gap-3 transition-opacity hover:opacity-80"
>
<span class="flex-1 text-left">
<span class="mb-1 flex items-center gap-2">
<span class="text-base font-bold text-[rgb(var(--text-primary))]">
{address.name || 'Primary Service Address'}
</span>
{#if address.isPrimary && showPrimaryBadge}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs font-semibold text-primary-500"
>Primary</span
>
{/if}
</span>
<span class="block text-sm text-[rgb(var(--text-secondary))]">
{address.streetAddress}, {address.city}, {address.state}
{address.zipCode}
</span>
</span>
<svg
class="h-5 w-5 flex-shrink-0 text-[rgb(var(--text-secondary))] transition-transform {expanded
? 'rotate-180'
: ''}"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if address.notes && expanded}
<p
class="mt-3 border-t border-[rgb(var(--border))] pt-3 text-sm text-[rgb(var(--text-secondary))] italic"
>
{address.notes}
</p>
{/if}
{#if expanded}
<!-- Schedules for this address -->
{#if address.schedules && address.schedules.length > 0}
<div class="mt-4 border-t border-[rgb(var(--border))] pt-4">
<h4 class="mb-3 text-sm font-semibold text-[rgb(var(--text-primary))]">Schedule</h4>
<div class="space-y-3">
{#each address.schedules as schedule (schedule.id)}
<div class="rounded-lg bg-[rgb(var(--bg-secondary))] p-3 text-sm">
<div class="mb-2 flex items-start justify-between">
<h5 class="font-semibold text-[rgb(var(--text-primary))]">
{schedule.name || 'Schedule'}
</h5>
{#if schedule.scheduleException}
<span
class="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-400"
>{schedule.scheduleException}</span
>
{/if}
</div>
{#if schedule.startDate}
<p class="mb-2 text-xs text-[rgb(var(--text-secondary))]">
As of {formatDate(schedule.startDate)}
</p>
{/if}
<div class="flex flex-wrap gap-1">
{#if schedule.sundayService}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs text-primary-500"
>Sun</span
>
{/if}
{#if schedule.mondayService}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs text-primary-500"
>Mon</span
>
{/if}
{#if schedule.tuesdayService}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs text-primary-500"
>Tue</span
>
{/if}
{#if schedule.wednesdayService}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs text-primary-500"
>Wed</span
>
{/if}
{#if schedule.thursdayService}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs text-primary-500"
>Thu</span
>
{/if}
{#if schedule.fridayService}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs text-primary-500"
>Fri</span
>
{/if}
{#if schedule.saturdayService}
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs text-primary-500"
>Sat</span
>
{/if}
{#if schedule.weekendService}
<span class="rounded bg-accent-500/20 px-2 py-1 text-xs text-accent-500"
>Weekend</span
>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Labor for this address -->
{#if address.labors && address.labors.length > 0}
<div class="mt-4 border-t border-[rgb(var(--border))] pt-4">
<h4 class="mb-3 text-sm font-semibold text-[rgb(var(--text-primary))]">Labor</h4>
<div class="space-y-3">
{#each address.labors as labor (labor.id)}
<div class="rounded-lg bg-[rgb(var(--bg-secondary))] p-3 text-sm">
<div class="mb-2 flex items-start justify-between">
<h5 class="font-semibold text-[rgb(var(--text-primary))]">
${labor.amount ? parseFloat(labor.amount).toFixed(2) : '0.00'}
</h5>
</div>
{#if labor.startDate}
<p class="text-xs text-[rgb(var(--text-secondary))]">
As of {formatDate(labor.startDate)}
</p>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Scopes for this address -->
{#if address.scopes && address.scopes.length > 0}
<div class="mt-4 border-t-2 border-accent-500/20 pt-4">
<button
onclick={() => onToggleScope?.(address.id)}
class="flex w-full items-center justify-between text-sm font-bold text-accent-600 transition-colors hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300"
>
<span>Scope</span>
<svg
class="h-5 w-5 transition-transform {expandedScopes[address.id] ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if expandedScopes[address.id]}
<div class="mt-4 space-y-4">
{#each address.scopes as scope (scope.id)}
<div
class="rounded-lg border border-accent-500/20 bg-gradient-to-br from-accent-500/5 to-accent-500/10 p-5 text-sm shadow-sm dark:from-accent-500/10 dark:to-accent-500/5"
>
<h5 class="mb-1 text-base font-bold text-accent-700 dark:text-accent-300">
{scope.name}
</h5>
{#if scope.description}
<p class="mb-4 text-xs text-[rgb(var(--text-secondary))]">
{scope.description}
</p>
{/if}
{#if scope.areas && scope.areas.length > 0}
<div class="mt-4 space-y-3">
{#each scope.areas as area (area.id)}
<div
class="rounded-r-md border-l-4 border-primary-500 bg-[rgb(var(--bg-primary))]/40 py-2 pr-2 pl-4"
>
<button
onclick={() => onToggleArea?.(area.id)}
class="flex w-full items-start justify-between gap-2 text-sm font-semibold text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<span class="flex-1 text-left">{area.name}</span>
<svg
class="h-4 w-4 flex-shrink-0 transition-transform {expandedAreas[
area.id
]
? 'rotate-180'
: ''}"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if expandedAreas[area.id] && area.tasks && area.tasks.length > 0}
<div class="mt-3 ml-1 space-y-2">
{#each area.tasks as task (task.id)}
<div
class="rounded-md border-l-2 border-[rgb(var(--text-secondary))]/20 bg-[rgb(var(--bg-secondary))] p-3"
>
<p
class="mb-1 text-xs leading-relaxed text-[rgb(var(--text-primary))]"
>
{task.description}
</p>
{#if task.frequency}
<p class="mt-2 text-[10px] text-[rgb(var(--text-secondary))]">
<span class="font-semibold text-primary-500">Frequency:</span>
{task.frequency}
</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</div>

View File

@ -0,0 +1,31 @@
<script lang="ts">
import type { EntityId } from './types';
interface Props {
ids: EntityId[];
title?: string;
}
let { ids, title = 'IDs' }: Props = $props();
</script>
<div class="lg:col-span-1">
<div
class="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--bg-primary))] p-6 lg:sticky lg:top-6"
>
<h2 class="mb-4 text-xl font-semibold text-accent-500">{title}</h2>
<div class="space-y-4 text-sm">
{#each ids as id (id.label)}
{#if id.value}
<div>
<p class="mb-1 text-[rgb(var(--text-secondary))]">{id.label}</p>
<p class="font-mono text-xs break-all text-[rgb(var(--text-primary))]">
{id.value}
</p>
</div>
{/if}
{/each}
</div>
</div>
</div>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import type { EntityType, EntityTab } from './types';
interface Props {
loading: boolean;
hasMore: boolean;
entityType: EntityType;
activeTab: EntityTab;
onLoadMore: () => void;
}
let { loading, hasMore, entityType, activeTab, onLoadMore }: Props = $props();
function getButtonColor(tab: EntityTab): string {
switch (tab) {
case 'scheduled':
return 'border-primary-500 text-primary-500 hover:bg-primary-500';
case 'in_progress':
return 'border-secondary-500 text-secondary-500 hover:bg-secondary-500';
case 'completed':
return 'border-accent-500 text-accent-500 hover:bg-accent-500';
}
}
let entityLabel = $derived(entityType === 'service' ? 'Services' : 'Projects');
</script>
{#if hasMore}
<div class="mt-6 flex justify-center">
<button
onclick={onLoadMore}
disabled={loading}
class="rounded-lg border px-6 py-3 font-semibold transition-colors hover:text-white disabled:opacity-50 {getButtonColor(
activeTab
)}"
>
{#if loading}
<span class="flex items-center gap-2">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
></span>
Loading...
</span>
{:else}
Load More {entityLabel}
{/if}
</button>
</div>
{/if}

View File

@ -0,0 +1,131 @@
<script lang="ts">
import { GetProjectScopeStore } from '$houdini';
import type { GetProjectScope$result } from '$houdini';
import { toGlobalId } from '$lib/utils/relay';
interface Props {
scopeId: string | undefined;
}
let { scopeId }: Props = $props();
// Internal state for fetching and expansion
const scopeStore = new GetProjectScopeStore();
let scopeData = $state<GetProjectScope$result | null>(null);
let scopeLoading = $state(false);
let scopeFetched = $state(false);
let scopeExpanded = $state(false);
let expandedAreas = $state<Record<string, boolean>>({});
async function toggleScope() {
scopeExpanded = !scopeExpanded;
// Fetch scope data on first expand
if (scopeExpanded && !scopeFetched && scopeId) {
scopeLoading = true;
try {
const result = await scopeStore.fetch({
variables: { id: toGlobalId('ProjectScopeType', scopeId) }
});
scopeData = result.data ?? null;
scopeFetched = true;
} finally {
scopeLoading = false;
}
}
}
function toggleArea(areaId: string) {
expandedAreas[areaId] = !expandedAreas[areaId];
}
</script>
<div class="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--bg-primary))] p-6">
{#if scopeId}
<button
onclick={toggleScope}
class="flex w-full items-center justify-between text-xl font-semibold text-accent-600 transition-colors hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300"
>
<span>Project Scope</span>
<svg
class="h-5 w-5 transition-transform {scopeExpanded ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if scopeExpanded && scopeData?.projectScope}
{@const scopeInfo = scopeData.projectScope}
<div class="mt-4 space-y-4">
<div
class="rounded-lg border border-accent-500/20 bg-gradient-to-br from-accent-500/5 to-accent-500/10 p-5 shadow-sm dark:from-accent-500/10 dark:to-accent-500/5"
>
<h3 class="mb-1 text-base font-bold text-accent-700 dark:text-accent-300">
{scopeInfo.name}
</h3>
{#if scopeInfo.description}
<p class="mb-4 text-xs text-[rgb(var(--text-secondary))]">
{scopeInfo.description}
</p>
{/if}
{#if scopeInfo.projectAreas && scopeInfo.projectAreas.length > 0}
<div class="mt-4 space-y-3">
{#each scopeInfo.projectAreas as area (area.id)}
<div
class="rounded-r-md border-l-4 border-primary-500 bg-[rgb(var(--bg-primary))]/40 py-2 pr-2 pl-4"
>
<button
onclick={() => toggleArea(area.id)}
class="flex w-full items-start justify-between gap-2 text-sm font-semibold text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<span class="flex-1 text-left">{area.name}</span>
<svg
class="h-4 w-4 flex-shrink-0 transition-transform {expandedAreas[area.id]
? 'rotate-180'
: ''}"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if expandedAreas[area.id] && area.projectTasks && area.projectTasks.length > 0}
<div class="mt-3 ml-1 space-y-2">
{#each area.projectTasks as task (task.id)}
<div
class="rounded-md border-l-2 border-[rgb(var(--text-secondary))]/20 bg-[rgb(var(--bg-secondary))] p-3"
>
<p class="text-xs leading-relaxed text-[rgb(var(--text-primary))]">
{task.description}
</p>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{:else if scopeExpanded && scopeLoading}
<div class="mt-4 flex items-center gap-2 text-sm text-[rgb(var(--text-secondary))]">
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-accent-500/30 border-t-accent-500"
></div>
<span>Loading scope...</span>
</div>
{:else if scopeExpanded && scopeFetched && !scopeData?.projectScope}
<p class="mt-4 text-sm text-[rgb(var(--text-secondary))]">No scope data available</p>
{/if}
{:else}
<h2 class="mb-4 text-xl font-semibold text-accent-600 dark:text-accent-400">Project Scope</h2>
<p class="text-sm text-[rgb(var(--text-secondary))]">No scopes loaded</p>
{/if}
</div>

View File

@ -0,0 +1,98 @@
<script lang="ts">
import type { ServiceScope } from './types';
interface Props {
scopes: ServiceScope[];
expandedScopes: Record<string, boolean>;
expandedAreas: Record<string, boolean>;
onToggleScope: (scopeId: string) => void;
onToggleArea: (areaId: string) => void;
}
let { scopes, expandedScopes, expandedAreas, onToggleScope, onToggleArea }: Props = $props();
</script>
{#if scopes && scopes.length > 0}
<div class="mt-4 border-t-2 border-accent-500/20 pt-4">
{#each scopes as scope (scope.id)}
<button
onclick={() => onToggleScope(scope.id)}
class="mb-2 flex w-full items-center justify-between text-sm font-bold text-accent-600 transition-colors hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300"
>
<span>Service Scope</span>
<svg
class="h-5 w-5 transition-transform {expandedScopes[scope.id] ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if expandedScopes[scope.id]}
<div class="mt-4">
<div
class="rounded-lg border border-accent-500/20 bg-gradient-to-br from-accent-500/5 to-accent-500/10 p-5 text-sm shadow-sm dark:from-accent-500/10 dark:to-accent-500/5"
>
<h5 class="mb-1 text-base font-bold text-accent-700 dark:text-accent-300">
{scope.name}
</h5>
{#if scope.description}
<p class="mb-4 text-xs text-[rgb(var(--text-secondary))]">
{scope.description}
</p>
{/if}
{#if scope.areas && scope.areas.length > 0}
<div class="mt-4 space-y-3">
{#each scope.areas as area (area.id)}
<div
class="rounded-r-md border-l-4 border-primary-500 bg-[rgb(var(--bg-primary))]/40 py-2 pr-2 pl-4"
>
<button
onclick={() => onToggleArea(area.id)}
class="flex w-full items-start justify-between gap-2 text-sm font-semibold text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<span class="flex-1 text-left">{area.name}</span>
<svg
class="h-4 w-4 flex-shrink-0 transition-transform {expandedAreas[area.id]
? 'rotate-180'
: ''}"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if expandedAreas[area.id] && area.tasks && area.tasks.length > 0}
<div class="mt-3 ml-1 space-y-2">
{#each area.tasks as task (task.id)}
<div
class="rounded-md border-l-2 border-[rgb(var(--text-secondary))]/20 bg-[rgb(var(--bg-secondary))] p-3"
>
<p class="mb-1 text-xs leading-relaxed text-[rgb(var(--text-primary))]">
{task.description}
</p>
{#if task.frequency}
<p class="mt-2 text-[10px] text-[rgb(var(--text-secondary))]">
<span class="font-semibold text-primary-500">Frequency:</span>
{task.frequency}
</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/if}

View File

@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
status: string;
}
let { status }: Props = $props();
function getStatusColor(status: string): string {
switch (status?.toLowerCase()) {
case 'completed':
return 'bg-green-500/20 text-green-600 dark:text-green-400';
case 'in_progress':
case 'active':
return 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400';
case 'scheduled':
case 'pending':
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
case 'cancelled':
return 'bg-red-500/20 text-red-600 dark:text-red-400';
default:
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
}
}
</script>
<span class="inline-block rounded-full px-3 py-1 text-sm font-semibold {getStatusColor(status)}">
{status}
</span>

View File

@ -0,0 +1,75 @@
<script lang="ts">
import type { TeamMember } from './types';
interface Props {
members: TeamMember[];
directMessageUrlFn: (pk: string, name: string) => string;
groupMessageUrl?: string;
}
let { members, directMessageUrlFn, groupMessageUrl }: Props = $props();
function getRoleBadge(role: string): string {
switch (role) {
case 'ADMIN':
return 'A';
case 'TEAM_LEADER':
return 'TL';
case 'TEAM_MEMBER':
return 'TM';
default:
return role.charAt(0);
}
}
</script>
{#if members.length > 0}
<div>
<div class="mb-2 flex items-center justify-between">
<p class="text-sm text-[rgb(var(--text-secondary))]">Team Members</p>
{#if groupMessageUrl && members.length >= 2}
<a
href={groupMessageUrl}
class="flex items-center gap-1.5 rounded-md bg-primary-500 px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-primary-600"
title="Message all team members"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" style="fill: currentColor">
<path
d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58A2.01 2.01 0 000 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85A6.95 6.95 0 0020 14c-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z"
/>
</svg>
<span>Message Team</span>
</a>
{/if}
</div>
<div class="space-y-2">
{#each members as member (member.pk)}
<div
class="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))]/30 p-3"
>
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-[rgb(var(--text-primary))]">
{member.fullName}
</p>
<div class="flex items-center gap-2">
<a
href={directMessageUrlFn(member.pk, member.fullName)}
class="rounded p-1 text-[rgb(var(--text-secondary))] transition-colors hover:bg-primary-500/10 hover:text-primary-500"
title="Send direct message"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" style="fill: currentColor">
<path
d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"
/>
</svg>
</a>
<span class="rounded bg-primary-500/20 px-2 py-1 text-xs font-bold text-primary-500">
{getRoleBadge(member.role)}
</span>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}

View File

@ -0,0 +1,15 @@
// Entity components barrel export
export { default as StatusBadge } from './StatusBadge.svelte';
export { default as IdSidebar } from './IdSidebar.svelte';
export { default as TeamMemberList } from './TeamMemberList.svelte';
export { default as AddressCard } from './AddressCard.svelte';
export { default as ServiceScopeCard } from './ServiceScopeCard.svelte';
export { default as ProjectScopeCard } from './ProjectScopeCard.svelte';
export { default as EntityTabs } from './EntityTabs.svelte';
export { default as EntityCard } from './EntityCard.svelte';
export { default as ContactCard } from './ContactCard.svelte';
export { default as ExpandableAddressCard } from './ExpandableAddressCard.svelte';
export { default as LoadMoreButton } from './LoadMoreButton.svelte';
// Types
export * from './types';

View File

@ -0,0 +1,194 @@
/**
* Shared type definitions for entity (service/project) components
*/
export type EntityType = 'service' | 'project';
export type EntityTab = 'scheduled' | 'in_progress' | 'completed';
/**
* Team member information for display in entity detail pages
*/
export interface TeamMember {
pk: string;
fullName: string;
role: string;
}
/**
* Address information that works for both account addresses and inline project addresses
*/
export interface AddressInfo {
name?: string | null;
streetAddress?: string | null;
city?: string | null;
state?: string | null;
zipCode?: string | null;
notes?: string | null;
}
/**
* ID entry for the sidebar display
*/
export interface EntityId {
label: string;
value: string | undefined;
}
/**
* Tab counts for the entity list pages
* Can be numbers or strings (e.g., "20+" when there are more pages)
*/
export interface EntityTabCounts {
scheduled: number | string;
inProgress: number | string;
completed: number | string;
}
/**
* Service scope area with tasks (from AccountAddress.scopes[].areas[])
*/
export interface ServiceScopeArea {
id: string;
name: string;
order?: number;
scopeId?: string;
tasks?: ServiceScopeTask[];
}
/**
* Service scope task
*/
export interface ServiceScopeTask {
id: string;
description: string;
checklistDescription?: string | null;
frequency?: string | null;
estimatedMinutes?: number | null;
order?: number | null;
areaId?: string | null;
isConditional?: boolean | null;
}
/**
* Service scope (from AccountAddress.scopes[])
*/
export interface ServiceScope {
id: string;
name: string;
description?: string;
isActive?: boolean;
accountId?: string;
accountAddressId?: string;
areas?: ServiceScopeArea[];
}
/**
* Project scope area with tasks
*/
export interface ProjectScopeArea {
id: string;
name: string;
order?: number;
scopeId?: string;
projectTasks?: ProjectScopeTask[];
}
/**
* Project scope task
*/
export interface ProjectScopeTask {
id: string;
description: string;
checklistDescription?: string;
order?: number;
}
/**
* Project scope data structure
*/
export interface ProjectScope {
id: string;
name: string;
description?: string;
isActive?: boolean;
accountId?: string;
accountAddressId?: string;
projectId?: string;
projectAreas?: ProjectScopeArea[];
}
/**
* Contact information for accounts/customers
*/
export interface ContactInfo {
id: string;
fullName: string;
email?: string | null;
phone?: string | null;
isPrimary?: boolean;
notes?: string | null;
}
/**
* Schedule for day-of-week display (account addresses)
*/
export interface AccountSchedule {
id: string;
name?: string | null;
startDate?: string | null;
endDate?: string | null;
accountAddressId?: string | null;
scheduleException?: string | null;
sundayService?: boolean;
mondayService?: boolean;
tuesdayService?: boolean;
wednesdayService?: boolean;
thursdayService?: boolean;
fridayService?: boolean;
saturdayService?: boolean;
weekendService?: boolean;
}
/**
* Labor cost entry (account addresses)
*/
export interface AccountLabor {
id: string;
amount: string;
startDate?: string | null;
}
/**
* Scope area type for account addresses (reuses ServiceScopeArea but uses 'tasks' naming)
*/
export interface AccountScopeArea {
id: string;
name: string;
order?: number;
scopeId?: string;
tasks?: ServiceScopeTask[];
}
/**
* Scope type for account addresses
*/
export interface AccountScope {
id: string;
name: string;
description?: string;
isActive?: boolean;
accountId?: string;
accountAddressId?: string;
areas?: AccountScopeArea[];
}
/**
* Account address with nested data (schedules, labor, scopes)
*/
export interface AccountAddressInfo extends AddressInfo {
id: string;
isPrimary?: boolean;
schedules?: AccountSchedule[];
labors?: AccountLabor[];
scopes?: AccountScope[];
}

View File

@ -0,0 +1,11 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
<div
class="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))] p-6 sm:p-8 lg:p-12"
>
{@render children()}
</div>
</div>

View File

@ -0,0 +1,422 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { themeStore } from '$lib/stores/theme.svelte.js';
import { isAuthenticated, logout } from '$lib/auth';
import {
startPolling as startMessagePolling,
stopPolling as stopMessagePolling,
formatCount
} from '$lib/utils/messages';
import {
startPolling as startNotificationPolling,
stopPolling as stopNotificationPolling
} from '$lib/utils/notifications';
let menuOpen = $state(false);
let unreadMessageCount = $state(0);
let unreadNotificationCount = $state(0);
let isCompact = $state(false);
onMount(() => {
if ($isAuthenticated) {
startMessagePolling((count) => {
unreadMessageCount = count;
});
startNotificationPolling((count) => {
unreadNotificationCount = count;
});
}
// Check viewport width for compact mode
const mediaQuery = window.matchMedia('(max-width: 459px)');
isCompact = mediaQuery.matches;
const handleMediaChange = (e: MediaQueryListEvent) => {
isCompact = e.matches;
};
mediaQuery.addEventListener('change', handleMediaChange);
return () => {
mediaQuery.removeEventListener('change', handleMediaChange);
};
});
onDestroy(() => {
stopMessagePolling();
stopNotificationPolling();
});
// Re-start polling when auth state changes
$effect(() => {
if ($isAuthenticated) {
startMessagePolling((count) => {
unreadMessageCount = count;
});
startNotificationPolling((count) => {
unreadNotificationCount = count;
});
} else {
stopMessagePolling();
stopNotificationPolling();
unreadMessageCount = 0;
unreadNotificationCount = 0;
}
});
async function handleLogout() {
await logout();
}
const publicNavItems = [{ href: '/', label: 'Home' }];
const authenticatedNavItems = [
{ href: '/', label: 'Home' },
{ href: '/accounts', label: 'Accounts' },
{ href: '/services', label: 'Services' },
{ href: '/projects', label: 'Projects' },
{ href: '/reports', label: 'Reports' },
{ href: '/profile', label: 'Profile' }
];
let navItems = $derived($isAuthenticated ? authenticatedNavItems : publicNavItems);
function toggleMenu() {
menuOpen = !menuOpen;
}
function closeMenu() {
menuOpen = false;
}
</script>
<nav class="sticky top-0 z-50 border-b border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))]">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between gap-4">
<!-- Left section: Hamburger + Logo -->
<div class="flex items-center gap-3">
<!-- Hamburger menu button -->
<button
onclick={toggleMenu}
class="inline-flex items-center justify-center rounded-md p-2 text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
aria-expanded={menuOpen}
aria-label="Toggle navigation menu"
>
{#if !menuOpen}
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
<!-- Logo -->
<a href="/" class="flex items-center space-x-2">
<span class="text-2xl font-bold text-primary-500">Nexus</span>
<span class="text-[rgb(var(--text-secondary))]">|</span>
<span class="text-base font-medium text-[rgb(var(--text-secondary))]">Team</span>
</a>
</div>
<!-- Right section: Icons + Theme + Auth -->
<div class="flex items-center space-x-1">
<!-- Icons shown in header when not compact -->
{#if !isCompact}
{#if $isAuthenticated}
<!-- Messages -->
<a
href="/messages"
class="relative rounded-md p-2 text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
aria-label="Messages"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
{#if unreadMessageCount > 0}
<span
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{formatCount(unreadMessageCount)}
</span>
{/if}
</a>
<!-- Notifications -->
<a
href="/notifications"
class="relative rounded-md p-2 text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
aria-label="Notifications"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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>
{#if unreadNotificationCount > 0}
<span
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{formatCount(unreadNotificationCount)}
</span>
{/if}
</a>
{/if}
<!-- Theme Toggle -->
<button
onclick={() => themeStore.toggle()}
class="rounded-md p-2 text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
aria-label="Toggle theme"
>
{#if themeStore.current === 'dark'}
<svg class="h-5 w-5" stroke="currentColor" viewBox="0 0 24 24" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{:else}
<svg class="h-5 w-5" stroke="currentColor" viewBox="0 0 24 24" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
{/if}
</button>
{/if}
<!-- Auth Button (always visible) -->
{#if $isAuthenticated}
<button
onclick={handleLogout}
class="ml-2 rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-700"
>
Log out
</button>
{:else}
<a
href="/login"
class="ml-2 rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-700"
>
Log in
</a>
{/if}
</div>
</div>
</div>
</nav>
<!-- Dropdown menu overlay -->
{#if menuOpen}
<!-- Backdrop to close menu when clicking outside -->
<button
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onclick={closeMenu}
aria-label="Close menu"
></button>
<!-- Menu panel -->
<div class="fixed top-16 right-0 left-0 z-50 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div
class="rounded-b-lg border border-t-0 border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))] shadow-lg"
>
<!-- Navigation items -->
<div class="space-y-1 px-2 pt-2 pb-3">
{#each navItems as item (item.href)}
<a
href={item.href}
onclick={closeMenu}
class="block rounded-md px-3 py-2 text-base font-medium transition-colors
{page.url.pathname === item.href
? 'bg-primary-600 text-white'
: 'text-[rgb(var(--text-secondary))] hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500'}"
>
{item.label}
</a>
{/each}
</div>
<!-- Icons section (shown in menu when compact) -->
{#if isCompact}
<div
class="flex items-center justify-between border-t border-[rgb(var(--border))] px-4 py-3"
>
<div class="flex items-center space-x-2">
{#if $isAuthenticated}
<!-- Messages -->
<a
href="/messages"
onclick={closeMenu}
class="relative rounded-md p-2 text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
aria-label="Messages"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
{#if unreadMessageCount > 0}
<span
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{formatCount(unreadMessageCount)}
</span>
{/if}
</a>
<!-- Notifications -->
<a
href="/notifications"
onclick={closeMenu}
class="relative rounded-md p-2 text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
aria-label="Notifications"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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>
{#if unreadNotificationCount > 0}
<span
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{formatCount(unreadNotificationCount)}
</span>
{/if}
</a>
{/if}
<!-- Theme Toggle -->
<button
onclick={() => themeStore.toggle()}
class="rounded-md p-2 text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
aria-label="Toggle theme"
>
{#if themeStore.current === 'dark'}
<svg class="h-5 w-5" stroke="currentColor" viewBox="0 0 24 24" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{:else}
<svg class="h-5 w-5" stroke="currentColor" viewBox="0 0 24 24" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
{/if}
</button>
</div>
<span class="text-sm text-[rgb(var(--text-secondary))]">
{themeStore.current === 'dark' ? 'Dark' : 'Light'} mode
</span>
</div>
{/if}
<!-- Nexus Apps Section -->
<div class="border-t border-[rgb(var(--border))] px-2 py-3">
<p class="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-[rgb(var(--text-tertiary))]">
Nexus Apps
</p>
<div class="space-y-1">
<!-- Team (current app - active) -->
<span
class="flex items-center rounded-md bg-primary-600 px-3 py-2 text-base font-medium text-white"
>
<span>Nexus</span>
<span class="mx-2 text-white/70">|</span>
<span>Team</span>
</span>
<!-- Admin -->
<a
href="https://admin.example.com"
onclick={closeMenu}
class="flex items-center rounded-md px-3 py-2 text-base font-medium text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
>
<span>Nexus</span>
<span class="mx-2 text-[rgb(var(--text-tertiary))]">|</span>
<span>Admin</span>
<svg
class="ml-2 h-4 w-4"
viewBox="0 0 24 24"
stroke="currentColor"
style="fill: none"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
<!-- Account -->
<a
href="https://account.example.com"
onclick={closeMenu}
class="flex items-center rounded-md px-3 py-2 text-base font-medium text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--bg-primary))] hover:text-primary-500"
>
<span>Nexus</span>
<span class="mx-2 text-[rgb(var(--text-tertiary))]">|</span>
<span>Account</span>
<svg
class="ml-2 h-4 w-4"
viewBox="0 0 24 24"
stroke="currentColor"
style="fill: none"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,81 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import type { SessionType } from './types';
interface Props {
sessionType: SessionType;
accountName?: string;
customerName?: string;
startDate: string;
scopeName?: string;
scopeDescription?: string;
isActive: boolean;
canClose: boolean;
isSubmitting: boolean;
onClose: () => void;
}
let {
sessionType,
accountName,
customerName,
startDate,
scopeName,
scopeDescription,
isActive,
canClose,
isSubmitting,
onClose
}: Props = $props();
let backHref = $derived(sessionType === 'service' ? '/services' : '/projects');
let backLabel = $derived(sessionType === 'service' ? 'Back to Services' : 'Back to Projects');
let sessionTitle = $derived(sessionType === 'service' ? 'Service Session' : 'Project Session');
// For projects, fall back to customer name if no account
let displayName = $derived(accountName || customerName);
</script>
<!-- Back Button -->
<a
href={backHref}
class="mb-6 inline-flex items-center gap-2 text-[rgb(var(--text-secondary))] transition-colors hover:text-[rgb(var(--text-primary))]"
>
<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 19l-7-7 7-7" />
</svg>
<span>{backLabel}</span>
</a>
<!-- Session Header -->
<div class="mb-8">
<div class="mb-4 flex items-start justify-between">
<div>
{#if displayName}
<p class="text-sm font-medium text-[rgb(var(--text-secondary))]">{displayName}</p>
{/if}
<h1 class="text-3xl font-bold text-[rgb(var(--text-primary))]">{sessionTitle}</h1>
<p class="mt-1 text-[rgb(var(--text-secondary))]">
Started {formatDate(startDate)}
</p>
</div>
{#if isActive}
<button
onclick={onClose}
disabled={!canClose || isSubmitting}
class="rounded-lg bg-red-500 px-6 py-3 font-medium text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Closing...' : 'Close Session'}
</button>
{/if}
</div>
{#if scopeName}
<div class="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--bg-primary))] p-4">
<h2 class="mb-2 text-xl font-semibold text-[rgb(var(--text-primary))]">{scopeName}</h2>
{#if scopeDescription}
<p class="text-[rgb(var(--text-secondary))]">{scopeDescription}</p>
{/if}
</div>
{/if}
</div>

View File

@ -0,0 +1,182 @@
<script lang="ts">
import type { SessionPhoto, SessionVideo } from './types';
import { getAuthenticatedImageUrl } from './types';
import SessionMediaUploadForm from './SessionMediaUploadForm.svelte';
import SessionPhotoGrid from './SessionPhotoGrid.svelte';
import SessionVideoGrid from './SessionVideoGrid.svelte';
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
interface Props {
photos: SessionPhoto[];
videos: SessionVideo[];
isActive: boolean;
getTeamMemberName: (id: string | null | undefined) => string;
onUploadPhoto: (file: File, title: string, notes: string, internal: boolean) => Promise<void>;
onUploadVideo: (file: File, title: string, notes: string, internal: boolean) => Promise<void>;
onUpdatePhoto: (photoId: string, title: string, notes: string) => Promise<void>;
onUpdateVideo: (videoId: string, title: string, notes: string) => Promise<void>;
onDeletePhoto: (photoId: string) => Promise<void>;
onDeleteVideo: (videoId: string) => Promise<void>;
}
let {
photos,
videos,
isActive,
getTeamMemberName,
onUploadPhoto,
onUploadVideo,
onUpdatePhoto,
onUpdateVideo,
onDeletePhoto,
onDeleteVideo
}: Props = $props();
// Upload form state
let isUploadingPhoto = $state(false);
let isUploadingVideo = $state(false);
// Store authenticated URLs
let authenticatedImageUrls = new SvelteMap<string, string>();
// Track URLs that are currently being fetched to prevent duplicates
let fetchingUrls = new SvelteSet<string>();
// Load authenticated image URLs when photos/videos change
$effect(() => {
// Load photo URLs (both thumbnail and full image)
photos?.forEach(async (photo) => {
// Fetch thumbnail URL
const thumbnailUrl = photo.thumbnail?.url || photo.image.url;
if (
thumbnailUrl &&
!authenticatedImageUrls.has(thumbnailUrl) &&
!fetchingUrls.has(thumbnailUrl)
) {
fetchingUrls.add(thumbnailUrl);
const authUrl = await getAuthenticatedImageUrl(thumbnailUrl);
authenticatedImageUrls.set(thumbnailUrl, authUrl);
fetchingUrls.delete(thumbnailUrl);
}
// Also fetch full image URL if different from thumbnail
if (
photo.image.url !== thumbnailUrl &&
!authenticatedImageUrls.has(photo.image.url) &&
!fetchingUrls.has(photo.image.url)
) {
fetchingUrls.add(photo.image.url);
const authUrl = await getAuthenticatedImageUrl(photo.image.url);
authenticatedImageUrls.set(photo.image.url, authUrl);
fetchingUrls.delete(photo.image.url);
}
});
// Load video thumbnail URLs
videos?.forEach(async (video) => {
if (
video.thumbnail?.url &&
!authenticatedImageUrls.has(video.thumbnail.url) &&
!fetchingUrls.has(video.thumbnail.url)
) {
fetchingUrls.add(video.thumbnail.url);
const authUrl = await getAuthenticatedImageUrl(video.thumbnail.url);
authenticatedImageUrls.set(video.thumbnail.url, authUrl);
fetchingUrls.delete(video.thumbnail.url);
}
});
});
async function handlePhotoUpload(file: File, title: string, notes: string, internal: boolean) {
await onUploadPhoto(file, title, notes, internal);
isUploadingPhoto = false;
}
async function handleVideoUpload(file: File, title: string, notes: string, internal: boolean) {
await onUploadVideo(file, title, notes, internal);
isUploadingVideo = false;
}
</script>
<div class="space-y-6">
<!-- Upload Section (only for active sessions) -->
{#if isActive}
<div class="rounded-lg border-2 border-primary-500 bg-[rgb(var(--bg-primary))] p-6">
<div class="flex flex-wrap gap-3">
{#if isUploadingPhoto}
<SessionMediaUploadForm
mediaType="photo"
onUpload={handlePhotoUpload}
onCancel={() => (isUploadingPhoto = false)}
/>
{:else if isUploadingVideo}
<SessionMediaUploadForm
mediaType="video"
onUpload={handleVideoUpload}
onCancel={() => (isUploadingVideo = false)}
/>
{:else}
<button
onclick={() => (isUploadingPhoto = true)}
class="flex items-center gap-2 rounded-lg border-2 border-dashed border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] px-4 py-3 text-[rgb(var(--text-secondary))] transition-colors hover:border-primary-500 hover:text-primary-500"
>
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="font-medium">Upload Photo</span>
</button>
<button
onclick={() => (isUploadingVideo = true)}
class="flex items-center gap-2 rounded-lg border-2 border-dashed border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] px-4 py-3 text-[rgb(var(--text-secondary))] transition-colors hover:border-primary-500 hover:text-primary-500"
>
<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 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<span class="font-medium">Upload Video</span>
</button>
{/if}
</div>
</div>
{:else}
<div class="rounded-lg border border-tertiary-500/30 bg-tertiary-500/10 p-4">
<p class="text-sm text-[rgb(var(--text-secondary))]">
This session is closed. Media cannot be uploaded or deleted.
</p>
</div>
{/if}
<!-- Photos Section -->
<SessionPhotoGrid
{photos}
{isActive}
authenticatedUrls={authenticatedImageUrls}
{getTeamMemberName}
onUpdate={onUpdatePhoto}
onDelete={onDeletePhoto}
/>
<!-- Videos Section -->
<SessionVideoGrid
{videos}
{isActive}
authenticatedUrls={authenticatedImageUrls}
{getTeamMemberName}
onUpdate={onUpdateVideo}
onDelete={onDeleteVideo}
/>
{#if (!photos || photos.length === 0) && (!videos || videos.length === 0) && !isActive}
<div class="py-12 text-center">
<p class="text-lg text-[rgb(var(--text-secondary))]">No media for this session</p>
</div>
{/if}
</div>

View File

@ -0,0 +1,108 @@
<script lang="ts">
type MediaType = 'photo' | 'video';
interface Props {
mediaType: MediaType;
onUpload: (file: File, title: string, notes: string, internal: boolean) => Promise<void>;
onCancel: () => void;
}
let { mediaType, onUpload, onCancel }: Props = $props();
let file = $state<File | null>(null);
let title = $state('');
let notes = $state('');
let internal = $state(true);
let submitting = $state(false);
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
file = input.files[0];
}
}
async function handleUpload() {
if (!file) return;
try {
submitting = true;
// Use filename as default title if not provided
const finalTitle = title || file.name;
await onUpload(file, finalTitle, notes, internal);
} catch (err) {
console.error(`Failed to upload ${mediaType}:`, err);
} finally {
submitting = false;
}
}
function handleCancel() {
file = null;
title = '';
notes = '';
internal = true;
onCancel();
}
let isPhoto = $derived(mediaType === 'photo');
let acceptType = $derived(isPhoto ? 'image/*' : 'video/*');
let uploadLabel = $derived(isPhoto ? 'Upload Photo' : 'Upload Video');
</script>
<div class="w-full space-y-4">
<h3 class="text-xl font-semibold text-[rgb(var(--text-primary))]">
{uploadLabel}
</h3>
<div>
<input
type="file"
accept={acceptType}
onchange={handleFileSelect}
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-3 text-[rgb(var(--text-primary))] file:mr-4 file:rounded file:border-0 file:bg-primary-500 file:px-4 file:py-2 file:text-sm file:font-medium file:text-white"
/>
</div>
{#if file}
<p class="text-sm text-[rgb(var(--text-secondary))]">
Selected: {file.name}
</p>
{/if}
<input
type="text"
bind:value={title}
placeholder="Title (optional - defaults to filename)"
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-3 text-[rgb(var(--text-primary))] placeholder-[rgb(var(--text-secondary))] focus:border-primary-500 focus:outline-none"
/>
<textarea
bind:value={notes}
placeholder="Notes (optional)"
rows="2"
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-3 text-[rgb(var(--text-primary))] placeholder-[rgb(var(--text-secondary))] focus:border-primary-500 focus:outline-none"
></textarea>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="{mediaType}-internal"
bind:checked={internal}
class="h-4 w-4 rounded border-[rgb(var(--border))] text-primary-500 focus:ring-primary-500"
/>
<label for="{mediaType}-internal" class="text-sm text-[rgb(var(--text-secondary))]">
Internal (not visible to customer)
</label>
</div>
<div class="flex justify-end gap-3">
<button
onclick={handleCancel}
class="rounded-lg px-4 py-2 text-sm font-medium text-[rgb(var(--text-secondary))] transition-colors hover:text-[rgb(var(--text-primary))]"
>
Cancel
</button>
<button
onclick={handleUpload}
disabled={submitting || !file}
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"
>
{submitting ? 'Uploading...' : 'Upload'}
</button>
</div>
</div>

View File

@ -0,0 +1,269 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import type { SessionNote } from './types';
interface Props {
notes: SessionNote[];
isActive: boolean;
onAdd: (content: string, internal: boolean) => Promise<void>;
onUpdate: (noteId: string, content: string, internal: boolean) => Promise<void>;
onDelete: (noteId: string) => Promise<void>;
}
let { notes, isActive, onAdd, onUpdate, onDelete }: Props = $props();
// State for adding notes
let isAddingNote = $state(false);
let newNoteContent = $state('');
let newNoteInternal = $state(true);
let noteSubmitting = $state(false);
// State for editing notes
let editingNoteId = $state<string | null>(null);
let editNoteContent = $state('');
let editNoteInternal = $state(true);
// State for deleting notes
let deletingNoteId = $state<string | null>(null);
// Section collapse state
let notesSectionExpanded = $state(true);
async function handleAddNote() {
if (!newNoteContent.trim()) return;
try {
noteSubmitting = true;
await onAdd(newNoteContent.trim(), newNoteInternal);
newNoteContent = '';
newNoteInternal = true;
isAddingNote = false;
} catch (err) {
console.error('Failed to add note:', err);
} finally {
noteSubmitting = false;
}
}
function startEditNote(note: SessionNote) {
editingNoteId = note.id;
editNoteContent = note.content;
editNoteInternal = note.internal;
}
function cancelEditNote() {
editingNoteId = null;
editNoteContent = '';
editNoteInternal = true;
}
async function handleUpdateNote(noteId: string) {
if (!editNoteContent.trim()) return;
try {
noteSubmitting = true;
await onUpdate(noteId, editNoteContent.trim(), editNoteInternal);
editingNoteId = null;
editNoteContent = '';
editNoteInternal = true;
} catch (err) {
console.error('Failed to update note:', err);
} finally {
noteSubmitting = false;
}
}
async function handleDeleteNote(noteId: string) {
try {
deletingNoteId = noteId;
await onDelete(noteId);
} catch (err) {
console.error('Failed to delete note:', err);
} finally {
deletingNoteId = null;
}
}
</script>
<div class="space-y-6">
<!-- Add Note Section (only for active sessions) -->
{#if isActive}
<div class="rounded-lg border-2 border-primary-500 bg-[rgb(var(--bg-primary))] p-6">
{#if isAddingNote}
<div class="space-y-4">
<h3 class="text-xl font-semibold text-[rgb(var(--text-primary))]">Add Note</h3>
<textarea
bind:value={newNoteContent}
placeholder="Enter your note..."
rows="4"
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-3 text-[rgb(var(--text-primary))] placeholder-[rgb(var(--text-secondary))] focus:border-primary-500 focus:outline-none"
></textarea>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="new-note-internal"
bind:checked={newNoteInternal}
class="h-4 w-4 rounded border-[rgb(var(--border))] text-primary-500 focus:ring-primary-500"
/>
<label for="new-note-internal" class="text-sm text-[rgb(var(--text-secondary))]">
Internal note (not visible to customer)
</label>
</div>
<div class="flex justify-end gap-3">
<button
onclick={() => {
isAddingNote = false;
newNoteContent = '';
newNoteInternal = true;
}}
class="rounded-lg px-4 py-2 text-sm font-medium text-[rgb(var(--text-secondary))] transition-colors hover:text-[rgb(var(--text-primary))]"
>
Cancel
</button>
<button
onclick={handleAddNote}
disabled={noteSubmitting || !newNoteContent.trim()}
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"
>
{noteSubmitting ? 'Adding...' : 'Add Note'}
</button>
</div>
</div>
{:else}
<button
onclick={() => (isAddingNote = true)}
class="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-4 text-[rgb(var(--text-secondary))] transition-colors hover:border-primary-500 hover:text-primary-500"
>
<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 4v16m8-8H4"
/>
</svg>
<span class="font-medium">Add Note</span>
</button>
{/if}
</div>
{:else}
<div class="rounded-lg border border-tertiary-500/30 bg-tertiary-500/10 p-4">
<p class="text-sm text-[rgb(var(--text-secondary))]">
This session is closed. Notes cannot be added or modified.
</p>
</div>
{/if}
<!-- Existing Notes -->
{#if notes && notes.length > 0}
<div class="rounded-lg border-2 border-primary-500/30 bg-[rgb(var(--bg-primary))] p-6">
<button
onclick={() => (notesSectionExpanded = !notesSectionExpanded)}
class="mb-4 flex w-full items-center justify-between"
>
<span class="text-xl font-semibold text-[rgb(var(--text-primary))]">
Notes ({notes.length})
</span>
<svg
class="h-5 w-5 transition-transform {notesSectionExpanded ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
style="fill: none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if notesSectionExpanded}
<div class="space-y-3">
{#each notes as note (note.id)}
<div
class="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-3"
>
{#if editingNoteId === note.id}
<!-- Edit Mode -->
<div class="space-y-3">
<textarea
bind:value={editNoteContent}
rows="4"
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-primary))] p-3 text-[rgb(var(--text-primary))] focus:border-primary-500 focus:outline-none"
></textarea>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="edit-note-internal-{note.id}"
bind:checked={editNoteInternal}
class="h-4 w-4 rounded border-[rgb(var(--border))] text-primary-500 focus:ring-primary-500"
/>
<label
for="edit-note-internal-{note.id}"
class="text-sm text-[rgb(var(--text-secondary))]"
>
Internal note
</label>
</div>
<div class="flex justify-end gap-3">
<button
onclick={cancelEditNote}
class="rounded-lg px-4 py-2 text-sm font-medium text-[rgb(var(--text-secondary))] transition-colors hover:text-[rgb(var(--text-primary))]"
>
Cancel
</button>
<button
onclick={() => handleUpdateNote(note.id)}
disabled={noteSubmitting || !editNoteContent.trim()}
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"
>
{noteSubmitting ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{:else}
<!-- View Mode -->
<div>
<div
class="mb-2 flex flex-col gap-1 text-xs text-[rgb(var(--text-secondary))] md:flex-row md:items-center md:gap-3"
>
<span>{formatDate(note.createdAt)}</span>
{#if note.internal}
<span class="hidden md:inline"></span>
<span
class="rounded bg-tertiary-500/20 px-2 py-0.5 text-xs font-medium text-tertiary-600"
>
Internal
</span>
{/if}
</div>
<p class="whitespace-pre-wrap text-[rgb(var(--text-primary))]">
{note.content}
</p>
{#if isActive}
<div class="mt-3 flex justify-end gap-2">
<button
onclick={() => startEditNote(note)}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-[rgb(var(--text-secondary))] transition-colors hover:bg-[rgb(var(--surface-primary))] hover:text-[rgb(var(--text-primary))]"
>
Edit
</button>
<button
onclick={() => handleDeleteNote(note.id)}
disabled={deletingNoteId === note.id}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-red-500 transition-colors hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-50"
>
{deletingNoteId === note.id ? 'Deleting...' : 'Delete'}
</button>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else if !isActive}
<div class="py-12 text-center">
<p class="text-lg text-[rgb(var(--text-secondary))]">No notes for this session</p>
</div>
{/if}
</div>

View File

@ -0,0 +1,198 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import type { SessionPhoto } from './types';
import type { SvelteMap } from 'svelte/reactivity';
interface Props {
photos: SessionPhoto[];
isActive: boolean;
authenticatedUrls: SvelteMap<string, string>;
getTeamMemberName: (id: string | null | undefined) => string;
onUpdate: (photoId: string, title: string, notes: string) => Promise<void>;
onDelete: (photoId: string) => Promise<void>;
}
let { photos, isActive, authenticatedUrls, getTeamMemberName, onUpdate, onDelete }: Props =
$props();
// State for editing
let editingPhotoId = $state<string | null>(null);
let editPhotoTitle = $state('');
let editPhotoNotes = $state('');
let mediaSubmitting = $state(false);
// State for deleting
let deletingPhotoId = $state<string | null>(null);
// Section collapse state
let photosSectionExpanded = $state(true);
function startEditPhoto(photo: SessionPhoto) {
editingPhotoId = photo.id;
editPhotoTitle = photo.title;
editPhotoNotes = photo.notes;
}
function cancelEditPhoto() {
editingPhotoId = null;
editPhotoTitle = '';
editPhotoNotes = '';
}
async function handleUpdatePhoto(photoId: string) {
if (!editPhotoTitle.trim()) return;
try {
mediaSubmitting = true;
await onUpdate(photoId, editPhotoTitle.trim(), editPhotoNotes.trim());
editingPhotoId = null;
editPhotoTitle = '';
editPhotoNotes = '';
} catch (err) {
console.error('Failed to update photo:', err);
} finally {
mediaSubmitting = false;
}
}
async function handleDeletePhoto(photoId: string) {
try {
deletingPhotoId = photoId;
await onDelete(photoId);
} catch (err) {
console.error('Failed to delete photo:', err);
} finally {
deletingPhotoId = null;
}
}
</script>
{#if photos && photos.length > 0}
<div class="rounded-lg border-2 border-accent-500/30 bg-[rgb(var(--bg-primary))] p-6">
<button
onclick={() => (photosSectionExpanded = !photosSectionExpanded)}
class="mb-4 flex w-full items-center justify-between"
>
<span class="text-xl font-semibold text-[rgb(var(--text-primary))]">
Photos ({photos.length})
</span>
<svg
class="h-5 w-5 transition-transform {photosSectionExpanded ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
style="fill: none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if photosSectionExpanded}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each photos as photo (photo.id)}
{@const originalUrl = photo.thumbnail?.url || photo.image.url}
{@const imageUrl = authenticatedUrls.get(originalUrl)}
{@const fullImageUrl = authenticatedUrls.get(photo.image.url)}
<div
class="overflow-hidden rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))]"
>
<a
href={fullImageUrl || imageUrl || '#'}
target="_blank"
rel="noopener noreferrer"
class="block"
>
{#if imageUrl}
<img
src={imageUrl}
alt={photo.title}
class="h-48 w-full object-cover transition-opacity hover:opacity-90"
/>
{:else}
<div
class="flex h-48 w-full items-center justify-center bg-[rgb(var(--bg-secondary))]"
>
<div class="text-sm text-[rgb(var(--text-secondary))]">Loading...</div>
</div>
{/if}
</a>
<div class="p-3">
{#if editingPhotoId === photo.id}
<!-- Edit Mode -->
<div class="space-y-3">
<input
type="text"
bind:value={editPhotoTitle}
placeholder="Title"
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-2 text-sm text-[rgb(var(--text-primary))] placeholder-[rgb(var(--text-secondary))] focus:border-primary-500 focus:outline-none"
/>
<textarea
bind:value={editPhotoNotes}
placeholder="Notes (optional)"
rows="2"
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-2 text-sm text-[rgb(var(--text-primary))] placeholder-[rgb(var(--text-secondary))] focus:border-primary-500 focus:outline-none"
></textarea>
<div class="flex justify-end gap-2">
<button
onclick={cancelEditPhoto}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-[rgb(var(--text-secondary))] transition-colors hover:text-[rgb(var(--text-primary))]"
>
Cancel
</button>
<button
onclick={() => handleUpdatePhoto(photo.id)}
disabled={mediaSubmitting || !editPhotoTitle.trim()}
class="rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{mediaSubmitting ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{:else}
<!-- View Mode -->
<div class="mb-2 flex items-start justify-between gap-2">
<h3 class="flex-1 text-left font-semibold text-[rgb(var(--text-primary))]">
{photo.title}
</h3>
{#if photo.internal}
<span
class="flex-shrink-0 rounded bg-tertiary-500/20 px-2 py-0.5 text-xs font-medium text-tertiary-600"
>
Internal
</span>
{/if}
</div>
{#if photo.notes}
<p class="mt-1 text-sm text-[rgb(var(--text-secondary))]">{photo.notes}</p>
{/if}
<div
class="mt-2 flex flex-col gap-1 text-xs text-[rgb(var(--text-secondary))] md:flex-row md:items-center md:gap-3"
>
<span>{formatDate(photo.createdAt)}</span>
<span class="hidden md:inline"></span>
<span>By: {getTeamMemberName(photo.uploadedByTeamProfileId)}</span>
</div>
{#if isActive}
<div class="mt-3 flex justify-end gap-2">
<button
onclick={() => startEditPhoto(photo)}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-[rgb(var(--text-secondary))] transition-colors hover:text-[rgb(var(--text-primary))]"
>
Edit
</button>
<button
onclick={() => handleDeletePhoto(photo.id)}
disabled={deletingPhotoId === photo.id}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-red-500 transition-colors hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-50"
>
{deletingPhotoId === photo.id ? 'Deleting...' : 'Delete'}
</button>
</div>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}

View File

@ -0,0 +1,278 @@
<script lang="ts">
import { formatDate, formatDateTime } from '$lib/utils/date';
import type {
SessionType,
TeamProfile,
Account,
AccountAddress,
Service,
Project
} from './types';
interface SessionStats {
completedTasks: number;
photos: number;
videos: number;
notes: number;
}
interface SessionDetails {
start: string;
end?: string | null;
isActive: boolean;
}
interface CustomerInfo {
name: string;
}
// Project-specific address (when project has its own address)
interface ProjectAddress {
streetAddress?: string | null;
city?: string | null;
state?: string | null;
zipCode?: string | null;
}
interface Props {
sessionType: SessionType;
stats: SessionStats;
sessionDetails: SessionDetails;
entity?: Service | Project | null;
account?: Account | null;
accountAddress?: AccountAddress | null;
customer?: CustomerInfo | null;
teamProfiles: TeamProfile[];
projectAddress?: ProjectAddress | null;
}
let {
sessionType,
stats,
sessionDetails,
entity,
account,
accountAddress,
customer,
teamProfiles,
projectAddress
}: Props = $props();
let isService = $derived(sessionType === 'service');
let entityLabel = $derived(isService ? 'Service Information' : 'Project Information');
let dateLabel = $derived(isService ? 'Service Date' : 'Project Date');
// Total submissions count
let totalSubmissions = $derived(stats.completedTasks + stats.photos + stats.videos + stats.notes);
// Entity properties (cast to access specific fields)
let entityNotes = $derived(entity?.notes);
let entityDate = $derived(entity && 'date' in entity ? entity.date : null);
let entityStatus = $derived(entity && 'status' in entity ? entity.status : null);
// Address to display (account address or project's own address)
let displayAddress = $derived(accountAddress || projectAddress);
</script>
<div class="space-y-4">
<!-- Session Stats -->
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<!-- Completed Tasks Card -->
<div
class="rounded-lg border-2 border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-accent-500/5 p-4 shadow-sm"
>
<p
class="mb-1 text-xs font-semibold tracking-wide text-accent-600 uppercase dark:text-accent-400"
>
Tasks
</p>
<p class="text-3xl font-bold text-accent-600 dark:text-accent-400">
{stats.completedTasks}
</p>
<p class="mt-1 text-xs text-[rgb(var(--text-secondary))]">completed</p>
</div>
<!-- Photos Card -->
<div
class="rounded-lg border-2 border-primary-500/30 bg-gradient-to-br from-primary-500/10 to-primary-500/5 p-4 shadow-sm"
>
<p
class="mb-1 text-xs font-semibold tracking-wide text-primary-600 uppercase dark:text-primary-400"
>
Photos
</p>
<p class="text-3xl font-bold text-primary-600 dark:text-primary-400">
{stats.photos}
</p>
<p class="mt-1 text-xs text-[rgb(var(--text-secondary))]">images</p>
</div>
<!-- Videos Card -->
<div
class="rounded-lg border-2 border-secondary-500/30 bg-gradient-to-br from-secondary-500/10 to-secondary-500/5 p-4 shadow-sm"
>
<p
class="mb-1 text-xs font-semibold tracking-wide text-secondary-600 uppercase dark:text-secondary-400"
>
Videos
</p>
<p class="text-3xl font-bold text-secondary-600 dark:text-secondary-400">
{stats.videos}
</p>
<p class="mt-1 text-xs text-[rgb(var(--text-secondary))]">clips</p>
</div>
<!-- Notes Card -->
<div
class="rounded-lg border-2 border-tertiary-500/30 bg-gradient-to-br from-tertiary-500/10 to-tertiary-500/5 p-4 shadow-sm"
>
<p
class="mb-1 text-xs font-semibold tracking-wide text-tertiary-600 uppercase dark:text-tertiary-400"
>
Notes
</p>
<p class="text-3xl font-bold text-tertiary-600 dark:text-tertiary-400">
{stats.notes}
</p>
<p class="mt-1 text-xs text-[rgb(var(--text-secondary))]">entries</p>
</div>
</div>
<!-- Total Submissions -->
<div
class="flex items-center justify-between rounded-lg border-2 border-[rgb(var(--border))] bg-gradient-to-r from-[rgb(var(--surface-secondary))] to-[rgb(var(--bg-primary))] p-4 shadow-sm"
>
<p class="text-sm font-semibold tracking-wide text-[rgb(var(--text-secondary))] uppercase">
Total Submissions
</p>
<p class="text-3xl font-bold text-[rgb(var(--text-primary))]">
{totalSubmissions}
</p>
</div>
<!-- Entity Information (Service/Project) -->
{#if entity || account || customer}
<div class="rounded-lg border-2 border-primary-500/30 bg-[rgb(var(--bg-primary))] p-4">
<h3 class="mb-3 text-lg font-semibold text-[rgb(var(--text-primary))]">
{entityLabel}
</h3>
<div class="space-y-3">
{#if entityNotes}
<div>
<p class="text-sm text-[rgb(var(--text-secondary))]">{entityNotes}</p>
</div>
{/if}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#if customer}
<div>
<p class="text-xs text-[rgb(var(--text-secondary))]">Customer</p>
<p class="font-medium text-[rgb(var(--text-primary))]">{customer.name}</p>
</div>
{/if}
{#if account}
<div>
<p class="text-xs text-[rgb(var(--text-secondary))]">Account</p>
<p class="font-medium text-[rgb(var(--text-primary))]">{account.name}</p>
</div>
{/if}
{#if displayAddress}
<div>
<p class="text-xs text-[rgb(var(--text-secondary))]">Address</p>
<p class="font-medium text-[rgb(var(--text-primary))]">
{displayAddress.streetAddress}
</p>
{#if displayAddress.city || displayAddress.state || displayAddress.zipCode}
<p class="text-sm text-[rgb(var(--text-secondary))]">
{[displayAddress.city, displayAddress.state, displayAddress.zipCode]
.filter(Boolean)
.join(', ')}
</p>
{/if}
</div>
{/if}
{#if entityDate}
<div>
<p class="text-xs text-[rgb(var(--text-secondary))]">{dateLabel}</p>
<p class="font-medium text-[rgb(var(--text-primary))]">
{formatDate(entityDate)}
</p>
</div>
{/if}
{#if entityStatus}
<div>
<p class="text-xs text-[rgb(var(--text-secondary))]">Status</p>
<span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold
{entityStatus === 'SCHEDULED' ? 'bg-tertiary-500/20 text-tertiary-600 dark:text-tertiary-400' : ''}
{entityStatus === 'IN_PROGRESS' ? 'bg-primary-500/20 text-primary-600 dark:text-primary-400' : ''}
{entityStatus === 'COMPLETED' ? 'bg-accent-500/20 text-accent-600 dark:text-accent-400' : ''}
{entityStatus === 'CANCELLED'
? 'bg-secondary-500/20 text-secondary-600 dark:text-secondary-400'
: ''}
"
>
{entityStatus === 'SCHEDULED' ? 'Scheduled' : ''}
{entityStatus === 'IN_PROGRESS' ? 'In Progress' : ''}
{entityStatus === 'COMPLETED' ? 'Completed' : ''}
{entityStatus === 'CANCELLED' ? 'Cancelled' : ''}
</span>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Session Details -->
<div class="rounded-lg border-2 border-secondary-500/30 bg-[rgb(var(--bg-primary))] p-4">
<h3 class="mb-3 text-lg font-semibold text-[rgb(var(--text-primary))]">Session Details</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<p class="text-xs text-[rgb(var(--text-secondary))]">Started</p>
<p class="font-medium text-[rgb(var(--text-primary))]">
{formatDateTime(sessionDetails.start)}
</p>
</div>
{#if sessionDetails.end}
<div>
<p class="text-xs text-[rgb(var(--text-secondary))]">Ended</p>
<p class="font-medium text-[rgb(var(--text-primary))]">
{formatDateTime(sessionDetails.end)}
</p>
</div>
{/if}
<div>
<p class="text-xs text-[rgb(var(--text-secondary))]">Status</p>
<span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold {sessionDetails.isActive
? 'bg-accent-500/20 text-accent-600 dark:text-accent-400'
: 'bg-secondary-500/20 text-secondary-600 dark:text-secondary-400'}"
>
{sessionDetails.isActive ? 'Active' : 'Closed'}
</span>
</div>
</div>
</div>
<!-- Team Members -->
{#if teamProfiles.length > 0}
<div class="rounded-lg border-2 border-accent-500/30 bg-[rgb(var(--bg-primary))] p-4">
<h3 class="mb-3 text-lg font-semibold text-[rgb(var(--text-primary))]">
Team Members ({teamProfiles.length})
</h3>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each teamProfiles as member (member.id)}
<div
class="flex items-center justify-between rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-3"
>
<p class="font-medium text-[rgb(var(--text-primary))]">{member.fullName}</p>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold {member.role === 'TEAM_LEADER'
? 'bg-primary-500/20 text-primary-600 dark:text-primary-400'
: 'bg-secondary-500/20 text-secondary-600 dark:text-secondary-400'}"
>
{member.role === 'TEAM_LEADER' ? 'TL' : 'TM'}
</span>
</div>
{/each}
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,125 @@
<script lang="ts">
import type { SessionTab, SessionTabsCounts } from './types';
interface Props {
activeTab: SessionTab;
counts: SessionTabsCounts;
hasScope: boolean;
onTabChange: (tab: SessionTab) => void;
}
let { activeTab, counts, hasScope, onTabChange }: Props = $props();
const tabs: SessionTab[] = ['summary', 'tasks', 'media', 'notes'];
function handlePrevTab() {
const currentIndex = tabs.indexOf(activeTab);
const prevIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
onTabChange(tabs[prevIndex]);
}
function handleNextTab() {
const currentIndex = tabs.indexOf(activeTab);
const nextIndex = currentIndex === tabs.length - 1 ? 0 : currentIndex + 1;
onTabChange(tabs[nextIndex]);
}
function getTabLabel(tab: SessionTab): string {
switch (tab) {
case 'summary':
return 'Summary';
case 'tasks':
return hasScope ? `Tasks (${counts.tasks})` : 'Tasks';
case 'media':
return `Media (${counts.photos + counts.videos})`;
case 'notes':
return `Notes (${counts.notes})`;
}
}
</script>
<!-- Mobile: Carousel style -->
<div class="mb-6 border-b border-[rgb(var(--border))] md:hidden">
<div class="flex items-center justify-between px-4 py-2">
<button
onclick={handlePrevTab}
class="text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]"
aria-label="Previous tab"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex-1 text-center font-semibold text-accent-500">
{getTabLabel(activeTab)}
</div>
<button
onclick={handleNextTab}
class="text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]"
aria-label="Next tab"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
<!-- Tablet/Desktop: Regular tabs -->
<div class="mb-6 hidden border-b border-[rgb(var(--border))] md:block">
<div class="flex gap-1">
<button
onclick={() => onTabChange('summary')}
class="relative px-6 py-3 font-medium transition-colors
{activeTab === 'summary'
? 'border-b-2 border-accent-500 text-accent-500'
: 'text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]'}"
>
Summary
</button>
<button
onclick={() => onTabChange('tasks')}
class="relative px-6 py-3 font-medium transition-colors
{activeTab === 'tasks'
? 'border-b-2 border-accent-500 text-accent-500'
: 'text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]'}"
>
Tasks
{#if hasScope}
<span
class="ml-2 rounded-full border border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))] px-2 py-0.5 text-xs"
>
{counts.tasks}
</span>
{/if}
</button>
<button
onclick={() => onTabChange('media')}
class="relative px-6 py-3 font-medium transition-colors
{activeTab === 'media'
? 'border-b-2 border-accent-500 text-accent-500'
: 'text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]'}"
>
Media
<span
class="ml-2 rounded-full border border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))] px-2 py-0.5 text-xs"
>
{counts.photos + counts.videos}
</span>
</button>
<button
onclick={() => onTabChange('notes')}
class="relative px-6 py-3 font-medium transition-colors
{activeTab === 'notes'
? 'border-b-2 border-accent-500 text-accent-500'
: 'text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]'}"
>
Notes
<span
class="ml-2 rounded-full border border-[rgb(var(--border))] bg-[rgb(var(--bg-secondary))] px-2 py-0.5 text-xs"
>
{counts.notes}
</span>
</button>
</div>
</div>

View File

@ -0,0 +1,547 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import type { SessionType, ExtendedTask, AreaWithCompletions, AreaWithTasks } from './types';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
sessionType: SessionType;
isActive: boolean;
hasScope: boolean;
selectedTaskIds: SvelteSet<string>;
completedTasksByArea: AreaWithCompletions[];
readyToSubmitByArea: AreaWithTasks[];
availableTasksCount: number;
tasks: ExtendedTask[];
completedTaskIds: SvelteSet<string>;
areas: { id: string; name: string }[];
isSubmitting: boolean;
submittingTaskId: string | null;
removingTaskId: string | null;
getTeamMemberName: (id: string | null | undefined) => string;
onToggleTask: (taskUuid: string) => void;
onSubmitTask: (taskUuid: string) => Promise<void>;
onRemoveTask: (taskUuid: string) => void;
onRemoveCompletedTask: (taskUuid: string) => Promise<void>;
onSubmitAllTasks: () => Promise<void>;
onClearSelection: () => void;
}
let {
sessionType,
isActive,
hasScope,
selectedTaskIds,
completedTasksByArea,
readyToSubmitByArea,
availableTasksCount,
tasks,
completedTaskIds,
areas,
isSubmitting,
submittingTaskId,
removingTaskId,
getTeamMemberName,
onToggleTask,
onSubmitTask,
onRemoveTask,
onRemoveCompletedTask,
onSubmitAllTasks,
onClearSelection
}: Props = $props();
// State for expandable task areas
let expandedCompletedAreas = new SvelteSet<string>();
let expandedAvailableAreas = new SvelteSet<string>();
let expandedReadyToSubmitAreas = new SvelteSet<string>();
// State for main section collapsibility
let readyToSubmitSectionExpanded = $state(true);
let completedTasksSectionExpanded = $state(true);
let availableTasksSectionExpanded = $state(true);
function toggleCompletedArea(areaId: string) {
if (expandedCompletedAreas.has(areaId)) {
expandedCompletedAreas.delete(areaId);
} else {
expandedCompletedAreas.add(areaId);
}
}
function toggleAvailableArea(areaId: string) {
if (expandedAvailableAreas.has(areaId)) {
expandedAvailableAreas.delete(areaId);
} else {
expandedAvailableAreas.add(areaId);
}
}
function toggleReadyToSubmitArea(areaId: string) {
if (expandedReadyToSubmitAreas.has(areaId)) {
expandedReadyToSubmitAreas.delete(areaId);
} else {
expandedReadyToSubmitAreas.add(areaId);
}
}
// Check if this is a service session (to show frequency/estimated minutes)
let isService = $derived(sessionType === 'service');
// Get badge color based on frequency
function getFrequencyBadgeClass(frequency: string): string {
const freq = frequency.toUpperCase();
if (freq === 'DAILY' || freq === 'PER_VISIT') {
return 'bg-red-500/20 text-red-500';
} else if (freq === 'WEEKLY') {
return 'bg-orange-500/20 text-orange-500';
} else if (freq === 'BIWEEKLY' || freq === 'BI_WEEKLY') {
return 'bg-yellow-500/20 text-yellow-500';
} else if (freq === 'MONTHLY') {
return 'bg-blue-500/20 text-blue-500';
} else if (freq === 'QUARTERLY') {
return 'bg-purple-500/20 text-purple-500';
} else if (freq === 'ANNUALLY' || freq === 'YEARLY') {
return 'bg-green-500/20 text-green-500';
}
return 'bg-[rgb(var(--bg-secondary))] text-[rgb(var(--text-secondary))]';
}
// Format frequency label for display
function formatFrequency(frequency: string): string {
return frequency
.replace(/_/g, ' ')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
</script>
<div class="space-y-6">
<!-- Ready to Submit Section -->
{#if selectedTaskIds.size > 0 && isActive}
<div class="rounded-lg border-2 border-primary-500 bg-[rgb(var(--bg-primary))] p-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-semibold text-[rgb(var(--text-primary))]">
Ready to Submit ({selectedTaskIds.size})
</h3>
<div class="flex items-center gap-3">
<button
onclick={onClearSelection}
class="text-sm text-[rgb(var(--text-secondary))] underline hover:text-[rgb(var(--text-primary))]"
>
Clear all
</button>
<button
onclick={onSubmitAllTasks}
disabled={isSubmitting}
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"
>
{isSubmitting ? 'Submitting...' : `Submit All (${selectedTaskIds.size})`}
</button>
<button
onclick={() => (readyToSubmitSectionExpanded = !readyToSubmitSectionExpanded)}
class="text-[rgb(var(--text-primary))]"
aria-label="Toggle section"
>
<svg
class="h-5 w-5 transition-transform {readyToSubmitSectionExpanded
? 'rotate-180'
: ''}"
viewBox="0 0 24 24"
style="fill: none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
{#if readyToSubmitSectionExpanded}
<div class="space-y-3">
{#each readyToSubmitByArea as { area, tasks: areaTasks } (area.id)}
<div class="overflow-hidden rounded-lg border border-[rgb(var(--border))]">
<button
onclick={() => toggleReadyToSubmitArea(area.id)}
class="flex w-full items-start justify-between bg-[rgb(var(--surface-primary))] p-4 transition-colors hover:bg-[rgb(var(--surface-secondary))]"
>
<span class="flex flex-1 items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-[rgb(var(--text-secondary))] transition-transform {expandedReadyToSubmitAreas.has(
area.id
)
? '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="flex-1 text-left font-medium text-[rgb(var(--text-primary))]"
>{area.name}</span
>
</span>
<span
class="rounded-full bg-primary-500/20 px-3 py-1 text-xs font-medium text-primary-500"
>
{areaTasks.length}
</span>
</button>
{#if expandedReadyToSubmitAreas.has(area.id)}
<div class="space-y-1 bg-[rgb(var(--surface-secondary))] p-3">
{#each areaTasks as task (task.id)}
<div class="rounded-lg bg-[rgb(var(--surface-primary))] p-3">
<div class="flex items-start gap-3">
<button
onclick={() => onRemoveTask(task.uuid)}
class="mt-1 flex-shrink-0 text-[rgb(var(--text-secondary))] hover:text-red-500"
aria-label="Remove task from selection"
>
<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 class="flex-1 text-left">
<p class="font-medium text-[rgb(var(--text-primary))]">
{task.checklistDescription}
</p>
{#if isService}
<div class="mt-1 flex flex-wrap items-center gap-2">
{#if 'frequency' in task && task.frequency}
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {getFrequencyBadgeClass(
task.frequency
)}"
>
{formatFrequency(task.frequency)}
</span>
{/if}
{#if 'estimatedMinutes' in task && task.estimatedMinutes}
<span
class="rounded-full bg-[rgb(var(--bg-secondary))] px-2 py-0.5 text-xs text-[rgb(var(--text-secondary))]"
>
{task.estimatedMinutes} min
</span>
{/if}
</div>
{/if}
</div>
</div>
<div class="mt-3 flex justify-end">
<button
onclick={() => onSubmitTask(task.uuid)}
disabled={submittingTaskId === task.uuid}
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"
>
{submittingTaskId === task.uuid ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Completed Tasks Section -->
{#if completedTasksByArea.length > 0}
<div class="rounded-lg border-2 border-accent-500/30 bg-[rgb(var(--bg-primary))] p-6">
<button
onclick={() => (completedTasksSectionExpanded = !completedTasksSectionExpanded)}
class="mb-4 flex w-full items-center justify-between"
>
<span class="text-xl font-semibold text-[rgb(var(--text-primary))]">
Completed Tasks ({completedTasksByArea.reduce((sum, a) => sum + a.completions.length, 0)})
</span>
<svg
class="h-5 w-5 transition-transform {completedTasksSectionExpanded ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
style="fill: none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if completedTasksSectionExpanded}
<div class="space-y-3">
{#each completedTasksByArea as { area, completions } (area.id)}
<div class="overflow-hidden rounded-lg border border-[rgb(var(--border))]">
<button
onclick={() => toggleCompletedArea(area.id)}
class="flex w-full items-start justify-between bg-[rgb(var(--surface-primary))] p-4 transition-colors hover:bg-[rgb(var(--surface-secondary))]"
>
<span class="flex flex-1 items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-[rgb(var(--text-secondary))] transition-transform {expandedCompletedAreas.has(
area.id
)
? '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="flex-1 text-left font-medium text-[rgb(var(--text-primary))]"
>{area.name}</span
>
</span>
<span
class="rounded-full bg-accent-500/20 px-3 py-1 text-xs font-medium text-accent-500"
>
{completions.length}
</span>
</button>
{#if expandedCompletedAreas.has(area.id)}
<div class="space-y-1 bg-[rgb(var(--surface-secondary))] p-3">
{#each completions as completion (completion.id)}
{@const task = tasks.find((t) => t.uuid === completion.taskId)}
<div class="rounded-lg bg-[rgb(var(--surface-primary))] p-3">
<div class="flex items-start gap-3">
<svg
class="mt-1 h-5 w-5 flex-shrink-0 text-accent-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>
<div class="flex-1">
{#if task}
<p class="font-medium text-[rgb(var(--text-primary))]">
{task.checklistDescription}
</p>
{#if isService}
<div class="mt-1 flex flex-wrap items-center gap-2">
{#if 'frequency' in task && task.frequency}
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {getFrequencyBadgeClass(
task.frequency
)}"
>
{formatFrequency(task.frequency)}
</span>
{/if}
{#if 'estimatedMinutes' in task && task.estimatedMinutes}
<span
class="rounded-full bg-[rgb(var(--bg-secondary))] px-2 py-0.5 text-xs text-[rgb(var(--text-secondary))]"
>
{task.estimatedMinutes} min
</span>
{/if}
</div>
{/if}
{:else}
<p class="text-[rgb(var(--text-secondary))] italic">
Task details not available
</p>
{/if}
<div
class="mt-2 flex flex-col gap-1 text-xs text-[rgb(var(--text-secondary))] md:flex-row md:items-center md:gap-3"
>
<span>Completed: {formatDate(completion.completedAt)}</span>
<span class="hidden md:inline"></span>
<span>By: {getTeamMemberName(completion.completedById)}</span>
</div>
{#if completion.notes}
<p class="mt-1 text-sm text-[rgb(var(--text-secondary))]">
Note: {completion.notes}
</p>
{/if}
</div>
</div>
{#if isActive}
<div class="mt-3 flex justify-end">
<button
onclick={() => onRemoveCompletedTask(completion.taskId)}
disabled={removingTaskId === completion.taskId}
class="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{removingTaskId === completion.taskId ? 'Removing...' : 'Remove'}
</button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Available Tasks Section -->
{#if hasScope && areas.length > 0}
<div class="rounded-lg border-2 border-secondary-500/30 bg-[rgb(var(--bg-primary))] p-6">
<button
onclick={() => (availableTasksSectionExpanded = !availableTasksSectionExpanded)}
class="mb-4 flex w-full items-center justify-between"
>
<span class="text-xl font-semibold text-[rgb(var(--text-primary))]">
Available Tasks ({availableTasksCount})
</span>
<svg
class="h-5 w-5 transition-transform {availableTasksSectionExpanded ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
style="fill: none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if availableTasksSectionExpanded}
{#if !isActive}
<div class="rounded-lg border border-tertiary-500/30 bg-tertiary-500/10 p-4">
<p class="text-sm text-[rgb(var(--text-secondary))]">
This session is closed. Tasks cannot be added or modified.
</p>
</div>
{:else if availableTasksCount === 0}
<div class="rounded-lg border border-accent-500/30 bg-accent-500/10 p-4">
<p class="text-sm text-[rgb(var(--text-secondary))]">
No more tasks to add, good work!
</p>
</div>
{:else}
<div class="space-y-3">
{#each areas as area (area.id)}
{@const availableTasks = tasks.filter(
(t) =>
t.areaId === area.id &&
!completedTaskIds.has(t.uuid) &&
!selectedTaskIds.has(t.uuid)
)}
{#if availableTasks.length > 0}
<div class="overflow-hidden rounded-lg border border-[rgb(var(--border))]">
<button
onclick={() => toggleAvailableArea(area.id)}
class="flex w-full items-start justify-between bg-[rgb(var(--surface-primary))] p-4 transition-colors hover:bg-[rgb(var(--surface-secondary))]"
>
<span class="flex flex-1 items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-[rgb(var(--text-secondary))] transition-transform {expandedAvailableAreas.has(
area.id
)
? '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="flex-1 text-left font-medium text-[rgb(var(--text-primary))]"
>{area.name}</span
>
</span>
<span
class="rounded-full bg-secondary-500/20 px-3 py-1 text-xs font-medium text-secondary-500"
>
{availableTasks.length}
</span>
</button>
{#if expandedAvailableAreas.has(area.id)}
<div class="space-y-1 bg-[rgb(var(--surface-secondary))] p-3">
{#each availableTasks as task (task.id)}
<div
class="cursor-pointer rounded-lg p-3 transition-all hover:bg-[rgb(var(--surface-secondary))]"
onclick={() => onToggleTask(task.uuid)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onToggleTask(task.uuid)}
>
<div class="text-left">
<p class="font-medium text-[rgb(var(--text-primary))]">
{task.checklistDescription}
</p>
{#if isService}
<div class="mt-1 flex flex-wrap items-center gap-2">
{#if 'frequency' in task && task.frequency}
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {getFrequencyBadgeClass(
task.frequency
)}"
>
{formatFrequency(task.frequency)}
</span>
{/if}
{#if 'estimatedMinutes' in task && task.estimatedMinutes}
<span
class="rounded-full bg-[rgb(var(--bg-secondary))] px-2 py-0.5 text-xs text-[rgb(var(--text-secondary))]"
>
{task.estimatedMinutes} min
</span>
{/if}
</div>
{/if}
</div>
<div class="mt-3 flex justify-end">
<button
onclick={(e) => {
e.stopPropagation();
onToggleTask(task.uuid);
}}
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600"
>
Add
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
{/if}
</div>
{:else}
<div class="py-12 text-center">
<p class="text-lg text-[rgb(var(--text-secondary))]">No tasks available for this session</p>
</div>
{/if}
</div>

View File

@ -0,0 +1,212 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import type { SessionVideo } from './types';
import type { SvelteMap } from 'svelte/reactivity';
interface Props {
videos: SessionVideo[];
isActive: boolean;
authenticatedUrls: SvelteMap<string, string>;
getTeamMemberName: (id: string | null | undefined) => string;
onUpdate: (videoId: string, title: string, notes: string) => Promise<void>;
onDelete: (videoId: string) => Promise<void>;
}
let { videos, isActive, authenticatedUrls, getTeamMemberName, onUpdate, onDelete }: Props =
$props();
// State for editing
let editingVideoId = $state<string | null>(null);
let editVideoTitle = $state('');
let editVideoNotes = $state('');
let mediaSubmitting = $state(false);
// State for deleting
let deletingVideoId = $state<string | null>(null);
// Section collapse state
let videosSectionExpanded = $state(true);
function startEditVideo(video: SessionVideo) {
editingVideoId = video.id;
editVideoTitle = video.title;
editVideoNotes = video.notes;
}
function cancelEditVideo() {
editingVideoId = null;
editVideoTitle = '';
editVideoNotes = '';
}
async function handleUpdateVideo(videoId: string) {
if (!editVideoTitle.trim()) return;
try {
mediaSubmitting = true;
await onUpdate(videoId, editVideoTitle.trim(), editVideoNotes.trim());
editingVideoId = null;
editVideoTitle = '';
editVideoNotes = '';
} catch (err) {
console.error('Failed to update video:', err);
} finally {
mediaSubmitting = false;
}
}
async function handleDeleteVideo(videoId: string) {
try {
deletingVideoId = videoId;
await onDelete(videoId);
} catch (err) {
console.error('Failed to delete video:', err);
} finally {
deletingVideoId = null;
}
}
</script>
{#if videos && videos.length > 0}
<div class="rounded-lg border-2 border-secondary-500/30 bg-[rgb(var(--bg-primary))] p-6">
<button
onclick={() => (videosSectionExpanded = !videosSectionExpanded)}
class="mb-4 flex w-full items-center justify-between"
>
<span class="text-xl font-semibold text-[rgb(var(--text-primary))]">
Videos ({videos.length})
</span>
<svg
class="h-5 w-5 transition-transform {videosSectionExpanded ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
style="fill: none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if videosSectionExpanded}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each videos as video (video.id)}
{@const thumbnailUrl = video.thumbnail
? authenticatedUrls.get(video.thumbnail.url)
: null}
<div
class="overflow-hidden rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))]"
>
<div class="relative h-48 bg-black">
{#if thumbnailUrl}
<img src={thumbnailUrl} alt={video.title} class="h-full w-full object-contain" />
{:else if video.thumbnail}
<div class="flex h-full w-full items-center justify-center">
<div class="text-sm text-gray-400">Loading...</div>
</div>
{/if}
<div class="absolute inset-0 flex items-center justify-center">
<a
href={video.video.url}
target="_blank"
rel="noopener noreferrer"
aria-label="Play video: {video.title}"
class="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 transition-colors hover:bg-white"
>
<svg
class="ml-1 h-8 w-8 text-gray-900"
style="fill: currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</a>
</div>
</div>
<div class="p-3">
{#if editingVideoId === video.id}
<!-- Edit Mode -->
<div class="space-y-3">
<input
type="text"
bind:value={editVideoTitle}
placeholder="Title"
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-2 text-sm text-[rgb(var(--text-primary))] placeholder-[rgb(var(--text-secondary))] focus:border-primary-500 focus:outline-none"
/>
<textarea
bind:value={editVideoNotes}
placeholder="Notes (optional)"
rows="2"
class="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-secondary))] p-2 text-sm text-[rgb(var(--text-primary))] placeholder-[rgb(var(--text-secondary))] focus:border-primary-500 focus:outline-none"
></textarea>
<div class="flex justify-end gap-2">
<button
onclick={cancelEditVideo}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-[rgb(var(--text-secondary))] transition-colors hover:text-[rgb(var(--text-primary))]"
>
Cancel
</button>
<button
onclick={() => handleUpdateVideo(video.id)}
disabled={mediaSubmitting || !editVideoTitle.trim()}
class="rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{mediaSubmitting ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{:else}
<!-- View Mode -->
<div class="mb-2 flex items-start justify-between gap-2">
<h3 class="flex-1 text-left font-semibold text-[rgb(var(--text-primary))]">
{video.title}
</h3>
{#if video.internal}
<span
class="flex-shrink-0 rounded bg-tertiary-500/20 px-2 py-0.5 text-xs font-medium text-tertiary-600"
>
Internal
</span>
{/if}
</div>
{#if video.notes}
<p class="mt-1 text-sm text-[rgb(var(--text-secondary))]">{video.notes}</p>
{/if}
<div
class="mt-2 flex flex-col gap-1 text-xs text-[rgb(var(--text-secondary))] md:flex-row md:items-center md:gap-3"
>
<span>{formatDate(video.createdAt)}</span>
<span class="hidden md:inline"></span>
<span>By: {getTeamMemberName(video.uploadedByTeamProfileId)}</span>
{#if video.durationSeconds}
<span class="hidden md:inline"></span>
<span
>{Math.floor(video.durationSeconds / 60)}:{(video.durationSeconds % 60)
.toString()
.padStart(2, '0')}</span
>
{/if}
</div>
{#if isActive}
<div class="mt-3 flex justify-end gap-2">
<button
onclick={() => startEditVideo(video)}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-[rgb(var(--text-secondary))] transition-colors hover:text-[rgb(var(--text-primary))]"
>
Edit
</button>
<button
onclick={() => handleDeleteVideo(video.id)}
disabled={deletingVideoId === video.id}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-red-500 transition-colors hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-50"
>
{deletingVideoId === video.id ? 'Deleting...' : 'Delete'}
</button>
</div>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}

View File

@ -0,0 +1,10 @@
// Session components barrel export
export { default as SessionHeader } from './SessionHeader.svelte';
export { default as SessionTabs } from './SessionTabs.svelte';
export { default as SessionSummaryTab } from './SessionSummaryTab.svelte';
export { default as SessionTasksTab } from './SessionTasksTab.svelte';
export { default as SessionMediaTab } from './SessionMediaTab.svelte';
export { default as SessionNotesTab } from './SessionNotesTab.svelte';
// Types and utilities
export * from './types';

View File

@ -0,0 +1,127 @@
// Session component shared types
import type {
GetServiceSession$result,
GetProjectSession$result,
GetScope$result,
GetProjectScope$result,
GetService$result,
GetProject$result,
GetTeamProfiles$result,
AccountToAddressMap$result
} from '$houdini';
// Session type discriminator
export type SessionType = 'service' | 'project';
// Tab type
export type SessionTab = 'summary' | 'tasks' | 'media' | 'notes';
// Service session types
export type ServiceSession = NonNullable<GetServiceSession$result['serviceSession']>;
export type ServiceSessionPhoto = ServiceSession['photos'][number];
export type ServiceSessionVideo = ServiceSession['videos'][number];
export type ServiceSessionNote = ServiceSession['notes'][number];
// Project session types
export type ProjectSession = NonNullable<GetProjectSession$result['projectSession']>;
export type ProjectSessionPhoto = ProjectSession['photos'][number];
export type ProjectSessionVideo = ProjectSession['videos'][number];
export type ProjectSessionNote = ProjectSession['notes'][number];
// Union types for components that handle both
export type SessionPhoto = ServiceSessionPhoto | ProjectSessionPhoto;
export type SessionVideo = ServiceSessionVideo | ProjectSessionVideo;
export type SessionNote = ServiceSessionNote | ProjectSessionNote;
// Scope types
export type ServiceScope = NonNullable<GetScope$result['scope']>;
export type ServiceArea = ServiceScope['areas'][number];
export type ServiceTask = ServiceArea['tasks'][number];
export type ProjectScope = NonNullable<GetProjectScope$result['projectScope']>;
export type ProjectArea = ProjectScope['projectAreas'][number];
export type ProjectTask = ProjectArea['projectTasks'][number];
// Extended task type with additional fields for both types
export type ExtendedServiceTask = ServiceTask & {
uuid: string;
areaId: string;
areaName: string;
};
export type ExtendedProjectTask = ProjectTask & {
uuid: string;
areaId: string;
areaName: string;
};
export type ExtendedTask = ExtendedServiceTask | ExtendedProjectTask;
// Area with completions (for completed tasks section)
export type ServiceAreaWithCompletions = {
area: ServiceArea;
completions: ServiceSession['completedTasks'];
};
export type ProjectAreaWithCompletions = {
area: ProjectArea;
completions: ProjectSession['completedTasks'];
};
export type AreaWithCompletions = ServiceAreaWithCompletions | ProjectAreaWithCompletions;
// Area with tasks (for ready to submit section)
export type ServiceAreaWithTasks = {
area: ServiceArea;
tasks: ExtendedServiceTask[];
};
export type ProjectAreaWithTasks = {
area: ProjectArea;
tasks: ExtendedProjectTask[];
};
export type AreaWithTasks = ServiceAreaWithTasks | ProjectAreaWithTasks;
// Entity types (service or project)
export type Service = NonNullable<GetService$result['service']>;
export type Project = NonNullable<GetProject$result['project']>;
// Team profile types
export type TeamProfile = NonNullable<GetTeamProfiles$result['teamProfiles']>[number];
// Account and address types
export type Account = NonNullable<AccountToAddressMap$result['accounts']>[number];
export type AccountAddress = NonNullable<Account['addresses']>[number];
// Props interfaces for components
export interface SessionTabsCounts {
tasks: number;
photos: number;
videos: number;
notes: number;
}
// API base URL for authenticated media requests
export const API_BASE_URL = 'https://api.example.com';
// Helper function to format error messages
export function getErrorMessage(errors: { message: string }[] | null | undefined): string {
return errors?.map((e) => e.message).join(', ') ?? '';
}
// Helper to get authenticated image URL
export async function getAuthenticatedImageUrl(url: string): Promise<string> {
try {
const fullUrl = `${API_BASE_URL}${url}`;
const response = await fetch(fullUrl, {
credentials: 'include'
});
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (err) {
console.error('Failed to fetch authenticated image:', err);
return url; // Fallback to original URL
}
}

57
src/lib/graphql/client.ts Normal file
View File

@ -0,0 +1,57 @@
import { HoudiniClient, fetch as houdiniFetch } from '$houdini';
const URL = 'https://api.example.com/graphql/';
type RawGraphQLError = { message?: string; [key: string]: unknown };
type RawGraphQLResponse = {
data?: Record<string, unknown> | null;
errors?: RawGraphQLError[] | null;
} | null;
type FetchContext = {
fetch: typeof fetch;
text: string;
variables?: Record<string, unknown>;
};
export default new HoudiniClient({
url: URL,
plugins: [
houdiniFetch(async ({ fetch, text, variables }: FetchContext) => {
const res = await fetch(URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include', // Send cookies with request
body: JSON.stringify({ query: text, variables })
});
let json: RawGraphQLResponse = null;
try {
json = (await res.json()) as RawGraphQLResponse;
} catch (parseError) {
console.warn('Failed to parse JSON response:', parseError);
}
// Normalize response for Houdini
if (json && (json.data !== undefined || json.errors !== undefined)) {
const normalizedErrors =
json.errors?.map((e) => ({
message: typeof e?.message === 'string' ? e.message : 'Unknown error'
})) ?? null;
return {
data: (json.data ?? null) as Record<string, unknown> | null,
errors: normalizedErrors
};
}
const fallbackError =
res && res.status >= 400
? [{ message: `HTTP ${res.status}: ${res.statusText}` }]
: [{ message: 'Network error' }];
return { data: null, errors: fallbackError };
})
]
});

View File

@ -0,0 +1,6 @@
mutation ArchiveConversation($input: ArchiveConversationInput!) {
archiveConversation(input: $input) {
id
isArchived
}
}

View File

@ -0,0 +1,23 @@
mutation CreateConversation($input: ConversationInput!) {
createConversation(input: $input) {
id
subject
conversationType
createdAt
participants {
id
participant {
teamProfile {
id
firstName
lastName
}
customerProfile {
id
firstName
lastName
}
}
}
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteConversation($id: ID!) {
deleteConversation(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation DeleteMessage($id: ID!) {
deleteMessage(id: $id)
}

View File

@ -0,0 +1,6 @@
mutation MarkConversationAsRead($input: MarkAsReadInput!) {
markConversationAsRead(input: $input) {
id
unreadCount
}
}

View File

@ -0,0 +1,5 @@
mutation MuteConversation($input: MuteConversationInput!) {
muteConversation(input: $input) {
id
}
}

View File

@ -0,0 +1,23 @@
mutation SendMessage($input: MessageInput!) {
sendMessage(input: $input) {
id
body
attachments
metadata
isSystemMessage
createdAt
replyToId
sender {
teamProfile {
id
firstName
lastName
}
customerProfile {
id
firstName
lastName
}
}
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteNotification($id: ID!) {
deleteNotification(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation MarkAllNotificationsAsRead {
markAllNotificationsAsRead
}

View File

@ -0,0 +1,7 @@
mutation MarkNotificationAsRead($id: ID!) {
markNotificationAsRead(id: $id) {
id
isRead
readAt
}
}

View File

@ -0,0 +1,14 @@
mutation UpdateTeamProfile($input: TeamProfileUpdateInput!) {
updateTeamProfile(input: $input) {
id
role
firstName
lastName
fullName
email
phone
status
notes
oryKratosId
}
}

View File

@ -0,0 +1,17 @@
mutation CloseProjectSession($input: ProjectSessionCloseInput!) {
closeProjectSession(input: $input) {
id
projectId
accountId
accountAddressId
customerId
scopeId
start
end
isActive
durationSeconds
date
createdById
closedById
}
}

View File

@ -0,0 +1,25 @@
mutation CloseServiceSession($input: CloseServiceSessionInput!) {
closeServiceSession(input: $input) {
id
serviceId
accountId
accountAddressId
customerId
scopeId
start
end
isActive
durationSeconds
completedTasks {
id
taskId
serviceId
completedAt
completedById
notes
accountAddressId
month
year
}
}
}

View File

@ -0,0 +1,8 @@
mutation OpenProjectSession($input: ProjectSessionStartInput!) {
openProjectSession(input: $input) {
id
projectId
scopeId
customerId
}
}

View File

@ -0,0 +1,8 @@
mutation OpenServiceSession($input: OpenServiceSessionInput!) {
openServiceSession(input: $input) {
id
serviceId
scopeId
customerId
}
}

View File

@ -0,0 +1,3 @@
mutation RemoveProjectPhoto($id: ID!) {
deleteProjectSessionImage(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation RemoveProjectVideo($id: ID!) {
deleteProjectSessionVideo(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation RemoveServicePhoto($id: ID!) {
deleteServiceSessionImage(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation RemoveServiceVideo($id: ID!) {
deleteServiceSessionVideo(id: $id)
}

View File

@ -0,0 +1,30 @@
mutation UpdateProjectPhoto($input: ProjectSessionImageUpdateInput!) {
updateProjectSessionImage(input: $input) {
id
projectSessionId
title
notes
createdAt
contentType
width
height
uploadedByTeamProfileId
image {
url
width
height
name
size
path
}
thumbnail {
url
width
height
name
size
path
}
internal
}
}

View File

@ -0,0 +1,30 @@
mutation UpdateProjectVideo($input: ProjectSessionVideoUpdateInput!) {
updateProjectSessionVideo(input: $input) {
id
title
notes
createdAt
contentType
width
height
durationSeconds
fileSizeBytes
uploadedByTeamProfileId
projectSessionId
video {
url
name
size
path
}
thumbnail {
url
width
height
name
size
path
}
internal
}
}

View File

@ -0,0 +1,30 @@
mutation UpdateServicePhoto($input: ServiceSessionImageUpdateInput!) {
updateServiceSessionImage(input: $input) {
id
serviceSessionId
title
notes
createdAt
contentType
width
height
uploadedByTeamProfileId
image {
url
width
height
name
size
path
}
thumbnail {
url
width
height
name
size
path
}
internal
}
}

View File

@ -0,0 +1,30 @@
mutation UpdateServiceVideo($input: ServiceSessionVideoUpdateInput!) {
updateServiceSessionVideo(input: $input) {
id
title
notes
createdAt
contentType
width
height
durationSeconds
fileSizeBytes
uploadedByTeamProfileId
serviceSessionId
video {
url
name
size
path
}
thumbnail {
url
width
height
name
size
path
}
internal
}
}

View File

@ -0,0 +1,11 @@
mutation CreateProjectNote($input: ProjectSessionNoteInput!) {
createProjectSessionNote(input: $input) {
id
sessionId
content
authorId
internal
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
mutation CreateServiceNote($input: ServiceSessionNoteInput!) {
createServiceSessionNote(input: $input) {
id
sessionId
content
authorId
internal
createdAt
updatedAt
}
}

View File

@ -0,0 +1,3 @@
mutation RemoveProjectNote($id: ID!) {
deleteProjectSessionNote(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation RemoveServiceNote($id: ID!) {
deleteServiceSessionNote(id: $id)
}

View File

@ -0,0 +1,11 @@
mutation UpdateProjectNote($input: ProjectSessionNoteUpdateInput!) {
updateProjectSessionNote(input: $input) {
id
sessionId
content
authorId
internal
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
mutation UpdateServiceNote($input: ServiceSessionNoteUpdateInput!) {
updateServiceSessionNote(input: $input) {
id
sessionId
content
authorId
internal
createdAt
updatedAt
}
}

View File

@ -0,0 +1,26 @@
mutation AddProjectTask($projectId: ID!, $taskId: ID!, $notes: String) {
addProjectTaskCompletion(projectId: $projectId, taskId: $taskId, notes: $notes) {
id
projectId
accountId
accountAddressId
customerId
scopeId
start
end
isActive
durationSeconds
date
createdById
closedById
completedTasks {
id
taskId
projectId
completedAt
completedById
notes
accountAddressId
}
}
}

View File

@ -0,0 +1,25 @@
mutation AddServiceTask($serviceId: ID!, $taskId: ID!, $notes: String) {
addTaskCompletion(serviceId: $serviceId, taskId: $taskId, notes: $notes) {
id
serviceId
accountId
accountAddressId
customerId
scopeId
start
end
isActive
durationSeconds
completedTasks {
id
taskId
serviceId
completedAt
completedById
notes
accountAddressId
month
year
}
}
}

View File

@ -0,0 +1,26 @@
mutation RemoveProjectTask($projectId: ID!, $taskId: ID!) {
removeProjectTaskCompletion(projectId: $projectId, taskId: $taskId) {
id
projectId
accountId
accountAddressId
customerId
scopeId
start
end
isActive
durationSeconds
date
createdById
closedById
completedTasks {
id
taskId
projectId
completedAt
completedById
notes
accountAddressId
}
}
}

View File

@ -0,0 +1,25 @@
mutation RemoveServiceTask($serviceId: ID!, $taskId: ID!) {
removeTaskCompletion(serviceId: $serviceId, taskId: $taskId) {
id
serviceId
accountId
accountAddressId
customerId
scopeId
start
end
isActive
durationSeconds
completedTasks {
id
taskId
serviceId
completedAt
completedById
notes
accountAddressId
month
year
}
}
}

View File

@ -0,0 +1,15 @@
query AccountToAddressMap($filters: AccountFilter) {
accounts(filters: $filters) {
id
customerId
name
addresses {
id
name
streetAddress
city
state
zipCode
}
}
}

View File

@ -0,0 +1,91 @@
query GetAccount($id: ID!) {
account(id: $id) {
id
customerId
name
startDate
endDate
status
isActive
primaryAddress {
id
streetAddress
city
state
zipCode
isActive
isPrimary
notes
}
addresses {
id
streetAddress
name
city
state
zipCode
isActive
isPrimary
notes
schedules {
id
accountAddressId
name
startDate
endDate
sundayService
mondayService
tuesdayService
wednesdayService
thursdayService
fridayService
saturdayService
weekendService
scheduleException
}
labors {
id
accountAddressId
startDate
endDate
amount
}
scopes {
id
name
accountId
accountAddressId
name
description
isActive
areas {
id
name
order
scopeId
tasks {
id
areaId
description
checklistDescription
estimatedMinutes
frequency
isConditional
order
}
}
}
}
contacts {
id
firstName
lastName
fullName
email
phone
isActive
isPrimary
notes
}
}
}

View File

@ -0,0 +1,24 @@
query GetAccounts($filters: AccountFilter) {
accounts(filters: $filters) {
id
name
startDate
endDate
status
isActive
primaryAddress {
id
streetAddress
city
state
zipCode
}
addresses {
id
streetAddress
city
state
zipCode
}
}
}

View File

@ -0,0 +1,45 @@
query GetAccountAddress($id: ID!) {
accountAddress(id: $id) {
id
accountId
streetAddress
name
city
state
zipCode
isActive
isPrimary
notes
labors {
id
amount
startDate
endDate
}
scopes {
id
name
accountId
accountAddressId
name
description
isActive
areas {
id
name
order
scopeId
tasks {
id
areaId
description
checklistDescription
estimatedMinutes
frequency
isConditional
order
}
}
}
}
}

View File

@ -0,0 +1,6 @@
query GetCustomer($id: ID!) {
customer(id: $id) {
id
name
}
}

View File

@ -0,0 +1,6 @@
query GetCustomers {
customers {
id
name
}
}

View File

@ -0,0 +1,92 @@
query GetConversation($id: ID!, $messageLimit: Int = 50, $messageOffset: Int = 0) {
conversation(id: $id) {
id
subject
conversationType
createdAt
unreadCount
isArchived
createdBy {
teamProfile {
id
fullName
}
customerProfile {
id
fullName
}
}
entity {
entityType
entityId
project {
id
name
}
service {
id
date
}
account {
id
name
}
customer {
id
name
}
}
participants {
participant {
teamProfile {
id
fullName
}
customerProfile {
id
fullName
}
}
lastReadAt
isArchived
isMuted
}
messages(limit: $messageLimit, offset: $messageOffset) {
id
body
createdAt
canDelete
isSystemMessage
sender {
teamProfile {
id
fullName
}
customerProfile {
id
fullName
}
}
replyTo {
id
body
sender {
teamProfile {
fullName
}
customerProfile {
fullName
}
}
}
attachments
}
}
}

View File

@ -0,0 +1,62 @@
query GetMyConversations($includeArchived: Boolean = false) {
getMyConversations(includeArchived: $includeArchived) {
edges {
node {
id
subject
conversationType
lastMessageAt
unreadCount
entity {
entityType
entityId
project {
id
name
}
service {
id
date
}
account {
id
name
}
customer {
id
name
}
}
messages(limit: 1) {
body
createdAt
sender {
teamProfile {
id
fullName
}
customerProfile {
id
fullName
}
}
}
participants {
participant {
teamProfile {
id
fullName
}
customerProfile {
id
fullName
}
}
}
}
}
}
}

View File

@ -0,0 +1,3 @@
query GetUnreadMessageCount {
unreadMessageCount
}

View File

@ -0,0 +1,24 @@
query GetMyNotifications($unreadOnly: Boolean = false, $limit: Int = 50, $offset: Int = 0) {
myNotifications(unreadOnly: $unreadOnly, limit: $limit, offset: $offset) {
id
subject
body
actionUrl
createdAt
readAt
isRead
status
metadata
event {
id
eventType
entityId
entityType
createdAt
}
rule {
id
name
}
}
}

View File

@ -0,0 +1,24 @@
query GetNotification($id: ID!) {
notification(id: $id) {
id
subject
body
actionUrl
createdAt
readAt
isRead
status
metadata
event {
id
eventType
entityId
entityType
createdAt
}
rule {
id
name
}
}
}

View File

@ -0,0 +1,3 @@
query GetUnreadNotificationCount {
myUnreadNotificationCount
}

View File

@ -0,0 +1,21 @@
query GetProject($id: ID!) {
project(id: $id) {
id
customerId
accountAddressId
scopeId
name
amount
labor
date
status
notes
city
state
streetAddress
zipCode
teamMembers {
pk
}
}
}

View File

@ -0,0 +1,45 @@
query GetProjectsByTeamMember(
$teamProfileId: ID!
$first: Int
$after: String
$last: Int
$before: String
$filters: ProjectFilter
$ordering: DateOrdering
) {
getProjectsByTeamMember(
teamProfileId: $teamProfileId
first: $first
after: $after
last: $last
before: $before
filters: $filters
ordering: $ordering
) {
edges {
node {
id
name
date
status
accountAddressId
scopeId
city
state
zipCode
streetAddress
notes
teamMembers {
pk
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}

View File

@ -0,0 +1,44 @@
query GetReport($id: ID!) {
report(id: $id) {
id
date
teamMemberId
projectsLaborTotal
servicesLaborTotal
totalLaborValue
laborBreakdown {
grandTotal
projectsTotal
servicesTotal
teamMemberId
teamMemberName
services {
serviceId
isTeamMemberAssigned
teamMemberCount
totalLaborRate
laborShare
}
projects {
projectId
projectName
isTeamMemberAssigned
teamMemberCount
totalLaborAmount
laborShare
}
}
services {
id
accountAddressId
date
}
projects {
id
name
accountAddressId
customerId
date
}
}
}

View File

@ -0,0 +1,10 @@
query GetReports($filters: ReportFilter) {
reports(filters: $filters) {
id
date
teamMemberId
projectsLaborTotal
servicesLaborTotal
totalLaborValue
}
}

View File

@ -0,0 +1,23 @@
query GetProjectScope($id: ID!) {
projectScope(id: $id) {
id
accountAddressId
accountId
description
isActive
name
projectId
projectAreas {
id
name
scopeId
order
projectTasks {
id
description
checklistDescription
order
}
}
}
}

View File

@ -0,0 +1,26 @@
query GetScope($id: ID!) {
scope(id: $id) {
id
accountId
accountAddressId
name
description
isActive
areas {
id
name
order
scopeId
tasks {
id
areaId
description
checklistDescription
estimatedMinutes
frequency
isConditional
order
}
}
}
}

View File

@ -0,0 +1,13 @@
query GetService($id: ID!) {
service(id: $id) {
id
accountId
accountAddressId
date
status
notes
teamMembers {
pk
}
}
}

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