public-ready-init
This commit is contained in:
commit
1324f9259f
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
19
.env.example
Normal 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
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
.houdini
|
||||
.idea
|
||||
9
.graphqlrc.yaml
Normal file
9
.graphqlrc.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
projects:
|
||||
default:
|
||||
schema:
|
||||
- ./schema.graphql
|
||||
- ./.houdini/graphql/schema.graphql
|
||||
documents:
|
||||
- '**/*.gql'
|
||||
- '**/*.svelte'
|
||||
- ./.houdini/graphql/documents.gql
|
||||
12
.prettierignore
Normal file
12
.prettierignore
Normal 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
16
.prettierrc
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/app.css"
|
||||
}
|
||||
439
CLAUDE.md
Normal file
439
CLAUDE.md
Normal 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
24
Dockerfile
Normal 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
170
README.md
Normal 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
9
docker-compose.yml
Normal 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
42
eslint.config.js
Normal 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
25
houdini.config.js
Normal 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
6333
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal 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
4331
schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
70
src/app.css
Normal file
70
src/app.css
Normal 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
13
src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
src/app.html
Normal file
12
src/app.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Nexus v5.0 Online Platform</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
142
src/lib/auth.ts
Normal file
142
src/lib/auth.ts
Normal 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)}`;
|
||||
}
|
||||
41
src/lib/components/entity/AddressCard.svelte
Normal file
41
src/lib/components/entity/AddressCard.svelte
Normal 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}
|
||||
42
src/lib/components/entity/ContactCard.svelte
Normal file
42
src/lib/components/entity/ContactCard.svelte
Normal 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>
|
||||
150
src/lib/components/entity/EntityCard.svelte
Normal file
150
src/lib/components/entity/EntityCard.svelte
Normal 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>
|
||||
103
src/lib/components/entity/EntityTabs.svelte
Normal file
103
src/lib/components/entity/EntityTabs.svelte
Normal 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>
|
||||
264
src/lib/components/entity/ExpandableAddressCard.svelte
Normal file
264
src/lib/components/entity/ExpandableAddressCard.svelte
Normal 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>
|
||||
31
src/lib/components/entity/IdSidebar.svelte
Normal file
31
src/lib/components/entity/IdSidebar.svelte
Normal 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>
|
||||
49
src/lib/components/entity/LoadMoreButton.svelte
Normal file
49
src/lib/components/entity/LoadMoreButton.svelte
Normal 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}
|
||||
131
src/lib/components/entity/ProjectScopeCard.svelte
Normal file
131
src/lib/components/entity/ProjectScopeCard.svelte
Normal 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>
|
||||
98
src/lib/components/entity/ServiceScopeCard.svelte
Normal file
98
src/lib/components/entity/ServiceScopeCard.svelte
Normal 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}
|
||||
28
src/lib/components/entity/StatusBadge.svelte
Normal file
28
src/lib/components/entity/StatusBadge.svelte
Normal 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>
|
||||
75
src/lib/components/entity/TeamMemberList.svelte
Normal file
75
src/lib/components/entity/TeamMemberList.svelte
Normal 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}
|
||||
15
src/lib/components/entity/index.ts
Normal file
15
src/lib/components/entity/index.ts
Normal 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';
|
||||
194
src/lib/components/entity/types.ts
Normal file
194
src/lib/components/entity/types.ts
Normal 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[];
|
||||
}
|
||||
11
src/lib/components/layout/Container.svelte
Normal file
11
src/lib/components/layout/Container.svelte
Normal 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>
|
||||
422
src/lib/components/layout/Nav.svelte
Normal file
422
src/lib/components/layout/Nav.svelte
Normal 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}
|
||||
81
src/lib/components/session/SessionHeader.svelte
Normal file
81
src/lib/components/session/SessionHeader.svelte
Normal 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>
|
||||
182
src/lib/components/session/SessionMediaTab.svelte
Normal file
182
src/lib/components/session/SessionMediaTab.svelte
Normal 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>
|
||||
108
src/lib/components/session/SessionMediaUploadForm.svelte
Normal file
108
src/lib/components/session/SessionMediaUploadForm.svelte
Normal 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>
|
||||
269
src/lib/components/session/SessionNotesTab.svelte
Normal file
269
src/lib/components/session/SessionNotesTab.svelte
Normal 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>
|
||||
198
src/lib/components/session/SessionPhotoGrid.svelte
Normal file
198
src/lib/components/session/SessionPhotoGrid.svelte
Normal 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}
|
||||
278
src/lib/components/session/SessionSummaryTab.svelte
Normal file
278
src/lib/components/session/SessionSummaryTab.svelte
Normal 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>
|
||||
125
src/lib/components/session/SessionTabs.svelte
Normal file
125
src/lib/components/session/SessionTabs.svelte
Normal 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>
|
||||
547
src/lib/components/session/SessionTasksTab.svelte
Normal file
547
src/lib/components/session/SessionTasksTab.svelte
Normal 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>
|
||||
212
src/lib/components/session/SessionVideoGrid.svelte
Normal file
212
src/lib/components/session/SessionVideoGrid.svelte
Normal 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}
|
||||
10
src/lib/components/session/index.ts
Normal file
10
src/lib/components/session/index.ts
Normal 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';
|
||||
127
src/lib/components/session/types.ts
Normal file
127
src/lib/components/session/types.ts
Normal 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
57
src/lib/graphql/client.ts
Normal 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 };
|
||||
})
|
||||
]
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
mutation ArchiveConversation($input: ArchiveConversationInput!) {
|
||||
archiveConversation(input: $input) {
|
||||
id
|
||||
isArchived
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation DeleteConversation($id: ID!) {
|
||||
deleteConversation(id: $id)
|
||||
}
|
||||
3
src/lib/graphql/mutations/messages/DeleteMessage.graphql
Normal file
3
src/lib/graphql/mutations/messages/DeleteMessage.graphql
Normal file
@ -0,0 +1,3 @@
|
||||
mutation DeleteMessage($id: ID!) {
|
||||
deleteMessage(id: $id)
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
mutation MarkConversationAsRead($input: MarkAsReadInput!) {
|
||||
markConversationAsRead(input: $input) {
|
||||
id
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
mutation MuteConversation($input: MuteConversationInput!) {
|
||||
muteConversation(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
23
src/lib/graphql/mutations/messages/SendMessage.graphql
Normal file
23
src/lib/graphql/mutations/messages/SendMessage.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation DeleteNotification($id: ID!) {
|
||||
deleteNotification(id: $id)
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation MarkAllNotificationsAsRead {
|
||||
markAllNotificationsAsRead
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
mutation MarkNotificationAsRead($id: ID!) {
|
||||
markNotificationAsRead(id: $id) {
|
||||
id
|
||||
isRead
|
||||
readAt
|
||||
}
|
||||
}
|
||||
14
src/lib/graphql/mutations/profiles/UpdateTeamProfile.graphql
Normal file
14
src/lib/graphql/mutations/profiles/UpdateTeamProfile.graphql
Normal file
@ -0,0 +1,14 @@
|
||||
mutation UpdateTeamProfile($input: TeamProfileUpdateInput!) {
|
||||
updateTeamProfile(input: $input) {
|
||||
id
|
||||
role
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
email
|
||||
phone
|
||||
status
|
||||
notes
|
||||
oryKratosId
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
mutation CloseProjectSession($input: ProjectSessionCloseInput!) {
|
||||
closeProjectSession(input: $input) {
|
||||
id
|
||||
projectId
|
||||
accountId
|
||||
accountAddressId
|
||||
customerId
|
||||
scopeId
|
||||
start
|
||||
end
|
||||
isActive
|
||||
durationSeconds
|
||||
date
|
||||
createdById
|
||||
closedById
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
mutation OpenProjectSession($input: ProjectSessionStartInput!) {
|
||||
openProjectSession(input: $input) {
|
||||
id
|
||||
projectId
|
||||
scopeId
|
||||
customerId
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
mutation OpenServiceSession($input: OpenServiceSessionInput!) {
|
||||
openServiceSession(input: $input) {
|
||||
id
|
||||
serviceId
|
||||
scopeId
|
||||
customerId
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation RemoveProjectPhoto($id: ID!) {
|
||||
deleteProjectSessionImage(id: $id)
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation RemoveProjectVideo($id: ID!) {
|
||||
deleteProjectSessionVideo(id: $id)
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation RemoveServicePhoto($id: ID!) {
|
||||
deleteServiceSessionImage(id: $id)
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation RemoveServiceVideo($id: ID!) {
|
||||
deleteServiceSessionVideo(id: $id)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
mutation CreateProjectNote($input: ProjectSessionNoteInput!) {
|
||||
createProjectSessionNote(input: $input) {
|
||||
id
|
||||
sessionId
|
||||
content
|
||||
authorId
|
||||
internal
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
mutation CreateServiceNote($input: ServiceSessionNoteInput!) {
|
||||
createServiceSessionNote(input: $input) {
|
||||
id
|
||||
sessionId
|
||||
content
|
||||
authorId
|
||||
internal
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation RemoveProjectNote($id: ID!) {
|
||||
deleteProjectSessionNote(id: $id)
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
mutation RemoveServiceNote($id: ID!) {
|
||||
deleteServiceSessionNote(id: $id)
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
mutation UpdateProjectNote($input: ProjectSessionNoteUpdateInput!) {
|
||||
updateProjectSessionNote(input: $input) {
|
||||
id
|
||||
sessionId
|
||||
content
|
||||
authorId
|
||||
internal
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
mutation UpdateServiceNote($input: ServiceSessionNoteUpdateInput!) {
|
||||
updateServiceSessionNote(input: $input) {
|
||||
id
|
||||
sessionId
|
||||
content
|
||||
authorId
|
||||
internal
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/lib/graphql/queries/accounts/AccountToAddressMap.graphql
Normal file
15
src/lib/graphql/queries/accounts/AccountToAddressMap.graphql
Normal file
@ -0,0 +1,15 @@
|
||||
query AccountToAddressMap($filters: AccountFilter) {
|
||||
accounts(filters: $filters) {
|
||||
id
|
||||
customerId
|
||||
name
|
||||
addresses {
|
||||
id
|
||||
name
|
||||
streetAddress
|
||||
city
|
||||
state
|
||||
zipCode
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/lib/graphql/queries/accounts/GetAccount.graphql
Normal file
91
src/lib/graphql/queries/accounts/GetAccount.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/lib/graphql/queries/accounts/GetAccounts.graphql
Normal file
24
src/lib/graphql/queries/accounts/GetAccounts.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/lib/graphql/queries/accounts/GetAddress.graphql
Normal file
45
src/lib/graphql/queries/accounts/GetAddress.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src/lib/graphql/queries/customers/GetCustomer.graphql
Normal file
6
src/lib/graphql/queries/customers/GetCustomer.graphql
Normal file
@ -0,0 +1,6 @@
|
||||
query GetCustomer($id: ID!) {
|
||||
customer(id: $id) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
6
src/lib/graphql/queries/customers/GetCustomers.graphql
Normal file
6
src/lib/graphql/queries/customers/GetCustomers.graphql
Normal file
@ -0,0 +1,6 @@
|
||||
query GetCustomers {
|
||||
customers {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
92
src/lib/graphql/queries/messages/GetConversation.graphql
Normal file
92
src/lib/graphql/queries/messages/GetConversation.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/lib/graphql/queries/messages/GetMyConversations.graphql
Normal file
62
src/lib/graphql/queries/messages/GetMyConversations.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
query GetUnreadMessageCount {
|
||||
unreadMessageCount
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
query GetUnreadNotificationCount {
|
||||
myUnreadNotificationCount
|
||||
}
|
||||
21
src/lib/graphql/queries/projects/GetProject.graphql
Normal file
21
src/lib/graphql/queries/projects/GetProject.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/lib/graphql/queries/projects/GetProjects.graphql
Normal file
45
src/lib/graphql/queries/projects/GetProjects.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/lib/graphql/queries/reports/GetReport.graphql
Normal file
44
src/lib/graphql/queries/reports/GetReport.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/lib/graphql/queries/reports/GetReports.graphql
Normal file
10
src/lib/graphql/queries/reports/GetReports.graphql
Normal file
@ -0,0 +1,10 @@
|
||||
query GetReports($filters: ReportFilter) {
|
||||
reports(filters: $filters) {
|
||||
id
|
||||
date
|
||||
teamMemberId
|
||||
projectsLaborTotal
|
||||
servicesLaborTotal
|
||||
totalLaborValue
|
||||
}
|
||||
}
|
||||
23
src/lib/graphql/queries/scopes/GetProjectScope.graphql
Normal file
23
src/lib/graphql/queries/scopes/GetProjectScope.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/lib/graphql/queries/scopes/GetScope.graphql
Normal file
26
src/lib/graphql/queries/scopes/GetScope.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/lib/graphql/queries/services/GetService.graphql
Normal file
13
src/lib/graphql/queries/services/GetService.graphql
Normal 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
Loading…
x
Reference in New Issue
Block a user