commit 1324f9259fa23be003baa29df6f96dd5b78a1d77 Author: Damien Coles Date: Mon Jan 26 11:28:04 2026 -0500 public-ready-init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e4a2a21 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.git +.gitignore +.houdini +.svelte-kit +build +dist +*.log +*.md +.env* +!.env.example +.vscode +.idea diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..22c33ad --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b99eb9 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.graphqlrc.yaml b/.graphqlrc.yaml new file mode 100644 index 0000000..5eb640e --- /dev/null +++ b/.graphqlrc.yaml @@ -0,0 +1,9 @@ +projects: + default: + schema: + - ./schema.graphql + - ./.houdini/graphql/schema.graphql + documents: + - '**/*.gql' + - '**/*.svelte' + - ./.houdini/graphql/documents.gql diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..408263d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ + +# Generated files +schema.graphql diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8103a0b --- /dev/null +++ b/.prettierrc @@ -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" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4b477e1 --- /dev/null +++ b/CLAUDE.md @@ -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 + + +``` + +✅ **Correct**: + +```svelte + + +``` + +**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 `
` inside ` +``` + +✅ **Correct**: + +```svelte + +``` + +**Valid phrasing content elements** (safe to use in buttons): + +- ``, ``, ``, ``, ``, ``, `` +- ``, ``, ``, ``, ``, ``, `` + +**Invalid block elements** (never use in buttons): + +- `
`, `

