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