`, ``, `

    `, `
      `, `
      `, `
      ` + +**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(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 + +
      + {#each items as item} + + + + {/each} +
      + + + +``` + +**Clickable Table Rows:** + +Make entire rows clickable by wrapping each cell's content in anchor tags: + +```svelte + + + + {name} + + + + + {otherValue} + + + +``` + +## 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..457bca2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b2140f --- /dev/null +++ b/README.md @@ -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 + +``` + +### Mobile-First Design + +Optimized for mobile devices with responsive layouts: + +```svelte + +
      + {#each items as item} + + {/each} +
      + + + +
+ {:else} +
+

No labor breakdown available

+
+ {/if} +
+ {:else} +
Report not found
+ {/if} + + diff --git a/src/routes/reports/[report]/+page.ts b/src/routes/reports/[report]/+page.ts new file mode 100644 index 0000000..d785321 --- /dev/null +++ b/src/routes/reports/[report]/+page.ts @@ -0,0 +1,15 @@ +import { load_GetReport, load_AccountToAddressMap, load_GetCustomers } from '$houdini'; + +export const load = async (event) => { + const [report, accountToAddressMap, customers] = await Promise.all([ + load_GetReport({ event, variables: { id: event.params.report } }), + load_AccountToAddressMap({ event }), + load_GetCustomers({ event }) + ]); + + return { + ...report, + accountToAddressMap, + customers + }; +}; diff --git a/src/routes/services/+layout.ts b/src/routes/services/+layout.ts new file mode 100644 index 0000000..73fed09 --- /dev/null +++ b/src/routes/services/+layout.ts @@ -0,0 +1,15 @@ +import type { LayoutLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { checkSession } from '$lib/auth'; + +export const load: LayoutLoad = async ({ url, fetch }) => { + // Check authentication using SvelteKit's fetch + const session = await checkSession(fetch); + if (!session || !session.active) { + // Preserve the original URL so we can redirect back after login + const returnTo = encodeURIComponent(url.pathname + url.search); + throw redirect(307, `/login?return_to=${returnTo}`); + } + + return { session }; +}; diff --git a/src/routes/services/+page.svelte b/src/routes/services/+page.svelte new file mode 100644 index 0000000..8292972 --- /dev/null +++ b/src/routes/services/+page.svelte @@ -0,0 +1,538 @@ + + + +
+
+

+ Services +

+ + +
+
+ + + + +
+ +
+ + + + +
+ + {#if page.url.searchParams.has('month') || page.url.searchParams.has('year')} + + {/if} +
+
+ + {#if loading} +
+
+
+

Loading services...

+
+
+ {:else if navigatingToSession} +
+
+
+

Opening session...

+
+
+ {:else if error} +
+
+ + + +
+

Error Loading Services

+

{error?.[0]?.message}

+
+
+
+ {:else if hasAnyServices} + + + +
+ + {#if activeTab === 'scheduled'} + {#if scheduledItems.length > 0} +
+ {#each scheduledItems as service (service.id)} + {@const addressInfo = service.accountAddressId + ? accountAddressMap[service.accountAddressId] + : null} + handleStartSession(service) + }} + /> + {/each} +
+ + {:else} +
+

No scheduled services found.

+
+ {/if} + {/if} + + + {#if activeTab === 'in_progress'} + {#if inProgressItems.length > 0} +
+ {#each inProgressItems as service (service.id)} + {@const addressInfo = service.accountAddressId + ? accountAddressMap[service.accountAddressId] + : null} + handleOpenSession(service) + }} + /> + {/each} +
+ {:else} +
+

No services in progress.

+
+ {/if} + {/if} + + + {#if activeTab === 'completed'} + {#if completedItems.length > 0} +
+ {#each completedItems as service (service.id)} + {@const addressInfo = service.accountAddressId + ? accountAddressMap[service.accountAddressId] + : null} + handleViewCompletedSession(service) + }} + /> + {/each} +
+ + {:else} +
+

No completed services found.

+
+ {/if} + {/if} +
+ {:else} +
+

No services found.

+
+ {/if} +
+
diff --git a/src/routes/services/+page.ts b/src/routes/services/+page.ts new file mode 100644 index 0000000..72ab5fe --- /dev/null +++ b/src/routes/services/+page.ts @@ -0,0 +1,130 @@ +import { ServiceChoices, DateOrdering, type ServiceFilter } from '$houdini'; +import { + load_GetServicesByTeamMember, + load_AccountToAddressMap, + load_GetAccountAddress, + load_ActiveServiceSession, + load_LatestCompletedServiceSession +} from '$houdini'; +import { toGlobalId } from '$lib/utils/relay'; +import { error } from '@sveltejs/kit'; + +const PAGE_SIZE = 20; + +export const load = async (event) => { + // Get session from parent layout + const { session } = await event.parent(); + + if (!session?.identity?.metadata_public?.django_profile_id) { + throw error(400, 'No profile ID found in session'); + } + + // Pass the UUID directly - backend expects raw UUID, not global ID + const teamProfileId = session.identity.metadata_public.django_profile_id; + + // Get month and year from URL params, default to the current month/year + const now = new Date(); + const month = event.url.searchParams.get('month') || String(now.getMonth() + 1); + const year = event.url.searchParams.get('year') || String(now.getFullYear()); + + // Base date filter (always filter by month/year) + const dateFilter = { + month: { exact: parseInt(month) }, + year: { exact: parseInt(year) } + }; + + // Scheduled services filter: status NOT in [COMPLETED, IN_PROGRESS], ascending order + const scheduledFilters: ServiceFilter = { + date: dateFilter, + status: { inList: [ServiceChoices.SCHEDULED, ServiceChoices.CANCELLED] } + }; + + // In Progress services filter: status = IN_PROGRESS (no pagination needed) + const inProgressFilters: ServiceFilter = { + date: dateFilter, + status: { exact: ServiceChoices.IN_PROGRESS } + }; + + // Completed services filter: status = COMPLETED, descending order + const completedFilters: ServiceFilter = { + date: dateFilter, + status: { exact: ServiceChoices.COMPLETED } + }; + + // Load services with per-tab filters and pagination (initial page only) + const [scheduledServices, inProgressServices, completedServices] = await Promise.all([ + load_GetServicesByTeamMember({ + event, + variables: { + teamProfileId, + first: PAGE_SIZE, + filters: scheduledFilters, + ordering: DateOrdering.ASC + } + }), + load_GetServicesByTeamMember({ + event, + variables: { + teamProfileId, + filters: inProgressFilters, + ordering: DateOrdering.ASC + } + }), + load_GetServicesByTeamMember({ + event, + variables: { + teamProfileId, + first: PAGE_SIZE, + filters: completedFilters, + ordering: DateOrdering.DESC + } + }) + ]); + + // Load account to the address map + const accountToAddressMap = await load_AccountToAddressMap({ event }); + + // Load address if accountAddressId is in URL params + const accountAddressId = event.url.searchParams.get('address'); + let address; + if (accountAddressId) { + address = await load_GetAccountAddress({ + event, + variables: { id: toGlobalId('AccountAddressType', accountAddressId) } + }); + } + + // Load active session if serviceId is in URL params + const serviceId = event.url.searchParams.get('serviceId'); + let activeSession; + if (serviceId) { + activeSession = await load_ActiveServiceSession({ + event, + variables: { serviceId } + }); + } + + // Load latest completed session if completedServiceId is in URL params + const completedServiceId = event.url.searchParams.get('completedServiceId'); + let completedSession; + if (completedServiceId) { + completedSession = await load_LatestCompletedServiceSession({ + event, + variables: { serviceId: completedServiceId } + }); + } + + return { + scheduledServices, + inProgressServices, + completedServices, + accountToAddressMap, + address, + activeSession, + completedSession, + // Pass these for client-side load more + teamProfileId, + dateFilter, + pageSize: PAGE_SIZE + }; +}; diff --git a/src/routes/services/[service]/+page.svelte b/src/routes/services/[service]/+page.svelte new file mode 100644 index 0000000..fc2d509 --- /dev/null +++ b/src/routes/services/[service]/+page.svelte @@ -0,0 +1,384 @@ + + + +
+ {#if loading} +
+
+
+

Loading service details...

+
+
+ {:else if navigatingToSession} +
+
+
+

Opening session...

+
+
+ {:else if error} +
+
+ + + +
+

Error Loading Service

+

{error?.[0]?.message}

+
+
+
+ {:else if $service.data?.service} + {@const svc = $service.data.service} + + + + + +
+

+ Service {formatDate(svc.date)} +

+
+ +
+ +
+ +
+
+

Service Information

+ + + {#if svc.status === 'IN_PROGRESS'} + + {:else if svc.status === 'COMPLETED'} + + {/if} +
+ +
+ + {#if svc.accountAddressId && accountAddressMap[svc.accountAddressId]} +
+

Account

+

+ {accountAddressMap[svc.accountAddressId]} +

+
+ {/if} + + +
+
+

Date

+

+ {formatDate(svc.date)} +

+
+
+

Status

+ +
+ {#if $addressData?.data?.accountAddress?.labors?.length} + {@const currentLabor = + $addressData.data.accountAddress.labors.find((l) => { + const today = new Date().toISOString().split('T')[0]; + const startOk = !l.startDate || l.startDate <= today; + const endOk = !l.endDate || l.endDate >= today; + return startOk && endOk; + }) || $addressData.data.accountAddress.labors[0]} +
+

Labor

+

+ ${currentLabor.amount ? parseFloat(currentLabor.amount).toFixed(2) : '0.00'} +

+
+ {/if} +
+ + + = 2 ? getGroupMessageUrl() : undefined} + /> + + + {#if svc.notes} +
+

Notes

+

+ {svc.notes} +

+
+ {/if} +
+
+ + + {#if svc.accountAddressId && addressData && $addressData?.data?.accountAddress} + {@const addr = $addressData.data.accountAddress} +
+

Service Address

+ +
+ {#if addr.name} +

{addr.name}

+ {/if} +

{addr.streetAddress}

+

+ {addr.city}, {addr.state} + {addr.zipCode} +

+
+ + + +
+ {/if} +
+ + + +
+ {:else} +
+

Service not found.

+
+ {/if} +
+
diff --git a/src/routes/services/[service]/+page.ts b/src/routes/services/[service]/+page.ts new file mode 100644 index 0000000..bcde27e --- /dev/null +++ b/src/routes/services/[service]/+page.ts @@ -0,0 +1,58 @@ +import { + load_GetAccountAddress, + load_GetService, + load_GetTeamProfiles, + load_AccountToAddressMap, + load_ActiveServiceSession, + load_LatestCompletedServiceSession +} from '$houdini'; +import { toGlobalId } from '$lib/utils/relay'; + +export const load = async (event) => { + const service = await load_GetService({ event, variables: { id: event.params.service } }); + + // Load address if accountAddressId is in URL params + const accountAddressId = event.url.searchParams.get('addressId'); + let address; + if (accountAddressId) { + address = await load_GetAccountAddress({ + event, + variables: { id: toGlobalId('AccountAddressType', accountAddressId) } + }); + } + + // Load team members + const teamProfiles = await load_GetTeamProfiles({ event }); + + // Load account to address map + const accountToAddressMap = await load_AccountToAddressMap({ event }); + + // Load active session if openSession param is set (for IN_PROGRESS services) + const openSessionId = event.url.searchParams.get('openSession'); + let activeSession; + if (openSessionId) { + activeSession = await load_ActiveServiceSession({ + event, + variables: { serviceId: openSessionId } + }); + } + + // Load latest completed session if viewSession param is set (for COMPLETED services) + const viewSessionId = event.url.searchParams.get('viewSession'); + let completedSession; + if (viewSessionId) { + completedSession = await load_LatestCompletedServiceSession({ + event, + variables: { serviceId: viewSessionId } + }); + } + + return { + ...service, + address, + teamProfiles, + accountToAddressMap, + activeSession, + completedSession + }; +}; diff --git a/src/routes/sessions/projects/[session]/+page.svelte b/src/routes/sessions/projects/[session]/+page.svelte new file mode 100644 index 0000000..7e6cf31 --- /dev/null +++ b/src/routes/sessions/projects/[session]/+page.svelte @@ -0,0 +1,492 @@ + + + + {#if loading} +
+
+
+ Loading... +
+
+ {:else if error} +
+
+

Error loading session

+

+ {getErrorMessage(error)} +

+
+
+ {:else if session} + 0} + {isSubmitting} + onClose={handleCloseSession} + /> + + (activeTab = tab)} + /> + + {#if activeTab === 'summary'} + + {:else if activeTab === 'tasks'} + selectedTaskIds.clear()} + /> + {:else if activeTab === 'media'} + + {:else if activeTab === 'notes'} + + {/if} + {:else} +
+
+

Session not found

+
+
+ {/if} +
diff --git a/src/routes/sessions/projects/[session]/+page.ts b/src/routes/sessions/projects/[session]/+page.ts new file mode 100644 index 0000000..d330a32 --- /dev/null +++ b/src/routes/sessions/projects/[session]/+page.ts @@ -0,0 +1,63 @@ +import { + load_GetProjectSession, + load_GetTeamProfiles, + load_GetProject, + load_AccountToAddressMap, + load_GetProjectScope, + load_GetCustomer +} from '$houdini'; +import { toGlobalId } from '$lib/utils/relay'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async (event) => { + const { session } = event.params; + const projectId = event.url.searchParams.get('projectId'); + const scopeId = event.url.searchParams.get('scopeId'); + const customerId = event.url.searchParams.get('customerId'); + + // Load base queries in parallel + const [sessionResult, teamMembersResult, accountsResult] = await Promise.all([ + load_GetProjectSession({ + event, + variables: { id: session } + }), + load_GetTeamProfiles({ event }), + load_AccountToAddressMap({ event }) + ]); + + // Optionally load project if projectId is provided + let projectResult; + if (projectId) { + projectResult = await load_GetProject({ + event, + variables: { id: toGlobalId('ProjectType', projectId) } + }); + } + + // Load scope if scopeId is provided + let scopeResult; + if (scopeId) { + scopeResult = await load_GetProjectScope({ + event, + variables: { id: toGlobalId('ProjectScopeType', scopeId) } + }); + } + + // Load customer if customerId is provided + let customerResult; + if (customerId) { + customerResult = await load_GetCustomer({ + event, + variables: { id: toGlobalId('CustomerType', customerId) } + }); + } + + return { + ...sessionResult, + ...teamMembersResult, + ...accountsResult, + ...(projectResult || {}), + ...(scopeResult || {}), + ...(customerResult || {}) + }; +}; diff --git a/src/routes/sessions/services/[session]/+page.svelte b/src/routes/sessions/services/[session]/+page.svelte new file mode 100644 index 0000000..7c17743 --- /dev/null +++ b/src/routes/sessions/services/[session]/+page.svelte @@ -0,0 +1,477 @@ + + + + {#if loading} +
+
+
+ Loading... +
+
+ {:else if error} +
+
+

Error loading session

+

+ {getErrorMessage(error)} +

+
+
+ {:else if session} + 0} + {isSubmitting} + onClose={handleCloseSession} + /> + + (activeTab = tab)} + /> + + {#if activeTab === 'summary'} + + {:else if activeTab === 'tasks'} + selectedTaskIds.clear()} + /> + {:else if activeTab === 'media'} + + {:else if activeTab === 'notes'} + + {/if} + {:else} +
+
+

Session not found

+
+
+ {/if} +
diff --git a/src/routes/sessions/services/[session]/+page.ts b/src/routes/sessions/services/[session]/+page.ts new file mode 100644 index 0000000..665b858 --- /dev/null +++ b/src/routes/sessions/services/[session]/+page.ts @@ -0,0 +1,63 @@ +import { + load_GetServiceSession, + load_GetTeamProfiles, + load_GetService, + load_AccountToAddressMap, + load_GetScope, + load_GetCustomer +} from '$houdini'; +import { toGlobalId } from '$lib/utils/relay'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async (event) => { + const { session } = event.params; + const serviceId = event.url.searchParams.get('serviceId'); + const scopeId = event.url.searchParams.get('scopeId'); + const customerId = event.url.searchParams.get('customerId'); + + // Load base queries in parallel + const [sessionResult, teamMembersResult, accountsResult] = await Promise.all([ + load_GetServiceSession({ + event, + variables: { id: session } + }), + load_GetTeamProfiles({ event }), + load_AccountToAddressMap({ event }) + ]); + + // Optionally load service if serviceId is provided + let serviceResult; + if (serviceId) { + serviceResult = await load_GetService({ + event, + variables: { id: toGlobalId('ServiceType', serviceId) } + }); + } + + // Load scope if scopeId is provided + let scopeResult; + if (scopeId) { + scopeResult = await load_GetScope({ + event, + variables: { id: toGlobalId('ScopeType', scopeId) } + }); + } + + // Load customer if customerId is provided + let customerResult; + if (customerId) { + customerResult = await load_GetCustomer({ + event, + variables: { id: toGlobalId('CustomerType', customerId) } + }); + } + + return { + ...sessionResult, + ...teamMembersResult, + ...accountsResult, + ...(serviceResult || {}), + ...(scopeResult || {}), + ...(customerResult || {}) + }; +}; diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..b113b00 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + + alias: { + $houdini: '.houdini/' + } + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..338445d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "rootDirs": [".", "./.svelte-kit/types", "./.houdini/types"] + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..0c3061b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,13 @@ +import houdini from 'houdini/vite'; +import devtoolsJson from 'vite-plugin-devtools-json'; +import tailwindcss from '@tailwindcss/vite'; +import mkcert from 'vite-plugin-mkcert'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + host: 'local.example.com' + }, + plugins: [houdini(), tailwindcss(), sveltekit(), devtoolsJson(), mkcert()] +});