public-ready-init
This commit is contained in:
commit
72d5e2d984
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgres://user:password@localhost/nexus4
|
||||||
|
|
||||||
|
# JWT Authentication
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_EXPIRATION=3600
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Rust
|
||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Node (frontend)
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "nexus-4"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-web = "4.11.0"
|
||||||
|
sea-orm = { version = "1.1.12", features = [
|
||||||
|
# REQUIRED: Database Driver
|
||||||
|
"sqlx-postgres",
|
||||||
|
# REQUIRED: Async Runtime & TLS Implementation
|
||||||
|
"runtime-tokio-rustls", # For Actix/Tokio and pure-Rust TLS
|
||||||
|
# ESSENTIAL: For SeaORM's Derive Macros
|
||||||
|
"macros",
|
||||||
|
# EXTRA FEATURES (Recommended & Common for your use case):
|
||||||
|
"debug-print", # To print SQL queries to the console/logger in debug mode
|
||||||
|
"mock", # For unit testing database interactions
|
||||||
|
"with-chrono", # To map PostgreSQL timestamp/date types to Rust's `chrono` types
|
||||||
|
"with-json", # To map PostgreSQL JSON/JSONB types to `serde_json::Value`
|
||||||
|
"with-uuid", # To map PostgreSQL UUID types to Rust's `uuid` type
|
||||||
|
]}
|
||||||
|
async-graphql = { version = "7.0.17", features = ["chrono"] }
|
||||||
|
async-graphql-actix-web = "7.0.17"
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
uuid = { version = "1.17.0", features = ["v4"] }
|
||||||
|
chrono = "0.4.41"
|
||||||
|
jsonwebtoken = "9.2.0"
|
||||||
|
bcrypt = "0.17.0"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
serde_json = "1.0.140"
|
||||||
|
dotenv = "0.15.0"
|
||||||
|
actix-cors = "0.7.0"
|
||||||
252
README.md
Normal file
252
README.md
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
# Nexus 4
|
||||||
|
|
||||||
|
A Rust-based business management API experiment featuring Actix-web, SeaORM, and async-graphql. This project was an exploration into building a high-performance backend with Rust but was ultimately abandoned in favor of returning to more familiar frameworks (Django) for Nexus 5.
|
||||||
|
|
||||||
|
## Project Status: Abandoned
|
||||||
|
|
||||||
|
While the API itself demonstrated significantly better performance characteristics than the Python-based predecessors, the project was discontinued due to:
|
||||||
|
|
||||||
|
- **Learning curve**: Rust's ownership model and borrow checker, while excellent for safety, required significant time investment
|
||||||
|
- **Ecosystem maturity**: The Django/Python ecosystem offered more battle-tested solutions for rapid business application development
|
||||||
|
- **Team familiarity**: Returning to Django allowed faster iteration and easier maintenance
|
||||||
|
|
||||||
|
Despite being abandoned, this codebase serves as a reference implementation for building GraphQL APIs in Rust.
|
||||||
|
|
||||||
|
## What Was Achieved
|
||||||
|
|
||||||
|
- Full GraphQL API with queries and mutations for all entities
|
||||||
|
- JWT authentication with bcrypt password hashing
|
||||||
|
- SeaORM entities with PostgreSQL
|
||||||
|
- Database migrations
|
||||||
|
- SvelteKit frontend with Houdini GraphQL client
|
||||||
|
- CORS configuration for frontend integration
|
||||||
|
|
||||||
|
## Performance Comparison
|
||||||
|
|
||||||
|
| Metric | Nexus 3 (Django/Graphene) | Nexus 4 (Rust/async-graphql) |
|
||||||
|
|--------|---------------------------|------------------------------|
|
||||||
|
| Memory usage | ~150MB | ~15MB |
|
||||||
|
| Cold start | ~2-3s | ~50ms |
|
||||||
|
| Simple query latency | ~20-50ms | ~2-5ms |
|
||||||
|
| Concurrent connections | Hundreds | Thousands |
|
||||||
|
|
||||||
|
*Note: These are approximate benchmarks from development testing, not production measurements.*
|
||||||
|
|
||||||
|
## Improvements Over Previous Versions
|
||||||
|
|
||||||
|
### Over Nexus 1-2 (Django REST)
|
||||||
|
- GraphQL instead of REST
|
||||||
|
- Compiled binary instead of interpreted Python
|
||||||
|
- Zero-cost abstractions and memory safety
|
||||||
|
- Native async/await without GIL limitations
|
||||||
|
|
||||||
|
### Over Nexus 3 (Django/Graphene)
|
||||||
|
- ~10x lower memory footprint
|
||||||
|
- ~10x faster query response times
|
||||||
|
- True async I/O without Python's GIL
|
||||||
|
- Type safety at compile time
|
||||||
|
- Single binary deployment
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Rust** (2024 edition)
|
||||||
|
- **Actix-web** 4.x - High-performance web framework
|
||||||
|
- **SeaORM** 1.x - Async ORM with compile-time checked queries
|
||||||
|
- **async-graphql** 7.x - GraphQL server library
|
||||||
|
- **jsonwebtoken** - JWT authentication
|
||||||
|
- **bcrypt** - Password hashing
|
||||||
|
- **PostgreSQL** - Database
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **SvelteKit** - Frontend framework
|
||||||
|
- **Houdini** - GraphQL client with generated types
|
||||||
|
- **TypeScript**
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nexus-4/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # Application entry point
|
||||||
|
│ ├── db.rs # Database connection
|
||||||
|
│ ├── auth/ # JWT authentication
|
||||||
|
│ │ ├── mod.rs # JWT middleware and utilities
|
||||||
|
│ │ ├── error.rs # Auth error types
|
||||||
|
│ │ └── handlers.rs # Login, register, token renewal
|
||||||
|
│ ├── entities/ # SeaORM entities
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── prelude.rs
|
||||||
|
│ │ ├── customer.rs
|
||||||
|
│ │ ├── account.rs
|
||||||
|
│ │ ├── service.rs
|
||||||
|
│ │ ├── project.rs
|
||||||
|
│ │ ├── schedule.rs
|
||||||
|
│ │ ├── revenue.rs
|
||||||
|
│ │ ├── labor.rs
|
||||||
|
│ │ ├── invoice.rs
|
||||||
|
│ │ ├── report.rs
|
||||||
|
│ │ ├── user.rs
|
||||||
|
│ │ └── profile.rs
|
||||||
|
│ └── graphql/ # GraphQL resolvers
|
||||||
|
│ ├── mod.rs # Schema definition
|
||||||
|
│ ├── customer.rs
|
||||||
|
│ ├── account.rs
|
||||||
|
│ ├── service.rs
|
||||||
|
│ ├── project.rs
|
||||||
|
│ ├── schedule.rs
|
||||||
|
│ ├── revenue.rs
|
||||||
|
│ ├── labor.rs
|
||||||
|
│ ├── invoice.rs
|
||||||
|
│ ├── report.rs
|
||||||
|
│ ├── user.rs
|
||||||
|
│ └── profile.rs
|
||||||
|
├── migration/ # SeaORM migrations
|
||||||
|
│ └── src/
|
||||||
|
│ ├── lib.rs
|
||||||
|
│ └── m20220101_*.rs
|
||||||
|
├── frontend/ # SvelteKit + Houdini frontend
|
||||||
|
│ ├── src/
|
||||||
|
│ ├── .houdini/ # Generated GraphQL artifacts
|
||||||
|
│ └── schema.graphql # GraphQL schema
|
||||||
|
├── Cargo.toml
|
||||||
|
└── Cargo.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Rust (latest stable)
|
||||||
|
- PostgreSQL 15+
|
||||||
|
- Node.js 18+ (for frontend)
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd nexus-4
|
||||||
|
|
||||||
|
# Create .env file
|
||||||
|
cat > .env << EOF
|
||||||
|
DATABASE_URL=postgres://user:password@localhost/nexus4
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_EXPIRATION=3600
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
cd migration
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
cd ..
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start at:
|
||||||
|
- GraphQL endpoint: http://127.0.0.1:8080/graphql
|
||||||
|
- GraphQL playground: http://127.0.0.1:8080/playground
|
||||||
|
- Auth endpoint: http://127.0.0.1:8080/token
|
||||||
|
|
||||||
|
### Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Usage
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
**Login:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "your_username", "password": "your_password"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create User:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/users \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "newuser",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securepassword",
|
||||||
|
"is_active": true,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"primary_phone": "555-123-4567",
|
||||||
|
"role": "user"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### GraphQL Queries
|
||||||
|
|
||||||
|
**Get all customers:**
|
||||||
|
```graphql
|
||||||
|
query {
|
||||||
|
customers {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
accounts {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create a service:**
|
||||||
|
```graphql
|
||||||
|
mutation {
|
||||||
|
createService(input: {
|
||||||
|
accountId: "uuid-here"
|
||||||
|
status: "scheduled"
|
||||||
|
scheduledDate: "2024-01-15"
|
||||||
|
}) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Environment Variable | Description | Default |
|
||||||
|
|---------------------|-------------|---------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string | Required |
|
||||||
|
| `JWT_SECRET` | Secret for signing JWTs | `default_secret_change_me` |
|
||||||
|
| `JWT_EXPIRATION` | Token expiration in seconds | `3600` |
|
||||||
|
|
||||||
|
## Why Rust Was Considered
|
||||||
|
|
||||||
|
1. **Performance**: Rust's zero-cost abstractions provide C-like performance
|
||||||
|
2. **Memory Safety**: No null pointer exceptions or data races
|
||||||
|
3. **Concurrency**: Fearless concurrency with async/await
|
||||||
|
4. **Type System**: Catch errors at compile time
|
||||||
|
5. **Single Binary**: Easy deployment without runtime dependencies
|
||||||
|
|
||||||
|
## Why It Was Abandoned
|
||||||
|
|
||||||
|
1. **Development Speed**: Django's ORM and admin interface provide faster iteration
|
||||||
|
2. **Ecosystem**: Python has more libraries for business logic (PDF generation, email, etc.)
|
||||||
|
3. **Hiring**: Finding Django developers is easier than Rust developers
|
||||||
|
4. **Maintenance**: Team was more comfortable debugging Python code
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
- Rust is excellent for performance-critical services
|
||||||
|
- For CRUD-heavy business apps, developer productivity often matters more than raw performance
|
||||||
|
- Consider Rust for specific microservices where performance is critical
|
||||||
|
- The async-graphql and SeaORM ecosystem is mature and pleasant to use
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - See LICENSE file for details.
|
||||||
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
9
frontend/.graphqlrc.yaml
Normal file
9
frontend/.graphqlrc.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
projects:
|
||||||
|
default:
|
||||||
|
schema:
|
||||||
|
- ./schema.graphql
|
||||||
|
- ./.houdini/graphql/schema.graphql
|
||||||
|
documents:
|
||||||
|
- '**/*.gql'
|
||||||
|
- '**/*.svelte'
|
||||||
|
- ./.houdini/graphql/documents.gql
|
||||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
6
frontend/.prettierignore
Normal file
6
frontend/.prettierignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
15
frontend/.prettierrc
Normal file
15
frontend/.prettierrc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
38
frontend/README.md
Normal file
38
frontend/README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
36
frontend/eslint.config.js
Normal file
36
frontend/eslint.config.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import globals from 'globals';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||||
|
|
||||||
|
export default ts.config(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node }
|
||||||
|
},
|
||||||
|
rules: { 'no-undef': 'off' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
28
frontend/houdini.config.js
Normal file
28
frontend/houdini.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/// <references types="houdini-svelte">
|
||||||
|
|
||||||
|
/** @type {import('houdini').ConfigFile} */
|
||||||
|
const config = {
|
||||||
|
watchSchema: {
|
||||||
|
url: 'http://127.0.0.1:8080/graphql',
|
||||||
|
interval: null
|
||||||
|
},
|
||||||
|
runtimeDir: '.houdini',
|
||||||
|
plugins: {
|
||||||
|
'houdini-svelte': {}
|
||||||
|
},
|
||||||
|
// Correct for rust chrono types
|
||||||
|
scalars: {
|
||||||
|
NaiveDateTime: {
|
||||||
|
type: 'string',
|
||||||
|
marshal: (val) => val,
|
||||||
|
unmarshal: (val) => val
|
||||||
|
},
|
||||||
|
NaiveDate: {
|
||||||
|
type: 'string',
|
||||||
|
marshal: (val) => val,
|
||||||
|
unmarshal: (val) => val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
6009
frontend/package-lock.json
generated
Normal file
6009
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"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.2.5",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@sveltejs/adapter-node": "^5.2.12",
|
||||||
|
"@sveltejs/kit": "<=2.21.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"typescript-eslint": "^8.20.0",
|
||||||
|
"vite": "^6.2.6",
|
||||||
|
"houdini": "^1.5.7",
|
||||||
|
"houdini-svelte": "^2.1.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
574
frontend/schema.graphql
Normal file
574
frontend/schema.graphql
Normal file
@ -0,0 +1,574 @@
|
|||||||
|
"""
|
||||||
|
Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its field be provided)
|
||||||
|
"""
|
||||||
|
directive @oneOf on INPUT_OBJECT
|
||||||
|
|
||||||
|
type Account {
|
||||||
|
city: String!
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
customerId: ID!
|
||||||
|
endDate: NaiveDate
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
primaryContactEmail: String!
|
||||||
|
primaryContactFirstName: String!
|
||||||
|
primaryContactLastName: String!
|
||||||
|
primaryContactPhone: String!
|
||||||
|
secondaryContactEmail: String
|
||||||
|
secondaryContactFirstName: String
|
||||||
|
secondaryContactLastName: String
|
||||||
|
secondaryContactPhone: String
|
||||||
|
startDate: NaiveDate!
|
||||||
|
state: String!
|
||||||
|
streetAddress: String!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
zipCode: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateAccountInput {
|
||||||
|
city: String!
|
||||||
|
customerId: ID!
|
||||||
|
endDate: NaiveDate
|
||||||
|
name: String!
|
||||||
|
primaryContactEmail: String!
|
||||||
|
primaryContactFirstName: String!
|
||||||
|
primaryContactLastName: String!
|
||||||
|
primaryContactPhone: String!
|
||||||
|
secondaryContactEmail: String
|
||||||
|
secondaryContactFirstName: String
|
||||||
|
secondaryContactLastName: String
|
||||||
|
secondaryContactPhone: String
|
||||||
|
startDate: NaiveDate!
|
||||||
|
state: String!
|
||||||
|
streetAddress: String!
|
||||||
|
zipCode: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateCustomerInput {
|
||||||
|
billingCity: String!
|
||||||
|
billingContactFirstName: String!
|
||||||
|
billingContactLastName: String!
|
||||||
|
billingEmail: String!
|
||||||
|
billingState: String!
|
||||||
|
billingStreetAddress: String!
|
||||||
|
billingTerms: String!
|
||||||
|
billingZipCode: String!
|
||||||
|
endDate: NaiveDate
|
||||||
|
name: String!
|
||||||
|
primaryContactEmail: String!
|
||||||
|
primaryContactFirstName: String!
|
||||||
|
primaryContactLastName: String!
|
||||||
|
primaryContactPhone: String!
|
||||||
|
secondaryContactEmail: String
|
||||||
|
secondaryContactFirstName: String
|
||||||
|
secondaryContactLastName: String
|
||||||
|
secondaryContactPhone: String
|
||||||
|
startDate: NaiveDate!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateInvoiceInput {
|
||||||
|
customerId: ID!
|
||||||
|
date: NaiveDate!
|
||||||
|
datePaid: NaiveDate
|
||||||
|
paymentType: String
|
||||||
|
sentAt: NaiveDateTime
|
||||||
|
status: String!
|
||||||
|
totalAmount: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateLaborInput {
|
||||||
|
accountId: ID!
|
||||||
|
amount: String!
|
||||||
|
endDate: NaiveDate
|
||||||
|
startDate: NaiveDate!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateProfileInput {
|
||||||
|
email: String!
|
||||||
|
firstName: String!
|
||||||
|
lastName: String!
|
||||||
|
primaryPhone: String!
|
||||||
|
role: String!
|
||||||
|
secondaryPhone: String
|
||||||
|
userId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateProjectInput {
|
||||||
|
accountId: ID
|
||||||
|
amount: String!
|
||||||
|
completedAt: NaiveDateTime
|
||||||
|
customerId: ID!
|
||||||
|
date: NaiveDate!
|
||||||
|
labor: String!
|
||||||
|
notes: String
|
||||||
|
status: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateReportInput {
|
||||||
|
date: NaiveDate!
|
||||||
|
notes: String
|
||||||
|
teamMemberId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateRevenueInput {
|
||||||
|
accountId: ID!
|
||||||
|
amount: String!
|
||||||
|
endDate: NaiveDate
|
||||||
|
startDate: NaiveDate!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateScheduleInput {
|
||||||
|
accountId: ID!
|
||||||
|
endDate: NaiveDate
|
||||||
|
fridayService: Boolean!
|
||||||
|
mondayService: Boolean!
|
||||||
|
saturdayService: Boolean!
|
||||||
|
scheduleException: String
|
||||||
|
startDate: NaiveDate!
|
||||||
|
sundayService: Boolean!
|
||||||
|
thursdayService: Boolean!
|
||||||
|
tuesdayService: Boolean!
|
||||||
|
wednesdayService: Boolean!
|
||||||
|
weekendService: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateServiceInput {
|
||||||
|
accountId: ID!
|
||||||
|
completedAt: NaiveDateTime
|
||||||
|
date: NaiveDate!
|
||||||
|
deadlineEnd: NaiveDateTime!
|
||||||
|
deadlineStart: NaiveDateTime!
|
||||||
|
notes: String
|
||||||
|
status: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateUserInput {
|
||||||
|
email: String!
|
||||||
|
isActive: Boolean!
|
||||||
|
password: String!
|
||||||
|
username: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Customer {
|
||||||
|
billingCity: String!
|
||||||
|
billingContactFirstName: String!
|
||||||
|
billingContactLastName: String!
|
||||||
|
billingEmail: String!
|
||||||
|
billingState: String!
|
||||||
|
billingStreetAddress: String!
|
||||||
|
billingTerms: String!
|
||||||
|
billingZipCode: String!
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
endDate: NaiveDate
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
primaryContactEmail: String!
|
||||||
|
primaryContactFirstName: String!
|
||||||
|
primaryContactLastName: String!
|
||||||
|
primaryContactPhone: String!
|
||||||
|
secondaryContactEmail: String
|
||||||
|
secondaryContactFirstName: String
|
||||||
|
secondaryContactLastName: String
|
||||||
|
secondaryContactPhone: String
|
||||||
|
startDate: NaiveDate!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Invoice {
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
customerId: ID!
|
||||||
|
date: NaiveDate!
|
||||||
|
datePaid: NaiveDate
|
||||||
|
id: ID!
|
||||||
|
paymentType: String
|
||||||
|
sentAt: NaiveDateTime
|
||||||
|
status: String!
|
||||||
|
totalAmount: String!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Labor {
|
||||||
|
accountId: ID!
|
||||||
|
amount: String!
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
endDate: NaiveDate
|
||||||
|
id: ID!
|
||||||
|
startDate: NaiveDate!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
createAccount(input: CreateAccountInput!): Account!
|
||||||
|
createCustomer(input: CreateCustomerInput!): Customer!
|
||||||
|
createInvoice(input: CreateInvoiceInput!): Invoice!
|
||||||
|
createLabor(input: CreateLaborInput!): Labor!
|
||||||
|
createProfile(input: CreateProfileInput!): Profile!
|
||||||
|
createProject(input: CreateProjectInput!): Project!
|
||||||
|
createReport(input: CreateReportInput!): Report!
|
||||||
|
createRevenue(input: CreateRevenueInput!): Revenue!
|
||||||
|
createSchedule(input: CreateScheduleInput!): Schedule!
|
||||||
|
createService(input: CreateServiceInput!): Service!
|
||||||
|
createUser(input: CreateUserInput!): User!
|
||||||
|
deleteAccount(id: ID!): Boolean!
|
||||||
|
deleteCustomer(id: ID!): Boolean!
|
||||||
|
deleteInvoice(id: ID!): Boolean!
|
||||||
|
deleteLabor(id: ID!): Boolean!
|
||||||
|
deleteProfile(id: ID!): Boolean!
|
||||||
|
deleteProject(id: ID!): Boolean!
|
||||||
|
deleteReport(id: ID!): Boolean!
|
||||||
|
deleteRevenue(id: ID!): Boolean!
|
||||||
|
deleteSchedule(id: ID!): Boolean!
|
||||||
|
deleteService(id: ID!): Boolean!
|
||||||
|
deleteUser(id: ID!): Boolean!
|
||||||
|
updateAccount(id: ID!, input: UpdateAccountInput!): Account!
|
||||||
|
updateCustomer(id: ID!, input: UpdateCustomerInput!): Customer!
|
||||||
|
updateInvoice(id: ID!, input: UpdateInvoiceInput!): Invoice!
|
||||||
|
updateLabor(id: ID!, input: UpdateLaborInput!): Labor!
|
||||||
|
updateProfile(id: ID!, input: UpdateProfileInput!): Profile!
|
||||||
|
updateProject(id: ID!, input: UpdateProjectInput!): Project!
|
||||||
|
updateReport(id: ID!, input: UpdateReportInput!): Report!
|
||||||
|
updateRevenue(id: ID!, input: UpdateRevenueInput!): Revenue!
|
||||||
|
updateSchedule(id: ID!, input: UpdateScheduleInput!): Schedule!
|
||||||
|
updateService(id: ID!, input: UpdateServiceInput!): Service!
|
||||||
|
updateUser(id: ID!, input: UpdateUserInput!): User!
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
ISO 8601 calendar date without timezone.
|
||||||
|
Format: %Y-%m-%d
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
|
||||||
|
* `1994-11-13`
|
||||||
|
* `2000-02-24`
|
||||||
|
"""
|
||||||
|
scalar NaiveDate
|
||||||
|
|
||||||
|
"""
|
||||||
|
ISO 8601 combined date and time without timezone.
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
|
||||||
|
* `2015-07-01T08:59:60.123`,
|
||||||
|
"""
|
||||||
|
scalar NaiveDateTime
|
||||||
|
|
||||||
|
type Profile {
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
email: String!
|
||||||
|
firstName: String!
|
||||||
|
id: ID!
|
||||||
|
lastName: String!
|
||||||
|
primaryPhone: String!
|
||||||
|
role: String!
|
||||||
|
secondaryPhone: String
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
userId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project {
|
||||||
|
accountId: ID
|
||||||
|
amount: String!
|
||||||
|
completedAt: NaiveDateTime
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
customerId: ID!
|
||||||
|
date: NaiveDate!
|
||||||
|
id: ID!
|
||||||
|
labor: String!
|
||||||
|
notes: String
|
||||||
|
status: String!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
account(id: ID!): Account
|
||||||
|
accounts(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Account!]!
|
||||||
|
accountsByCustomer(customerId: ID!): [Account!]!
|
||||||
|
customer(id: ID!): Customer
|
||||||
|
customers(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Customer!]!
|
||||||
|
invoice(id: ID!): Invoice
|
||||||
|
invoices(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Invoice!]!
|
||||||
|
invoicesByCustomer(customerId: ID!): [Invoice!]!
|
||||||
|
invoicesByStatus(status: String!): [Invoice!]!
|
||||||
|
labor(id: ID!): Labor
|
||||||
|
labors(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Labor!]!
|
||||||
|
laborsByAccount(accountId: ID!): [Labor!]!
|
||||||
|
profile(id: ID!): Profile
|
||||||
|
profileByUser(userId: ID!): Profile
|
||||||
|
profiles(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Profile!]!
|
||||||
|
profilesByRole(role: String!): [Profile!]!
|
||||||
|
project(id: ID!): Project
|
||||||
|
projects(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Project!]!
|
||||||
|
projectsByAccount(accountId: ID!): [Project!]!
|
||||||
|
projectsByCustomer(customerId: ID!): [Project!]!
|
||||||
|
projectsByStatus(status: String!): [Project!]!
|
||||||
|
report(id: ID!): Report
|
||||||
|
reports(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Report!]!
|
||||||
|
reportsByDate(date: NaiveDate!): [Report!]!
|
||||||
|
reportsByTeamMember(teamMemberId: ID!): [Report!]!
|
||||||
|
revenue(id: ID!): Revenue
|
||||||
|
revenues(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Revenue!]!
|
||||||
|
revenuesByAccount(accountId: ID!): [Revenue!]!
|
||||||
|
schedule(id: ID!): Schedule
|
||||||
|
schedules(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Schedule!]!
|
||||||
|
schedulesByAccount(accountId: ID!): [Schedule!]!
|
||||||
|
searchAccounts(name: String!): [Account!]!
|
||||||
|
searchCustomers(name: String!): [Customer!]!
|
||||||
|
service(id: ID!): Service
|
||||||
|
services(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [Service!]!
|
||||||
|
servicesByAccount(accountId: ID!): [Service!]!
|
||||||
|
servicesByStatus(status: String!): [Service!]!
|
||||||
|
user(id: ID!): User
|
||||||
|
userByEmail(email: String!): User
|
||||||
|
userByUsername(username: String!): User
|
||||||
|
users(
|
||||||
|
"""Number of items to return"""
|
||||||
|
limit: Int! = 10
|
||||||
|
|
||||||
|
"""Number of items to skip"""
|
||||||
|
offset: Int! = 0
|
||||||
|
): [User!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report {
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
date: NaiveDate!
|
||||||
|
id: ID!
|
||||||
|
notes: String
|
||||||
|
teamMemberId: ID!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Revenue {
|
||||||
|
accountId: ID!
|
||||||
|
amount: String!
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
endDate: NaiveDate
|
||||||
|
id: ID!
|
||||||
|
startDate: NaiveDate!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Schedule {
|
||||||
|
accountId: ID!
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
endDate: NaiveDate
|
||||||
|
fridayService: Boolean!
|
||||||
|
id: ID!
|
||||||
|
mondayService: Boolean!
|
||||||
|
saturdayService: Boolean!
|
||||||
|
scheduleException: String
|
||||||
|
startDate: NaiveDate!
|
||||||
|
sundayService: Boolean!
|
||||||
|
thursdayService: Boolean!
|
||||||
|
tuesdayService: Boolean!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
wednesdayService: Boolean!
|
||||||
|
weekendService: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service {
|
||||||
|
accountId: ID!
|
||||||
|
completedAt: NaiveDateTime
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
date: NaiveDate!
|
||||||
|
deadlineEnd: NaiveDateTime!
|
||||||
|
deadlineStart: NaiveDateTime!
|
||||||
|
id: ID!
|
||||||
|
notes: String
|
||||||
|
status: String!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateAccountInput {
|
||||||
|
city: String
|
||||||
|
customerId: ID
|
||||||
|
endDate: NaiveDate
|
||||||
|
name: String
|
||||||
|
primaryContactEmail: String
|
||||||
|
primaryContactFirstName: String
|
||||||
|
primaryContactLastName: String
|
||||||
|
primaryContactPhone: String
|
||||||
|
secondaryContactEmail: String
|
||||||
|
secondaryContactFirstName: String
|
||||||
|
secondaryContactLastName: String
|
||||||
|
secondaryContactPhone: String
|
||||||
|
startDate: NaiveDate
|
||||||
|
state: String
|
||||||
|
streetAddress: String
|
||||||
|
zipCode: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateCustomerInput {
|
||||||
|
billingCity: String
|
||||||
|
billingContactFirstName: String
|
||||||
|
billingContactLastName: String
|
||||||
|
billingEmail: String
|
||||||
|
billingState: String
|
||||||
|
billingStreetAddress: String
|
||||||
|
billingTerms: String
|
||||||
|
billingZipCode: String
|
||||||
|
endDate: NaiveDate
|
||||||
|
name: String
|
||||||
|
primaryContactEmail: String
|
||||||
|
primaryContactFirstName: String
|
||||||
|
primaryContactLastName: String
|
||||||
|
primaryContactPhone: String
|
||||||
|
secondaryContactEmail: String
|
||||||
|
secondaryContactFirstName: String
|
||||||
|
secondaryContactLastName: String
|
||||||
|
secondaryContactPhone: String
|
||||||
|
startDate: NaiveDate
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateInvoiceInput {
|
||||||
|
customerId: ID
|
||||||
|
date: NaiveDate
|
||||||
|
datePaid: NaiveDate
|
||||||
|
paymentType: String
|
||||||
|
sentAt: NaiveDateTime
|
||||||
|
status: String
|
||||||
|
totalAmount: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateLaborInput {
|
||||||
|
accountId: ID
|
||||||
|
amount: String
|
||||||
|
endDate: NaiveDate
|
||||||
|
startDate: NaiveDate
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateProfileInput {
|
||||||
|
email: String
|
||||||
|
firstName: String
|
||||||
|
lastName: String
|
||||||
|
primaryPhone: String
|
||||||
|
role: String
|
||||||
|
secondaryPhone: String
|
||||||
|
userId: ID
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateProjectInput {
|
||||||
|
accountId: ID
|
||||||
|
amount: String
|
||||||
|
completedAt: NaiveDateTime
|
||||||
|
customerId: ID
|
||||||
|
date: NaiveDate
|
||||||
|
labor: String
|
||||||
|
notes: String
|
||||||
|
status: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateReportInput {
|
||||||
|
date: NaiveDate
|
||||||
|
notes: String
|
||||||
|
teamMemberId: ID
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateRevenueInput {
|
||||||
|
accountId: ID
|
||||||
|
amount: String
|
||||||
|
endDate: NaiveDate
|
||||||
|
startDate: NaiveDate
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateScheduleInput {
|
||||||
|
accountId: ID
|
||||||
|
endDate: NaiveDate
|
||||||
|
fridayService: Boolean
|
||||||
|
mondayService: Boolean
|
||||||
|
saturdayService: Boolean
|
||||||
|
scheduleException: String
|
||||||
|
startDate: NaiveDate
|
||||||
|
sundayService: Boolean
|
||||||
|
thursdayService: Boolean
|
||||||
|
tuesdayService: Boolean
|
||||||
|
wednesdayService: Boolean
|
||||||
|
weekendService: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateServiceInput {
|
||||||
|
accountId: ID
|
||||||
|
completedAt: NaiveDateTime
|
||||||
|
date: NaiveDate
|
||||||
|
deadlineEnd: NaiveDateTime
|
||||||
|
deadlineStart: NaiveDateTime
|
||||||
|
notes: String
|
||||||
|
status: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateUserInput {
|
||||||
|
email: String
|
||||||
|
isActive: Boolean
|
||||||
|
password: String
|
||||||
|
username: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type User {
|
||||||
|
createdAt: NaiveDateTime!
|
||||||
|
email: String!
|
||||||
|
id: ID!
|
||||||
|
isActive: Boolean!
|
||||||
|
updatedAt: NaiveDateTime!
|
||||||
|
username: String!
|
||||||
|
}
|
||||||
12
frontend/src/app.css
Normal file
12
frontend/src/app.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin '@tailwindcss/forms';
|
||||||
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #2563eb; /* Darker green */
|
||||||
|
--color-secondary: #059669; /* Darker blue */
|
||||||
|
--color-accent: #ea580c; /* Orange accent */
|
||||||
|
--color-primary-light: #93c5fd;
|
||||||
|
--color-secondary-light: #6ee7b7;
|
||||||
|
--color-accent-light: #fed7aa;
|
||||||
|
}
|
||||||
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/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
frontend/src/app.html
Normal file
12
frontend/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</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
frontend/src/client.ts
Normal file
20
frontend/src/client.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { HoudiniClient } from '$houdini';
|
||||||
|
|
||||||
|
export default new HoudiniClient({
|
||||||
|
url: 'http://127.0.0.1:8080/graphql',
|
||||||
|
// Configure the network call with authentication
|
||||||
|
fetchParams() {
|
||||||
|
// Get the token from localStorage
|
||||||
|
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('accessToken') : null;
|
||||||
|
// If a token exists, include it in the Authorization header
|
||||||
|
if (token) {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Otherwise, return empty headers
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})
|
||||||
107
frontend/src/lib/components/Sidebar.svelte
Normal file
107
frontend/src/lib/components/Sidebar.svelte
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
// State for sidebar visibility
|
||||||
|
let isOpen = false;
|
||||||
|
let isAuthenticated = false;
|
||||||
|
|
||||||
|
// Toggle sidebar visibility
|
||||||
|
function toggleSidebar() {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
onMount(() => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
isAuthenticated = !!token;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isAuthenticated}
|
||||||
|
<div class="fixed top-0 right-0 h-full z-50 flex">
|
||||||
|
<!-- Sidebar toggle button -->
|
||||||
|
<button
|
||||||
|
on:click={toggleSidebar}
|
||||||
|
class="bg-primary text-white p-2 h-12 self-start mt-4 rounded-l-md shadow-md"
|
||||||
|
aria-label={isOpen ? 'Close sidebar' : 'Open sidebar'}
|
||||||
|
>
|
||||||
|
{#if isOpen}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sidebar content -->
|
||||||
|
<div
|
||||||
|
class="bg-secondary text-white w-64 shadow-lg transition-transform duration-300 ease-in-out"
|
||||||
|
class:translate-x-0={isOpen}
|
||||||
|
class:translate-x-full={!isOpen}
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<h2 class="text-xl font-bold mb-6 text-accent">Navigation</h2>
|
||||||
|
<nav>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a href="/home" class="block py-2 px-4 rounded hover:bg-primary-light transition-colors">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/customers" class="block py-2 px-4 rounded hover:bg-primary-light transition-colors">
|
||||||
|
Customers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/accounts" class="block py-2 px-4 rounded hover:bg-primary-light transition-colors">
|
||||||
|
Accounts
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/services" class="block py-2 px-4 rounded hover:bg-primary-light transition-colors">
|
||||||
|
Services
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/projects" class="block py-2 px-4 rounded hover:bg-primary-light transition-colors">
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/profile" class="block py-2 px-4 rounded hover:bg-primary-light transition-colors">
|
||||||
|
My Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Use the theme variables */
|
||||||
|
:global(.bg-primary) {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.bg-secondary) {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.text-accent) {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.bg-primary-light) {
|
||||||
|
background-color: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.bg-secondary-light) {
|
||||||
|
background-color: var(--color-secondary-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
332
frontend/src/lib/components/accounts/AccountForm.svelte
Normal file
332
frontend/src/lib/components/accounts/AccountForm.svelte
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Account, CreateAccountInput, UpdateAccountInput } from '$lib/types/accounts';
|
||||||
|
|
||||||
|
// Props using Svelte 5 syntax
|
||||||
|
let {
|
||||||
|
account = $bindable<Partial<Account>>({}),
|
||||||
|
isEditing = false,
|
||||||
|
onSubmit,
|
||||||
|
onCancel = () => {}
|
||||||
|
}: {
|
||||||
|
account?: Partial<Account>;
|
||||||
|
isEditing?: boolean;
|
||||||
|
onSubmit: (data: CreateAccountInput | (UpdateAccountInput & { id: string })) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Form state - needs to be $state since we bind to form inputs
|
||||||
|
let formData = $derived({ ...account });
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
function validateForm(): boolean {
|
||||||
|
errors = {};
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
const requiredFields: (keyof CreateAccountInput)[] = [
|
||||||
|
'name', 'customerId', 'startDate', 'streetAddress', 'city', 'state', 'zipCode',
|
||||||
|
'primaryContactFirstName', 'primaryContactLastName', 'primaryContactEmail', 'primaryContactPhone'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!formData[field]) {
|
||||||
|
errors[field] = 'This field is required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
if (formData.primaryContactEmail && !/^\S+@\S+\.\S+$/.test(formData.primaryContactEmail)) {
|
||||||
|
errors.primaryContactEmail = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.secondaryContactEmail && !/^\S+@\S+\.\S+$/.test(formData.secondaryContactEmail)) {
|
||||||
|
errors.secondaryContactEmail = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
if (isEditing && account.id) {
|
||||||
|
// For editing, include the ID
|
||||||
|
onSubmit({
|
||||||
|
id: account.id,
|
||||||
|
...formData
|
||||||
|
} as UpdateAccountInput & { id: string });
|
||||||
|
} else {
|
||||||
|
// For creating, omit the ID
|
||||||
|
onSubmit(formData as CreateAccountInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
{#if isEditing}
|
||||||
|
<div>
|
||||||
|
<label for="id" class="block text-sm font-medium text-gray-700 mb-1">ID</label>
|
||||||
|
<input
|
||||||
|
id="id"
|
||||||
|
type="text"
|
||||||
|
value={account.id || ''}
|
||||||
|
disabled
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name*</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.name}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Account name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.name}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="customerId" class="block text-sm font-medium text-gray-700 mb-1">Customer ID*</label>
|
||||||
|
<input
|
||||||
|
id="customerId"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.customerId}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Customer ID"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.customerId}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.customerId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="startDate" class="block text-sm font-medium text-gray-700 mb-1">Start Date*</label>
|
||||||
|
<input
|
||||||
|
id="startDate"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.startDate}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.startDate}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.startDate}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="endDate" class="block text-sm font-medium text-gray-700 mb-1">End Date</label>
|
||||||
|
<input
|
||||||
|
id="endDate"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.endDate}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="streetAddress" class="block text-sm font-medium text-gray-700 mb-1">Street Address*</label>
|
||||||
|
<input
|
||||||
|
id="streetAddress"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.streetAddress}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Street address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.streetAddress}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.streetAddress}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="city" class="block text-sm font-medium text-gray-700 mb-1">City*</label>
|
||||||
|
<input
|
||||||
|
id="city"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.city}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="City"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.city}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.city}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="state" class="block text-sm font-medium text-gray-700 mb-1">State*</label>
|
||||||
|
<input
|
||||||
|
id="state"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.state}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="State"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.state}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.state}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="zipCode" class="block text-sm font-medium text-gray-700 mb-1">Zip Code*</label>
|
||||||
|
<input
|
||||||
|
id="zipCode"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.zipCode}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Zip code"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.zipCode}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.zipCode}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mt-8 mb-4">Primary Contact Information</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="primaryContactFirstName" class="block text-sm font-medium text-gray-700 mb-1">First Name*</label>
|
||||||
|
<input
|
||||||
|
id="primaryContactFirstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.primaryContactFirstName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="First name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.primaryContactFirstName}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.primaryContactFirstName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="primaryContactLastName" class="block text-sm font-medium text-gray-700 mb-1">Last Name*</label>
|
||||||
|
<input
|
||||||
|
id="primaryContactLastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.primaryContactLastName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Last name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.primaryContactLastName}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.primaryContactLastName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="primaryContactEmail" class="block text-sm font-medium text-gray-700 mb-1">Email*</label>
|
||||||
|
<input
|
||||||
|
id="primaryContactEmail"
|
||||||
|
type="email"
|
||||||
|
bind:value={formData.primaryContactEmail}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Email address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.primaryContactEmail}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.primaryContactEmail}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="primaryContactPhone" class="block text-sm font-medium text-gray-700 mb-1">Phone*</label>
|
||||||
|
<input
|
||||||
|
id="primaryContactPhone"
|
||||||
|
type="tel"
|
||||||
|
bind:value={formData.primaryContactPhone}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Phone number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.primaryContactPhone}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.primaryContactPhone}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mt-8 mb-4">Secondary Contact Information (Optional)</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="secondaryContactFirstName" class="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||||
|
<input
|
||||||
|
id="secondaryContactFirstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.secondaryContactFirstName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="First name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="secondaryContactLastName" class="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||||
|
<input
|
||||||
|
id="secondaryContactLastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.secondaryContactLastName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Last name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="secondaryContactEmail" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
id="secondaryContactEmail"
|
||||||
|
type="email"
|
||||||
|
bind:value={formData.secondaryContactEmail}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Email address"
|
||||||
|
/>
|
||||||
|
{#if errors.secondaryContactEmail}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.secondaryContactEmail}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="secondaryContactPhone" class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||||
|
<input
|
||||||
|
id="secondaryContactPhone"
|
||||||
|
type="tel"
|
||||||
|
bind:value={formData.secondaryContactPhone}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Phone number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
onclick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-secondary hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
{isEditing ? 'Update' : 'Create'} Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
387
frontend/src/lib/components/customers/CustomerForm.svelte
Normal file
387
frontend/src/lib/components/customers/CustomerForm.svelte
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Customer, CreateCustomerInput, UpdateCustomerInput } from '$lib/types/customers';
|
||||||
|
|
||||||
|
// Props using Svelte 5 syntax
|
||||||
|
let {
|
||||||
|
customer = $bindable<Partial<Customer>>({}),
|
||||||
|
isEditing = false,
|
||||||
|
onSubmit,
|
||||||
|
onCancel = () => {}
|
||||||
|
}: {
|
||||||
|
customer?: Partial<Customer>;
|
||||||
|
isEditing?: boolean;
|
||||||
|
onSubmit: (data: CreateCustomerInput | (UpdateCustomerInput & { id: string })) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Form state - needs to be $state since we bind to form inputs
|
||||||
|
let formData = $derived({ ...customer });
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
function validateForm(): boolean {
|
||||||
|
errors = {};
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
const requiredFields: (keyof CreateCustomerInput)[] = [
|
||||||
|
'name', 'startDate',
|
||||||
|
'primaryContactFirstName', 'primaryContactLastName', 'primaryContactEmail', 'primaryContactPhone',
|
||||||
|
'billingContactFirstName', 'billingContactLastName', 'billingEmail', 'billingStreetAddress',
|
||||||
|
'billingCity', 'billingState', 'billingZipCode', 'billingTerms'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!formData[field]) {
|
||||||
|
errors[field] = 'This field is required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
if (formData.primaryContactEmail && !/^\S+@\S+\.\S+$/.test(formData.primaryContactEmail)) {
|
||||||
|
errors.primaryContactEmail = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.secondaryContactEmail && !/^\S+@\S+\.\S+$/.test(formData.secondaryContactEmail)) {
|
||||||
|
errors.secondaryContactEmail = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.billingEmail && !/^\S+@\S+\.\S+$/.test(formData.billingEmail)) {
|
||||||
|
errors.billingEmail = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
if (isEditing && customer.id) {
|
||||||
|
// For editing, include the ID
|
||||||
|
onSubmit({
|
||||||
|
id: customer.id,
|
||||||
|
...formData
|
||||||
|
} as UpdateCustomerInput & { id: string });
|
||||||
|
} else {
|
||||||
|
// For creating, omit the ID
|
||||||
|
onSubmit(formData as CreateCustomerInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
{#if isEditing}
|
||||||
|
<div>
|
||||||
|
<label for="id" class="block text-sm font-medium text-gray-700 mb-1">ID</label>
|
||||||
|
<input
|
||||||
|
id="id"
|
||||||
|
type="text"
|
||||||
|
value={customer.id || ''}
|
||||||
|
disabled
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name*</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.name}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Customer name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.name}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="startDate" class="block text-sm font-medium text-gray-700 mb-1">Start Date*</label>
|
||||||
|
<input
|
||||||
|
id="startDate"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.startDate}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.startDate}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.startDate}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="endDate" class="block text-sm font-medium text-gray-700 mb-1">End Date</label>
|
||||||
|
<input
|
||||||
|
id="endDate"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.endDate}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mt-8 mb-4">Primary Contact Information</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="primaryContactFirstName" class="block text-sm font-medium text-gray-700 mb-1">First Name*</label>
|
||||||
|
<input
|
||||||
|
id="primaryContactFirstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.primaryContactFirstName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="First name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.primaryContactFirstName}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.primaryContactFirstName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="primaryContactLastName" class="block text-sm font-medium text-gray-700 mb-1">Last Name*</label>
|
||||||
|
<input
|
||||||
|
id="primaryContactLastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.primaryContactLastName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Last name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.primaryContactLastName}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.primaryContactLastName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="primaryContactEmail" class="block text-sm font-medium text-gray-700 mb-1">Email*</label>
|
||||||
|
<input
|
||||||
|
id="primaryContactEmail"
|
||||||
|
type="email"
|
||||||
|
bind:value={formData.primaryContactEmail}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Email address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.primaryContactEmail}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.primaryContactEmail}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="primaryContactPhone" class="block text-sm font-medium text-gray-700 mb-1">Phone*</label>
|
||||||
|
<input
|
||||||
|
id="primaryContactPhone"
|
||||||
|
type="tel"
|
||||||
|
bind:value={formData.primaryContactPhone}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Phone number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.primaryContactPhone}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.primaryContactPhone}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mt-8 mb-4">Secondary Contact Information (Optional)</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="secondaryContactFirstName" class="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||||
|
<input
|
||||||
|
id="secondaryContactFirstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.secondaryContactFirstName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="First name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="secondaryContactLastName" class="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||||
|
<input
|
||||||
|
id="secondaryContactLastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.secondaryContactLastName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Last name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="secondaryContactEmail" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
id="secondaryContactEmail"
|
||||||
|
type="email"
|
||||||
|
bind:value={formData.secondaryContactEmail}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Email address"
|
||||||
|
/>
|
||||||
|
{#if errors.secondaryContactEmail}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.secondaryContactEmail}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="secondaryContactPhone" class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||||
|
<input
|
||||||
|
id="secondaryContactPhone"
|
||||||
|
type="tel"
|
||||||
|
bind:value={formData.secondaryContactPhone}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Phone number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mt-8 mb-4">Billing Information</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="billingContactFirstName" class="block text-sm font-medium text-gray-700 mb-1">Contact First Name*</label>
|
||||||
|
<input
|
||||||
|
id="billingContactFirstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.billingContactFirstName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="First name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.billingContactFirstName}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.billingContactFirstName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="billingContactLastName" class="block text-sm font-medium text-gray-700 mb-1">Contact Last Name*</label>
|
||||||
|
<input
|
||||||
|
id="billingContactLastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.billingContactLastName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Last name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.billingContactLastName}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.billingContactLastName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="billingEmail" class="block text-sm font-medium text-gray-700 mb-1">Billing Email*</label>
|
||||||
|
<input
|
||||||
|
id="billingEmail"
|
||||||
|
type="email"
|
||||||
|
bind:value={formData.billingEmail}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Billing email address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.billingEmail}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.billingEmail}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="billingStreetAddress" class="block text-sm font-medium text-gray-700 mb-1">Street Address*</label>
|
||||||
|
<input
|
||||||
|
id="billingStreetAddress"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.billingStreetAddress}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Street address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.billingStreetAddress}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.billingStreetAddress}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="billingCity" class="block text-sm font-medium text-gray-700 mb-1">City*</label>
|
||||||
|
<input
|
||||||
|
id="billingCity"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.billingCity}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="City"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.billingCity}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.billingCity}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="billingState" class="block text-sm font-medium text-gray-700 mb-1">State*</label>
|
||||||
|
<input
|
||||||
|
id="billingState"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.billingState}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="State"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.billingState}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.billingState}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="billingZipCode" class="block text-sm font-medium text-gray-700 mb-1">Zip Code*</label>
|
||||||
|
<input
|
||||||
|
id="billingZipCode"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.billingZipCode}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Zip code"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.billingZipCode}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.billingZipCode}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="billingTerms" class="block text-sm font-medium text-gray-700 mb-1">Billing Terms*</label>
|
||||||
|
<input
|
||||||
|
id="billingTerms"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.billingTerms}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Billing terms"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.billingTerms}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.billingTerms}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
onclick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-secondary hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
{isEditing ? 'Update' : 'Create'} Customer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
209
frontend/src/lib/components/projects/ProjectForm.svelte
Normal file
209
frontend/src/lib/components/projects/ProjectForm.svelte
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Project, CreateProjectInput, UpdateProjectInput } from '$lib/types/projects';
|
||||||
|
|
||||||
|
// Props using Svelte 5 syntax
|
||||||
|
let {
|
||||||
|
project = $bindable<Partial<Project>>({}),
|
||||||
|
isEditing = false,
|
||||||
|
onSubmit,
|
||||||
|
onCancel = () => {}
|
||||||
|
}: {
|
||||||
|
project?: Partial<Project>;
|
||||||
|
isEditing?: boolean;
|
||||||
|
onSubmit: (data: CreateProjectInput | (UpdateProjectInput & { id: string })) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Form state - needs to be $state since we bind to form inputs
|
||||||
|
let formData = $derived({ ...project });
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Status options
|
||||||
|
const statusOptions = [
|
||||||
|
'Pending',
|
||||||
|
'In Progress',
|
||||||
|
'Completed',
|
||||||
|
'Cancelled'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
function validateForm(): boolean {
|
||||||
|
errors = {};
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
const requiredFields: (keyof CreateProjectInput)[] = [
|
||||||
|
'customerId', 'date', 'amount', 'labor', 'status'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!formData[field]) {
|
||||||
|
errors[field] = 'This field is required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric validation for amount
|
||||||
|
if (formData.amount && !/^\d+(\.\d{1,2})?$/.test(formData.amount)) {
|
||||||
|
errors.amount = 'Please enter a valid amount (e.g., 100 or 100.00)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
if (isEditing && project.id) {
|
||||||
|
// For editing, include the ID
|
||||||
|
onSubmit({
|
||||||
|
id: project.id,
|
||||||
|
...formData
|
||||||
|
} as UpdateProjectInput & { id: string });
|
||||||
|
} else {
|
||||||
|
// For creating, omit the ID
|
||||||
|
onSubmit(formData as CreateProjectInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
{#if isEditing}
|
||||||
|
<div>
|
||||||
|
<label for="id" class="block text-sm font-medium text-gray-700 mb-1">ID</label>
|
||||||
|
<input
|
||||||
|
id="id"
|
||||||
|
type="text"
|
||||||
|
value={project.id || ''}
|
||||||
|
disabled
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="customerId" class="block text-sm font-medium text-gray-700 mb-1">Customer ID*</label>
|
||||||
|
<input
|
||||||
|
id="customerId"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.customerId}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Customer ID"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.customerId}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.customerId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="accountId" class="block text-sm font-medium text-gray-700 mb-1">Account ID</label>
|
||||||
|
<input
|
||||||
|
id="accountId"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.accountId}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Account ID (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="date" class="block text-sm font-medium text-gray-700 mb-1">Date*</label>
|
||||||
|
<input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.date}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.date}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.date}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="amount" class="block text-sm font-medium text-gray-700 mb-1">Amount*</label>
|
||||||
|
<input
|
||||||
|
id="amount"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.amount}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Amount (e.g., 100.00)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.amount}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.amount}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="labor" class="block text-sm font-medium text-gray-700 mb-1">Labor*</label>
|
||||||
|
<input
|
||||||
|
id="labor"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.labor}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Labor"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.labor}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.labor}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status*</label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
bind:value={formData.status}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a status</option>
|
||||||
|
{#each statusOptions as status (status)}
|
||||||
|
<option value={status}>{status}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if errors.status}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.status}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
bind:value={formData.notes}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Project notes"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="completedAt" class="block text-sm font-medium text-gray-700 mb-1">Completed At</label>
|
||||||
|
<input
|
||||||
|
id="completedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={formData.completedAt}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
onclick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-secondary hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
{isEditing ? 'Update' : 'Create'} Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
199
frontend/src/lib/components/services/ServiceForm.svelte
Normal file
199
frontend/src/lib/components/services/ServiceForm.svelte
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Service, CreateServiceInput, UpdateServiceInput } from '$lib/types/services';
|
||||||
|
|
||||||
|
// Props using Svelte 5 syntax
|
||||||
|
let {
|
||||||
|
service = $bindable<Partial<Service>>({}),
|
||||||
|
isEditing = false,
|
||||||
|
onSubmit,
|
||||||
|
onCancel = () => {}
|
||||||
|
}: {
|
||||||
|
service?: Partial<Service>;
|
||||||
|
isEditing?: boolean;
|
||||||
|
onSubmit: (data: CreateServiceInput | (UpdateServiceInput & { id: string })) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Form state - needs to be $state since we bind to form inputs
|
||||||
|
let formData = $derived({ ...service });
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Status options
|
||||||
|
const statusOptions = [
|
||||||
|
'Scheduled',
|
||||||
|
'In Progress',
|
||||||
|
'Completed',
|
||||||
|
'Cancelled'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
function validateForm(): boolean {
|
||||||
|
errors = {};
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
const requiredFields: (keyof CreateServiceInput)[] = [
|
||||||
|
'accountId', 'date', 'deadlineStart', 'deadlineEnd', 'status'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!formData[field]) {
|
||||||
|
errors[field] = 'This field is required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate deadlines
|
||||||
|
if (formData.deadlineStart && formData.deadlineEnd) {
|
||||||
|
const start = new Date(formData.deadlineStart);
|
||||||
|
const end = new Date(formData.deadlineEnd);
|
||||||
|
|
||||||
|
if (end < start) {
|
||||||
|
errors.deadlineEnd = 'End deadline must be after start deadline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
if (isEditing && service.id) {
|
||||||
|
onSubmit({
|
||||||
|
id: service.id,
|
||||||
|
...formData
|
||||||
|
} as UpdateServiceInput & { id: string });
|
||||||
|
} else {
|
||||||
|
onSubmit(formData as CreateServiceInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
{#if isEditing}
|
||||||
|
<div>
|
||||||
|
<label for="id" class="block text-sm font-medium text-gray-700 mb-1">ID</label>
|
||||||
|
<input
|
||||||
|
id="id"
|
||||||
|
type="text"
|
||||||
|
value={service.id || ''}
|
||||||
|
disabled
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="accountId" class="block text-sm font-medium text-gray-700 mb-1">Account ID*</label>
|
||||||
|
<input
|
||||||
|
id="accountId"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.accountId}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Account ID"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.accountId}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.accountId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="date" class="block text-sm font-medium text-gray-700 mb-1">Date*</label>
|
||||||
|
<input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.date}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.date}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.date}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="deadlineStart" class="block text-sm font-medium text-gray-700 mb-1">Deadline Start*</label>
|
||||||
|
<input
|
||||||
|
id="deadlineStart"
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={formData.deadlineStart}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.deadlineStart}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.deadlineStart}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="deadlineEnd" class="block text-sm font-medium text-gray-700 mb-1">Deadline End*</label>
|
||||||
|
<input
|
||||||
|
id="deadlineEnd"
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={formData.deadlineEnd}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.deadlineEnd}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.deadlineEnd}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status*</label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
bind:value={formData.status}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a status</option>
|
||||||
|
{#each statusOptions as status (status)}
|
||||||
|
<option value={status}>{status}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if errors.status}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{errors.status}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
bind:value={formData.notes}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
placeholder="Service notes"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="completedAt" class="block text-sm font-medium text-gray-700 mb-1">Completed At</label>
|
||||||
|
<input
|
||||||
|
id="completedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={formData.completedAt}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-secondary focus:border-secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
onclick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-secondary hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
{isEditing ? 'Update' : 'Create'} Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
23
frontend/src/lib/graphql/account/createAccount.graphql
Normal file
23
frontend/src/lib/graphql/account/createAccount.graphql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
mutation CreateAccount($input: CreateAccountInput!) {
|
||||||
|
createAccount(input: $input) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
customerId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
streetAddress
|
||||||
|
city
|
||||||
|
state
|
||||||
|
zipCode
|
||||||
|
primaryContactFirstName
|
||||||
|
primaryContactLastName
|
||||||
|
primaryContactEmail
|
||||||
|
primaryContactPhone
|
||||||
|
secondaryContactFirstName
|
||||||
|
secondaryContactLastName
|
||||||
|
secondaryContactEmail
|
||||||
|
secondaryContactPhone
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/account/deleteAccount.graphql
Normal file
3
frontend/src/lib/graphql/account/deleteAccount.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteAccount($id: ID!) {
|
||||||
|
deleteAccount(id: $id)
|
||||||
|
}
|
||||||
23
frontend/src/lib/graphql/account/getAccount.graphql
Normal file
23
frontend/src/lib/graphql/account/getAccount.graphql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
query GetAccount($id: ID!) {
|
||||||
|
account(id: $id) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
customerId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
streetAddress
|
||||||
|
city
|
||||||
|
state
|
||||||
|
zipCode
|
||||||
|
primaryContactFirstName
|
||||||
|
primaryContactLastName
|
||||||
|
primaryContactEmail
|
||||||
|
primaryContactPhone
|
||||||
|
secondaryContactFirstName
|
||||||
|
secondaryContactLastName
|
||||||
|
secondaryContactEmail
|
||||||
|
secondaryContactPhone
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/lib/graphql/account/getAccounts.graphql
Normal file
15
frontend/src/lib/graphql/account/getAccounts.graphql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
query GetAccounts {
|
||||||
|
accounts {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
customerId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
primaryContactFirstName
|
||||||
|
primaryContactLastName
|
||||||
|
primaryContactEmail
|
||||||
|
primaryContactPhone
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/src/lib/graphql/account/updateAccount.graphql
Normal file
23
frontend/src/lib/graphql/account/updateAccount.graphql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) {
|
||||||
|
updateAccount(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
customerId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
streetAddress
|
||||||
|
city
|
||||||
|
state
|
||||||
|
zipCode
|
||||||
|
primaryContactFirstName
|
||||||
|
primaryContactLastName
|
||||||
|
primaryContactEmail
|
||||||
|
primaryContactPhone
|
||||||
|
secondaryContactFirstName
|
||||||
|
secondaryContactLastName
|
||||||
|
secondaryContactEmail
|
||||||
|
secondaryContactPhone
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/lib/graphql/customer/createCustomer.graphql
Normal file
26
frontend/src/lib/graphql/customer/createCustomer.graphql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
mutation CreateCustomer($input: CreateCustomerInput!) {
|
||||||
|
createCustomer(input: $input) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
primaryContactFirstName
|
||||||
|
primaryContactLastName
|
||||||
|
primaryContactEmail
|
||||||
|
primaryContactPhone
|
||||||
|
secondaryContactFirstName
|
||||||
|
secondaryContactLastName
|
||||||
|
secondaryContactEmail
|
||||||
|
secondaryContactPhone
|
||||||
|
billingContactFirstName
|
||||||
|
billingContactLastName
|
||||||
|
billingEmail
|
||||||
|
billingStreetAddress
|
||||||
|
billingCity
|
||||||
|
billingState
|
||||||
|
billingZipCode
|
||||||
|
billingTerms
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/customer/deleteCustomer.graphql
Normal file
3
frontend/src/lib/graphql/customer/deleteCustomer.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteCustomer($id: ID!) {
|
||||||
|
deleteCustomer(id: $id)
|
||||||
|
}
|
||||||
26
frontend/src/lib/graphql/customer/getCustomer.graphql
Normal file
26
frontend/src/lib/graphql/customer/getCustomer.graphql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
query GetCustomer($id: ID!) {
|
||||||
|
customer(id: $id) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
primaryContactFirstName
|
||||||
|
primaryContactLastName
|
||||||
|
primaryContactEmail
|
||||||
|
primaryContactPhone
|
||||||
|
secondaryContactFirstName
|
||||||
|
secondaryContactLastName
|
||||||
|
secondaryContactEmail
|
||||||
|
secondaryContactPhone
|
||||||
|
billingContactFirstName
|
||||||
|
billingContactLastName
|
||||||
|
billingEmail
|
||||||
|
billingStreetAddress
|
||||||
|
billingCity
|
||||||
|
billingState
|
||||||
|
billingZipCode
|
||||||
|
billingTerms
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/customer/getCustomers.graphql
Normal file
14
frontend/src/lib/graphql/customer/getCustomers.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
query GetCustomers {
|
||||||
|
customers {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
primaryContactFirstName
|
||||||
|
primaryContactLastName
|
||||||
|
primaryContactEmail
|
||||||
|
primaryContactPhone
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/lib/graphql/customer/updateCustomer.graphql
Normal file
26
frontend/src/lib/graphql/customer/updateCustomer.graphql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
mutation UpdateCustomer($id: ID!, $input: UpdateCustomerInput!) {
|
||||||
|
updateCustomer(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
primaryContactFirstName
|
||||||
|
primaryContactLastName
|
||||||
|
primaryContactEmail
|
||||||
|
primaryContactPhone
|
||||||
|
secondaryContactFirstName
|
||||||
|
secondaryContactLastName
|
||||||
|
secondaryContactEmail
|
||||||
|
secondaryContactPhone
|
||||||
|
billingContactFirstName
|
||||||
|
billingContactLastName
|
||||||
|
billingEmail
|
||||||
|
billingStreetAddress
|
||||||
|
billingCity
|
||||||
|
billingState
|
||||||
|
billingZipCode
|
||||||
|
billingTerms
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/invoice/createInvoice.graphql
Normal file
14
frontend/src/lib/graphql/invoice/createInvoice.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mutation CreateInvoice($input: CreateInvoiceInput!) {
|
||||||
|
createInvoice(input: $input) {
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
date
|
||||||
|
datePaid
|
||||||
|
paymentType
|
||||||
|
sentAt
|
||||||
|
status
|
||||||
|
totalAmount
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/invoice/deleteInvoice.graphql
Normal file
3
frontend/src/lib/graphql/invoice/deleteInvoice.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteInvoice($id: ID!) {
|
||||||
|
deleteInvoice(id: $id)
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/invoice/getInvoice.graphql
Normal file
14
frontend/src/lib/graphql/invoice/getInvoice.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
query GetInvoice($id: ID!) {
|
||||||
|
invoice(id: $id) {
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
date
|
||||||
|
datePaid
|
||||||
|
paymentType
|
||||||
|
sentAt
|
||||||
|
status
|
||||||
|
totalAmount
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/src/lib/graphql/invoice/getInvoices.graphql
Normal file
12
frontend/src/lib/graphql/invoice/getInvoices.graphql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
query GetInvoices {
|
||||||
|
invoices {
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
date
|
||||||
|
status
|
||||||
|
totalAmount
|
||||||
|
datePaid
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/invoice/updateInvoice.graphql
Normal file
14
frontend/src/lib/graphql/invoice/updateInvoice.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mutation UpdateInvoice($id: ID!, $input: UpdateInvoiceInput!) {
|
||||||
|
updateInvoice(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
date
|
||||||
|
datePaid
|
||||||
|
paymentType
|
||||||
|
sentAt
|
||||||
|
status
|
||||||
|
totalAmount
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/labor/createLabor.graphql
Normal file
11
frontend/src/lib/graphql/labor/createLabor.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
mutation CreateLabor($input: CreateLaborInput!) {
|
||||||
|
createLabor(input: $input) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/labor/deleteLabor.graphql
Normal file
3
frontend/src/lib/graphql/labor/deleteLabor.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteLabor($id: ID!) {
|
||||||
|
deleteLabor(id: $id)
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/labor/getLabor.graphql
Normal file
11
frontend/src/lib/graphql/labor/getLabor.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
query GetLabor($id: ID!) {
|
||||||
|
labor(id: $id) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/labor/getLabors.graphql
Normal file
11
frontend/src/lib/graphql/labor/getLabors.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
query GetLabors {
|
||||||
|
labors {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/labor/laborsByAccount.graphql
Normal file
11
frontend/src/lib/graphql/labor/laborsByAccount.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
query LaborsByAccount($accountId: ID!) {
|
||||||
|
laborsByAccount(accountId: $accountId) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/labor/updateLabor.graphql
Normal file
11
frontend/src/lib/graphql/labor/updateLabor.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
mutation UpdateLabor($id: ID!, $input: UpdateLaborInput!) {
|
||||||
|
updateLabor(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/profile/createProfile.graphql
Normal file
14
frontend/src/lib/graphql/profile/createProfile.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mutation CreateProfile($input: CreateProfileInput!) {
|
||||||
|
createProfile(input: $input) {
|
||||||
|
id
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
email
|
||||||
|
primaryPhone
|
||||||
|
secondaryPhone
|
||||||
|
role
|
||||||
|
userId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/profile/deleteProfile.graphql
Normal file
3
frontend/src/lib/graphql/profile/deleteProfile.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteProfile($id: ID!) {
|
||||||
|
deleteProfile(id: $id)
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/profile/getProfile.graphql
Normal file
14
frontend/src/lib/graphql/profile/getProfile.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
query GetProfile($id: ID!) {
|
||||||
|
profile(id: $id) {
|
||||||
|
id
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
email
|
||||||
|
primaryPhone
|
||||||
|
secondaryPhone
|
||||||
|
role
|
||||||
|
userId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/profile/getProfiles.graphql
Normal file
14
frontend/src/lib/graphql/profile/getProfiles.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
query GetProfiles {
|
||||||
|
profiles {
|
||||||
|
id
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
email
|
||||||
|
primaryPhone
|
||||||
|
secondaryPhone
|
||||||
|
role
|
||||||
|
userId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/profile/updateProfile.graphql
Normal file
14
frontend/src/lib/graphql/profile/updateProfile.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mutation UpdateProfile($id: ID!, $input: UpdateProfileInput!) {
|
||||||
|
updateProfile(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
email
|
||||||
|
primaryPhone
|
||||||
|
secondaryPhone
|
||||||
|
role
|
||||||
|
userId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/lib/graphql/project/createProject.graphql
Normal file
15
frontend/src/lib/graphql/project/createProject.graphql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
mutation CreateProject($input: CreateProjectInput!) {
|
||||||
|
createProject(input: $input) {
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
labor
|
||||||
|
amount
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/project/deleteProject.graphql
Normal file
3
frontend/src/lib/graphql/project/deleteProject.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteProject($id: ID!) {
|
||||||
|
deleteProject(id: $id)
|
||||||
|
}
|
||||||
15
frontend/src/lib/graphql/project/getProject.graphql
Normal file
15
frontend/src/lib/graphql/project/getProject.graphql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
query GetProject($id: ID!) {
|
||||||
|
project(id: $id) {
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
labor
|
||||||
|
amount
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/lib/graphql/project/getProjects.graphql
Normal file
15
frontend/src/lib/graphql/project/getProjects.graphql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
query GetProjects {
|
||||||
|
projects {
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
labor
|
||||||
|
amount
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/lib/graphql/project/projectsByAccount.graphql
Normal file
15
frontend/src/lib/graphql/project/projectsByAccount.graphql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
query ProjectsByAccount($accountId: ID!) {
|
||||||
|
projectsByAccount(accountId: $accountId) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
customerId
|
||||||
|
date
|
||||||
|
status
|
||||||
|
amount
|
||||||
|
labor
|
||||||
|
completedAt
|
||||||
|
notes
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/lib/graphql/project/updateProject.graphql
Normal file
15
frontend/src/lib/graphql/project/updateProject.graphql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
mutation UpdateProject($id: ID!, $input: UpdateProjectInput!) {
|
||||||
|
updateProject(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
labor
|
||||||
|
amount
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/lib/graphql/report/createReport.graphql
Normal file
10
frontend/src/lib/graphql/report/createReport.graphql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
mutation CreateReport($input: CreateReportInput!) {
|
||||||
|
createReport(input: $input) {
|
||||||
|
id
|
||||||
|
teamMemberId
|
||||||
|
date
|
||||||
|
notes
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/report/deleteReport.graphql
Normal file
3
frontend/src/lib/graphql/report/deleteReport.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteReport($id: ID!) {
|
||||||
|
deleteReport(id: $id)
|
||||||
|
}
|
||||||
10
frontend/src/lib/graphql/report/getReport.graphql
Normal file
10
frontend/src/lib/graphql/report/getReport.graphql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
query GetReport($id: ID!) {
|
||||||
|
report(id: $id) {
|
||||||
|
id
|
||||||
|
teamMemberId
|
||||||
|
date
|
||||||
|
notes
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/lib/graphql/report/getReports.graphql
Normal file
10
frontend/src/lib/graphql/report/getReports.graphql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
query GetReports {
|
||||||
|
reports {
|
||||||
|
id
|
||||||
|
teamMemberId
|
||||||
|
date
|
||||||
|
notes
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/lib/graphql/report/updateReport.graphql
Normal file
10
frontend/src/lib/graphql/report/updateReport.graphql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
mutation UpdateReport($id: ID!, $input: UpdateReportInput!) {
|
||||||
|
updateReport(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
teamMemberId
|
||||||
|
date
|
||||||
|
notes
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/revenue/createRevenue.graphql
Normal file
11
frontend/src/lib/graphql/revenue/createRevenue.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
mutation CreateRevenue($input: CreateRevenueInput!) {
|
||||||
|
createRevenue(input: $input) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/revenue/deleteRevenue.graphql
Normal file
3
frontend/src/lib/graphql/revenue/deleteRevenue.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteRevenue($id: ID!) {
|
||||||
|
deleteRevenue(id: $id)
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/revenue/getRevenue.graphql
Normal file
11
frontend/src/lib/graphql/revenue/getRevenue.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
query GetRevenue($id: ID!) {
|
||||||
|
revenue(id: $id) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/revenue/getRevenues.graphql
Normal file
11
frontend/src/lib/graphql/revenue/getRevenues.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
query GetRevenues($limit: Int!, $offset: Int!) {
|
||||||
|
revenues(limit: $limit, offset: $offset) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/revenue/revenuesByAccount.graphql
Normal file
11
frontend/src/lib/graphql/revenue/revenuesByAccount.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
query RevenuesByAccount($accountId: ID!) {
|
||||||
|
revenuesByAccount(accountId: $accountId) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/lib/graphql/revenue/updateRevenue.graphql
Normal file
11
frontend/src/lib/graphql/revenue/updateRevenue.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
mutation UpdateRevenue($id: ID!, $input: UpdateRevenueInput!) {
|
||||||
|
updateRevenue(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
amount
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/src/lib/graphql/schedule/createSchedule.graphql
Normal file
19
frontend/src/lib/graphql/schedule/createSchedule.graphql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
mutation CreateSchedule($input: CreateScheduleInput!) {
|
||||||
|
createSchedule(input: $input) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
mondayService
|
||||||
|
tuesdayService
|
||||||
|
wednesdayService
|
||||||
|
thursdayService
|
||||||
|
fridayService
|
||||||
|
saturdayService
|
||||||
|
sundayService
|
||||||
|
weekendService
|
||||||
|
scheduleException
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/schedule/deleteSchedule.graphql
Normal file
3
frontend/src/lib/graphql/schedule/deleteSchedule.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteSchedule($id: ID!) {
|
||||||
|
deleteSchedule(id: $id)
|
||||||
|
}
|
||||||
19
frontend/src/lib/graphql/schedule/getSchedule.graphql
Normal file
19
frontend/src/lib/graphql/schedule/getSchedule.graphql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
query GetSchedule($id: ID!) {
|
||||||
|
schedule(id: $id) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
mondayService
|
||||||
|
tuesdayService
|
||||||
|
wednesdayService
|
||||||
|
thursdayService
|
||||||
|
fridayService
|
||||||
|
saturdayService
|
||||||
|
sundayService
|
||||||
|
weekendService
|
||||||
|
scheduleException
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/src/lib/graphql/schedule/getSchedules.graphql
Normal file
19
frontend/src/lib/graphql/schedule/getSchedules.graphql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
query GetSchedules($limit: Int!, $offset: Int!) {
|
||||||
|
schedules(limit: $limit, offset: $offset) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
mondayService
|
||||||
|
tuesdayService
|
||||||
|
wednesdayService
|
||||||
|
thursdayService
|
||||||
|
fridayService
|
||||||
|
saturdayService
|
||||||
|
sundayService
|
||||||
|
weekendService
|
||||||
|
scheduleException
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/src/lib/graphql/schedule/schedulesByAccount.graphql
Normal file
19
frontend/src/lib/graphql/schedule/schedulesByAccount.graphql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
query SchedulesByAccount($accountId: ID!) {
|
||||||
|
schedulesByAccount(accountId: $accountId) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
mondayService
|
||||||
|
tuesdayService
|
||||||
|
wednesdayService
|
||||||
|
thursdayService
|
||||||
|
fridayService
|
||||||
|
saturdayService
|
||||||
|
sundayService
|
||||||
|
weekendService
|
||||||
|
scheduleException
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/src/lib/graphql/schedule/updateSchedule.graphql
Normal file
19
frontend/src/lib/graphql/schedule/updateSchedule.graphql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
mutation UpdateSchedule($id: ID!, $input: UpdateScheduleInput!) {
|
||||||
|
updateSchedule(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
mondayService
|
||||||
|
tuesdayService
|
||||||
|
wednesdayService
|
||||||
|
thursdayService
|
||||||
|
fridayService
|
||||||
|
saturdayService
|
||||||
|
sundayService
|
||||||
|
weekendService
|
||||||
|
scheduleException
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/service/createService.graphql
Normal file
14
frontend/src/lib/graphql/service/createService.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mutation CreateService($input: CreateServiceInput!) {
|
||||||
|
createService(input: $input) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
deadlineStart
|
||||||
|
deadlineEnd
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
completedAt
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/lib/graphql/service/deleteService.graphql
Normal file
3
frontend/src/lib/graphql/service/deleteService.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteService($id: ID!) {
|
||||||
|
deleteService(id: $id)
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/service/getService.graphql
Normal file
14
frontend/src/lib/graphql/service/getService.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
query GetService($id: ID!) {
|
||||||
|
service(id: $id) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
deadlineStart
|
||||||
|
deadlineEnd
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
completedAt
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/service/getServices.graphql
Normal file
14
frontend/src/lib/graphql/service/getServices.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
query GetServices {
|
||||||
|
services {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
deadlineStart
|
||||||
|
deadlineEnd
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
completedAt
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/service/servicesByAccount.graphql
Normal file
14
frontend/src/lib/graphql/service/servicesByAccount.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
query ServicesByAccount($accountId: ID!) {
|
||||||
|
servicesByAccount(accountId: $accountId) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
status
|
||||||
|
completedAt
|
||||||
|
deadlineStart
|
||||||
|
deadlineEnd
|
||||||
|
notes
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/lib/graphql/service/updateService.graphql
Normal file
14
frontend/src/lib/graphql/service/updateService.graphql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mutation UpdateService($id: ID!, $input: UpdateServiceInput!) {
|
||||||
|
updateService(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
accountId
|
||||||
|
date
|
||||||
|
deadlineStart
|
||||||
|
deadlineEnd
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
completedAt
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
59
frontend/src/lib/types/accounts.ts
Normal file
59
frontend/src/lib/types/accounts.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
customerId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
streetAddress: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zipCode: string;
|
||||||
|
primaryContactFirstName: string;
|
||||||
|
primaryContactLastName: string;
|
||||||
|
primaryContactEmail: string;
|
||||||
|
primaryContactPhone: string;
|
||||||
|
secondaryContactFirstName: string | null;
|
||||||
|
secondaryContactLastName: string | null;
|
||||||
|
secondaryContactEmail: string | null;
|
||||||
|
secondaryContactPhone: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAccountInput {
|
||||||
|
name: string;
|
||||||
|
customerId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
streetAddress: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zipCode: string;
|
||||||
|
primaryContactFirstName: string;
|
||||||
|
primaryContactLastName: string;
|
||||||
|
primaryContactEmail: string;
|
||||||
|
primaryContactPhone: string;
|
||||||
|
secondaryContactFirstName?: string | null;
|
||||||
|
secondaryContactLastName?: string | null;
|
||||||
|
secondaryContactEmail?: string | null;
|
||||||
|
secondaryContactPhone?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAccountInput {
|
||||||
|
name?: string;
|
||||||
|
customerId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
streetAddress?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zipCode?: string;
|
||||||
|
primaryContactFirstName?: string;
|
||||||
|
primaryContactLastName?: string;
|
||||||
|
primaryContactEmail?: string;
|
||||||
|
primaryContactPhone?: string;
|
||||||
|
secondaryContactFirstName?: string | null;
|
||||||
|
secondaryContactLastName?: string | null;
|
||||||
|
secondaryContactEmail?: string | null;
|
||||||
|
secondaryContactPhone?: string | null;
|
||||||
|
}
|
||||||
68
frontend/src/lib/types/customers.ts
Normal file
68
frontend/src/lib/types/customers.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export interface Customer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
primaryContactFirstName: string;
|
||||||
|
primaryContactLastName: string;
|
||||||
|
primaryContactEmail: string;
|
||||||
|
primaryContactPhone: string;
|
||||||
|
secondaryContactFirstName: string | null;
|
||||||
|
secondaryContactLastName: string | null;
|
||||||
|
secondaryContactEmail: string | null;
|
||||||
|
secondaryContactPhone: string | null;
|
||||||
|
billingContactFirstName: string;
|
||||||
|
billingContactLastName: string;
|
||||||
|
billingEmail: string;
|
||||||
|
billingStreetAddress: string;
|
||||||
|
billingCity: string;
|
||||||
|
billingState: string;
|
||||||
|
billingZipCode: string;
|
||||||
|
billingTerms: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomerInput {
|
||||||
|
name: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
primaryContactFirstName: string;
|
||||||
|
primaryContactLastName: string;
|
||||||
|
primaryContactEmail: string;
|
||||||
|
primaryContactPhone: string;
|
||||||
|
secondaryContactFirstName?: string | null;
|
||||||
|
secondaryContactLastName?: string | null;
|
||||||
|
secondaryContactEmail?: string | null;
|
||||||
|
secondaryContactPhone?: string | null;
|
||||||
|
billingContactFirstName: string;
|
||||||
|
billingContactLastName: string;
|
||||||
|
billingEmail: string;
|
||||||
|
billingStreetAddress: string;
|
||||||
|
billingCity: string;
|
||||||
|
billingState: string;
|
||||||
|
billingZipCode: string;
|
||||||
|
billingTerms: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCustomerInput {
|
||||||
|
name?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
primaryContactFirstName?: string;
|
||||||
|
primaryContactLastName?: string;
|
||||||
|
primaryContactEmail?: string;
|
||||||
|
primaryContactPhone?: string;
|
||||||
|
secondaryContactFirstName?: string | null;
|
||||||
|
secondaryContactLastName?: string | null;
|
||||||
|
secondaryContactEmail?: string | null;
|
||||||
|
secondaryContactPhone?: string | null;
|
||||||
|
billingContactFirstName?: string;
|
||||||
|
billingContactLastName?: string;
|
||||||
|
billingEmail?: string;
|
||||||
|
billingStreetAddress?: string;
|
||||||
|
billingCity?: string;
|
||||||
|
billingState?: string;
|
||||||
|
billingZipCode?: string;
|
||||||
|
billingTerms?: string;
|
||||||
|
}
|
||||||
32
frontend/src/lib/types/invoices.ts
Normal file
32
frontend/src/lib/types/invoices.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
date: string;
|
||||||
|
totalAmount: string;
|
||||||
|
status: string;
|
||||||
|
datePaid: string | null;
|
||||||
|
paymentType: string | null;
|
||||||
|
sentAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceInput {
|
||||||
|
customerId: string;
|
||||||
|
date: string;
|
||||||
|
totalAmount: string;
|
||||||
|
status: string;
|
||||||
|
datePaid?: string | null;
|
||||||
|
paymentType?: string | null;
|
||||||
|
sentAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInvoiceInput {
|
||||||
|
customerId?: string;
|
||||||
|
date?: string;
|
||||||
|
totalAmount?: string;
|
||||||
|
status?: string;
|
||||||
|
datePaid?: string | null;
|
||||||
|
paymentType?: string | null;
|
||||||
|
sentAt?: string | null;
|
||||||
|
}
|
||||||
23
frontend/src/lib/types/labors.ts
Normal file
23
frontend/src/lib/types/labors.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface Labor {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
amount: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLaborInput {
|
||||||
|
accountId: string;
|
||||||
|
amount: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLaborInput {
|
||||||
|
accountId?: string;
|
||||||
|
amount?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
}
|
||||||
32
frontend/src/lib/types/profiles.ts
Normal file
32
frontend/src/lib/types/profiles.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
primaryPhone: string;
|
||||||
|
secondaryPhone: string | null;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProfileInput {
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
primaryPhone: string;
|
||||||
|
secondaryPhone?: string | null;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileInput {
|
||||||
|
userId?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
primaryPhone?: string;
|
||||||
|
secondaryPhone?: string | null;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
35
frontend/src/lib/types/projects.ts
Normal file
35
frontend/src/lib/types/projects.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
accountId: string | null;
|
||||||
|
date: string;
|
||||||
|
amount: string;
|
||||||
|
labor: string;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectInput {
|
||||||
|
customerId: string;
|
||||||
|
accountId?: string | null;
|
||||||
|
date: string;
|
||||||
|
amount: string;
|
||||||
|
labor: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectInput {
|
||||||
|
customerId?: string;
|
||||||
|
accountId?: string | null;
|
||||||
|
date?: string;
|
||||||
|
amount?: string;
|
||||||
|
labor?: string;
|
||||||
|
status?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
}
|
||||||
20
frontend/src/lib/types/reports.ts
Normal file
20
frontend/src/lib/types/reports.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
teamMemberId: string;
|
||||||
|
date: string;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReportInput {
|
||||||
|
teamMemberId: string;
|
||||||
|
date: string;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateReportInput {
|
||||||
|
teamMemberId?: string;
|
||||||
|
date?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
23
frontend/src/lib/types/revenues.ts
Normal file
23
frontend/src/lib/types/revenues.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface Revenue {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
amount: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRevenueInput {
|
||||||
|
accountId: string;
|
||||||
|
amount: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRevenueInput {
|
||||||
|
accountId?: string;
|
||||||
|
amount?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
}
|
||||||
47
frontend/src/lib/types/schedules.ts
Normal file
47
frontend/src/lib/types/schedules.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
export interface Schedule {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
mondayService: boolean;
|
||||||
|
tuesdayService: boolean;
|
||||||
|
wednesdayService: boolean;
|
||||||
|
thursdayService: boolean;
|
||||||
|
fridayService: boolean;
|
||||||
|
saturdayService: boolean;
|
||||||
|
sundayService: boolean;
|
||||||
|
weekendService: boolean;
|
||||||
|
scheduleException: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScheduleInput {
|
||||||
|
accountId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
mondayService: boolean;
|
||||||
|
tuesdayService: boolean;
|
||||||
|
wednesdayService: boolean;
|
||||||
|
thursdayService: boolean;
|
||||||
|
fridayService: boolean;
|
||||||
|
saturdayService: boolean;
|
||||||
|
sundayService: boolean;
|
||||||
|
weekendService: boolean;
|
||||||
|
scheduleException?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScheduleInput {
|
||||||
|
accountId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
mondayService?: boolean;
|
||||||
|
tuesdayService?: boolean;
|
||||||
|
wednesdayService?: boolean;
|
||||||
|
thursdayService?: boolean;
|
||||||
|
fridayService?: boolean;
|
||||||
|
saturdayService?: boolean;
|
||||||
|
sundayService?: boolean;
|
||||||
|
weekendService?: boolean;
|
||||||
|
scheduleException?: string | null;
|
||||||
|
}
|
||||||
32
frontend/src/lib/types/services.ts
Normal file
32
frontend/src/lib/types/services.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
date: string;
|
||||||
|
deadlineStart: string;
|
||||||
|
deadlineEnd: string;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateServiceInput {
|
||||||
|
accountId: string;
|
||||||
|
date: string;
|
||||||
|
deadlineStart: string;
|
||||||
|
deadlineEnd: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateServiceInput {
|
||||||
|
accountId?: string;
|
||||||
|
date?: string;
|
||||||
|
deadlineStart?: string;
|
||||||
|
deadlineEnd?: string;
|
||||||
|
status?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
}
|
||||||
16
frontend/src/routes/+layout.svelte
Normal file
16
frontend/src/routes/+layout.svelte
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col min-h-screen">
|
||||||
|
{#if page.url.pathname !== '/' && page.url.pathname !== '/login'}
|
||||||
|
<Sidebar />
|
||||||
|
{/if}
|
||||||
|
<main class="flex-1 flex flex-col w-full">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
32
frontend/src/routes/+page.svelte
Normal file
32
frontend/src/routes/+page.svelte
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
function navigateToLogin() {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gradient-to-b from-blue-50 to-green-50 flex flex-col items-center justify-center p-4">
|
||||||
|
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
|
||||||
|
<h1 class="text-4xl font-bold text-blue-600 mb-2">Welcome to Nexus</h1>
|
||||||
|
<p class="text-xl text-green-600 mb-8">Your next-generation application platform</p>
|
||||||
|
|
||||||
|
<p class="text-gray-600 mb-8">
|
||||||
|
Nexus provides a powerful, scalable foundation for your applications with
|
||||||
|
integrated GraphQL, authentication, and more.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={navigateToLogin}
|
||||||
|
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
|
||||||
|
transition-colors duration-300 shadow-md hover:shadow-lg focus:outline-none
|
||||||
|
focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||||
|
>
|
||||||
|
Login to Get Started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-8 text-center text-gray-500">
|
||||||
|
<p>© 2025 Nexus Platform. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
132
frontend/src/routes/accounts/+page.svelte
Normal file
132
frontend/src/routes/accounts/+page.svelte
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {GetAccountsStore} from '$houdini';
|
||||||
|
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
|
||||||
|
// Initialize the GraphQL query
|
||||||
|
const accountsStore = new GetAccountsStore();
|
||||||
|
|
||||||
|
// Fetch accounts data
|
||||||
|
onMount(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await accountsStore.fetch();
|
||||||
|
isLoading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching accounts:', error);
|
||||||
|
errorMessage = 'Failed to load accounts data';
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to format date
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the account detail page
|
||||||
|
function viewAccount(id: string) {
|
||||||
|
goto(`/accounts/${id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary">Accounts</h1>
|
||||||
|
<a
|
||||||
|
href="/accounts/new"
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Add New Account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center items-center h-64">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if $accountsStore.data?.accounts && $accountsStore.data.accounts.length > 0}
|
||||||
|
<div class="overflow-x-auto bg-white rounded-lg shadow">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer ID
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Primary
|
||||||
|
Contact
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">End Date
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
{#each $accountsStore.data.accounts as account (account.id)}
|
||||||
|
<tr class="hover:bg-gray-50 cursor-pointer" onclick={() => viewAccount(account.id)}>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{account.name}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500">{account.customerId}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-900">{account.primaryContactFirstName} {account.primaryContactLastName}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500">{account.primaryContactEmail}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500">{formatDate(account.startDate)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500">{formatDate(account.endDate)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<a href={`/accounts/${account.id}/edit`} class="text-primary hover:text-primary-dark mr-3"
|
||||||
|
onclick={(e) => e.stopPropagation()}>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">No accounts found.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/home" class="text-primary hover:text-primary-light font-medium">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
600
frontend/src/routes/accounts/[id]/+page.svelte
Normal file
600
frontend/src/routes/accounts/[id]/+page.svelte
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
import {GetAccountStore, DeleteAccountStore, SchedulesByAccountStore, LaborsByAccountStore, RevenuesByAccountStore, ServicesByAccountStore, ProjectsByAccountStore} from '$houdini';
|
||||||
|
|
||||||
|
let accountId = $state('');
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let successMessage = $state('');
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
|
||||||
|
// Initialize the GraphQL queries
|
||||||
|
const accountStore = new GetAccountStore();
|
||||||
|
const schedulesStore = new SchedulesByAccountStore();
|
||||||
|
const laborsStore = new LaborsByAccountStore();
|
||||||
|
const revenuesStore = new RevenuesByAccountStore();
|
||||||
|
const servicesStore = new ServicesByAccountStore();
|
||||||
|
const projectsStore = new ProjectsByAccountStore();
|
||||||
|
|
||||||
|
// Fetch account data using the ID from the URL
|
||||||
|
onMount(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accountId = page.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load the account data using the ID
|
||||||
|
await accountStore.fetch({variables: {id: accountId}});
|
||||||
|
|
||||||
|
// Load related data
|
||||||
|
await Promise.all([
|
||||||
|
schedulesStore.fetch({variables: {accountId}}),
|
||||||
|
laborsStore.fetch({variables: {accountId}}),
|
||||||
|
revenuesStore.fetch({variables: {accountId}}),
|
||||||
|
servicesStore.fetch({variables: {accountId}}),
|
||||||
|
projectsStore.fetch({variables: {accountId}})
|
||||||
|
]);
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching account data:', error);
|
||||||
|
errorMessage = 'Failed to load account data';
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the delete mutation store
|
||||||
|
const deleteAccountStore = new DeleteAccountStore();
|
||||||
|
|
||||||
|
// Function to format date
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to determine if an item is active based on dates
|
||||||
|
function isActive(startDate: string, endDate: string | null): boolean {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0); // Set to beginning of day for accurate comparison
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Item is active if today is after or equal to start date
|
||||||
|
// AND either there is no end date or today is before or equal to end date
|
||||||
|
return today >= start && (!endDate || today <= new Date(endDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to handle delete account
|
||||||
|
async function handleDeleteAccount() {
|
||||||
|
try {
|
||||||
|
// Clear any previous messages
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
|
||||||
|
// Execute the delete mutation
|
||||||
|
const result = await deleteAccountStore.mutate({ id: accountId });
|
||||||
|
|
||||||
|
if (result.data?.deleteAccount) {
|
||||||
|
successMessage = 'Account deleted successfully';
|
||||||
|
|
||||||
|
// Navigate back to the accounts list after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
goto('/accounts');
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
errorMessage = 'Failed to delete account';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting account:', error);
|
||||||
|
errorMessage = 'Failed to delete account';
|
||||||
|
} finally {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to show delete confirmation
|
||||||
|
function confirmDelete() {
|
||||||
|
showDeleteConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to cancel delete
|
||||||
|
function cancelDelete() {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary mb-8">Account Details</h1>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center items-center h-64">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if successMessage}
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if $accountStore.data?.account}
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold">{$accountStore.data.account.name}</h2>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/edit`}
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Edit Account
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onclick={confirmDelete}
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium mb-3 text-gray-900">Account Information</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Customer ID</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.customerId}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Start Date</p>
|
||||||
|
<p class="font-medium">{formatDate($accountStore.data.account.startDate)}</p>
|
||||||
|
</div>
|
||||||
|
{#if $accountStore.data.account.endDate}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">End Date</p>
|
||||||
|
<p class="font-medium">{formatDate($accountStore.data.account.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium mb-3 text-gray-900">Primary Contact</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Name</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.primaryContactFirstName} {$accountStore.data.account.primaryContactLastName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Email</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.primaryContactEmail}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Phone</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.primaryContactPhone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $accountStore.data.account.secondaryContactFirstName}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium mb-3 text-gray-900">Secondary Contact</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Name</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.secondaryContactFirstName} {$accountStore.data.account.secondaryContactLastName}</p>
|
||||||
|
</div>
|
||||||
|
{#if $accountStore.data.account.secondaryContactEmail}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Email</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.secondaryContactEmail}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if $accountStore.data.account.secondaryContactPhone}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Phone</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.secondaryContactPhone}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium mb-3 text-gray-900">Address Information</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Street Address</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.streetAddress}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">City, State, Zip</p>
|
||||||
|
<p class="font-medium">{$accountStore.data.account.city}, {$accountStore.data.account.state} {$accountStore.data.account.zipCode}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-medium mb-2">System Information</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Account ID</p>
|
||||||
|
<p class="font-mono text-sm">{$accountStore.data.account.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Created</p>
|
||||||
|
<p class="text-sm">{new Date($accountStore.data.account.createdAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Last Updated</p>
|
||||||
|
<p class="text-sm">{new Date($accountStore.data.account.updatedAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekly Schedule Section -->
|
||||||
|
<div class="mt-8 border-t pt-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Weekly Schedule</h2>
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/schedules/new`}
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Add Schedule
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $schedulesStore.data?.schedulesByAccount && $schedulesStore.data.schedulesByAccount.length > 0}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{#each $schedulesStore.data.schedulesByAccount as schedule (schedule.id)}
|
||||||
|
{@const active = isActive(schedule.startDate, schedule.endDate)}
|
||||||
|
<div class="bg-white border rounded-lg shadow-sm p-4 relative">
|
||||||
|
{#if active}
|
||||||
|
<span class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded">Active</span>
|
||||||
|
{:else}
|
||||||
|
<span class="absolute top-2 right-2 px-2 py-1 bg-gray-100 text-gray-800 text-xs font-medium rounded">Inactive</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-sm text-gray-500">Date Range</p>
|
||||||
|
<p class="font-medium">{formatDate(schedule.startDate)} - {formatDate(schedule.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-sm text-gray-500">Service Days</p>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${schedule.mondayService ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500'}`}>Mon</span>
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${schedule.tuesdayService ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500'}`}>Tue</span>
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${schedule.wednesdayService ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500'}`}>Wed</span>
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${schedule.thursdayService ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500'}`}>Thu</span>
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${schedule.fridayService ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500'}`}>Fri</span>
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${schedule.saturdayService ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500'}`}>Sat</span>
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${schedule.sundayService ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500'}`}>Sun</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if schedule.scheduleException}
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-sm text-gray-500">Exceptions</p>
|
||||||
|
<p class="text-sm">{schedule.scheduleException}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex space-x-2 mt-3">
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/schedules/${schedule.id}/edit`}
|
||||||
|
class="text-sm text-primary hover:text-primary-dark"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="text-sm text-red-600 hover:text-red-800"
|
||||||
|
onclick={() => {/* TODO: Implement delete schedule */}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-50 p-4 rounded text-center">
|
||||||
|
<p class="text-gray-500">No schedules found for this account.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Labor Section -->
|
||||||
|
<div class="mt-8 border-t pt-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Labor</h2>
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/labors/new`}
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Add Labor
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $laborsStore.data?.laborsByAccount && $laborsStore.data.laborsByAccount.length > 0}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{#each $laborsStore.data.laborsByAccount as labor (labor.id)}
|
||||||
|
{@const active = isActive(labor.startDate, labor.endDate)}
|
||||||
|
<div class="bg-white border rounded-lg shadow-sm p-4 relative">
|
||||||
|
{#if active}
|
||||||
|
<span class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded">Active</span>
|
||||||
|
{:else}
|
||||||
|
<span class="absolute top-2 right-2 px-2 py-1 bg-gray-100 text-gray-800 text-xs font-medium rounded">Inactive</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-sm text-gray-500">Amount</p>
|
||||||
|
<p class="font-medium">${labor.amount}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-sm text-gray-500">Date Range</p>
|
||||||
|
<p class="font-medium">{formatDate(labor.startDate)} - {formatDate(labor.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-2 mt-3">
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/labors/${labor.id}/edit`}
|
||||||
|
class="text-sm text-primary hover:text-primary-dark"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="text-sm text-red-600 hover:text-red-800"
|
||||||
|
onclick={() => {/* TODO: Implement delete labor */}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-50 p-4 rounded text-center">
|
||||||
|
<p class="text-gray-500">No labor records found for this account.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revenue Section -->
|
||||||
|
<div class="mt-8 border-t pt-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Monthly Revenue</h2>
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/revenues/new`}
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Add Revenue
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $revenuesStore.data?.revenuesByAccount && $revenuesStore.data.revenuesByAccount.length > 0}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{#each $revenuesStore.data.revenuesByAccount as revenue (revenue.id)}
|
||||||
|
{@const active = isActive(revenue.startDate, revenue.endDate)}
|
||||||
|
<div class="bg-white border rounded-lg shadow-sm p-4 relative">
|
||||||
|
{#if active}
|
||||||
|
<span class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded">Active</span>
|
||||||
|
{:else}
|
||||||
|
<span class="absolute top-2 right-2 px-2 py-1 bg-gray-100 text-gray-800 text-xs font-medium rounded">Inactive</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-sm text-gray-500">Monthly Amount</p>
|
||||||
|
<p class="font-medium">${revenue.amount}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-sm text-gray-500">Date Range</p>
|
||||||
|
<p class="font-medium">{formatDate(revenue.startDate)} - {formatDate(revenue.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-2 mt-3">
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/revenues/${revenue.id}/edit`}
|
||||||
|
class="text-sm text-primary hover:text-primary-dark"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="text-sm text-red-600 hover:text-red-800"
|
||||||
|
onclick={() => {/* TODO: Implement delete revenue */}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-50 p-4 rounded text-center">
|
||||||
|
<p class="text-gray-500">No revenue records found for this account.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upcoming Services Section -->
|
||||||
|
<div class="mt-8 border-t pt-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Upcoming Services</h2>
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/services/new`}
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Add Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $servicesStore.data?.servicesByAccount && $servicesStore.data.servicesByAccount.length > 0}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full bg-white border rounded-lg">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Deadline</th>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $servicesStore.data.servicesByAccount.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5) as service (service.id)}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="py-2 px-4 border-b">{formatDate(service.date)}</td>
|
||||||
|
<td class="py-2 px-4 border-b">
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${
|
||||||
|
service.status === 'Completed' ? 'bg-green-100 text-green-800' :
|
||||||
|
service.status === 'Scheduled' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
service.status === 'Pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{service.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4 border-b">
|
||||||
|
{new Date(service.deadlineStart).toLocaleString()} - {new Date(service.deadlineEnd).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4 border-b">
|
||||||
|
<a
|
||||||
|
href={`/services/${service.id}`}
|
||||||
|
class="text-primary hover:text-primary-dark mr-2"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if $servicesStore.data.servicesByAccount.length > 5}
|
||||||
|
<div class="mt-2 text-right">
|
||||||
|
<a href={`/accounts/${accountId}/services`} class="text-primary hover:text-primary-dark text-sm">
|
||||||
|
View all services
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-50 p-4 rounded text-center">
|
||||||
|
<p class="text-gray-500">No services found for this account.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects Section -->
|
||||||
|
<div class="mt-8 border-t pt-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Recent Projects</h2>
|
||||||
|
<a
|
||||||
|
href={`/accounts/${accountId}/projects/new`}
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Add Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $projectsStore.data?.projectsByAccount && $projectsStore.data.projectsByAccount.length > 0}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full bg-white border rounded-lg">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Labor</th>
|
||||||
|
<th class="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $projectsStore.data.projectsByAccount.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5) as project (project.id)}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="py-2 px-4 border-b">{formatDate(project.date)}</td>
|
||||||
|
<td class="py-2 px-4 border-b">
|
||||||
|
<span class={`px-2 py-1 text-xs font-medium rounded ${
|
||||||
|
project.status === 'Completed' ? 'bg-green-100 text-green-800' :
|
||||||
|
project.status === 'In Progress' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
project.status === 'Pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4 border-b">${project.amount}</td>
|
||||||
|
<td class="py-2 px-4 border-b">${project.labor}</td>
|
||||||
|
<td class="py-2 px-4 border-b">
|
||||||
|
<a
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
class="text-primary hover:text-primary-dark mr-2"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if $projectsStore.data.projectsByAccount.length > 5}
|
||||||
|
<div class="mt-2 text-right">
|
||||||
|
<a href={`/accounts/${accountId}/projects`} class="text-primary hover:text-primary-dark text-sm">
|
||||||
|
View all projects
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-50 p-4 rounded text-center">
|
||||||
|
<p class="text-gray-500">No projects found for this account.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Account not found.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/accounts" class="text-primary hover:text-primary-light font-medium">Back to Accounts</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDeleteConfirm}
|
||||||
|
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Confirm Delete</h3>
|
||||||
|
<p class="mb-6 text-gray-700">Are you sure you want to delete this account? This action cannot be undone.</p>
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onclick={cancelDelete}
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleDeleteAccount}
|
||||||
|
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
135
frontend/src/routes/accounts/[id]/edit/+page.svelte
Normal file
135
frontend/src/routes/accounts/[id]/edit/+page.svelte
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
import {type CreateAccountInput, GetAccountStore, UpdateAccountStore} from '$houdini';
|
||||||
|
import AccountForm from '$lib/components/accounts/AccountForm.svelte';
|
||||||
|
import type {Account, UpdateAccountInput} from '$lib/types/accounts';
|
||||||
|
|
||||||
|
let accountId = $state('');
|
||||||
|
let account = $state<Account | null>(null);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let successMessage = $state('');
|
||||||
|
|
||||||
|
// Initialize the GraphQL stores
|
||||||
|
const accountStore = new GetAccountStore();
|
||||||
|
const updateAccountStore = new UpdateAccountStore();
|
||||||
|
|
||||||
|
// Fetch account data using the ID from the URL
|
||||||
|
onMount(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accountId = page.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load the account data using the ID
|
||||||
|
await accountStore.fetch({variables: {id: accountId}});
|
||||||
|
|
||||||
|
if ($accountStore.data?.account) {
|
||||||
|
account = $accountStore.data.account;
|
||||||
|
} else {
|
||||||
|
errorMessage = 'Account not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching account:', error);
|
||||||
|
errorMessage = 'Failed to load account data';
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
async function handleSubmit(data: CreateAccountInput | (UpdateAccountInput & { id: string })) {
|
||||||
|
// Ensure we have an ID (we should always have one in edit mode)
|
||||||
|
if (!('id' in data)) {
|
||||||
|
console.error('ID is missing in edit mode');
|
||||||
|
errorMessage = 'Failed to update account: ID is missing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Clear any previous messages
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
|
||||||
|
// Update the account
|
||||||
|
await updateAccountStore.mutate({
|
||||||
|
id: data.id,
|
||||||
|
input: {
|
||||||
|
name: data.name,
|
||||||
|
customerId: data.customerId,
|
||||||
|
startDate: data.startDate,
|
||||||
|
endDate: data.endDate,
|
||||||
|
streetAddress: data.streetAddress,
|
||||||
|
city: data.city,
|
||||||
|
state: data.state,
|
||||||
|
zipCode: data.zipCode,
|
||||||
|
primaryContactFirstName: data.primaryContactFirstName,
|
||||||
|
primaryContactLastName: data.primaryContactLastName,
|
||||||
|
primaryContactEmail: data.primaryContactEmail,
|
||||||
|
primaryContactPhone: data.primaryContactPhone,
|
||||||
|
secondaryContactFirstName: data.secondaryContactFirstName,
|
||||||
|
secondaryContactLastName: data.secondaryContactLastName,
|
||||||
|
secondaryContactEmail: data.secondaryContactEmail,
|
||||||
|
secondaryContactPhone: data.secondaryContactPhone
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
successMessage = 'Account updated successfully';
|
||||||
|
|
||||||
|
// Navigate back to the account details page after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
goto(`/accounts/${accountId}`);
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating account:', error);
|
||||||
|
errorMessage = 'Failed to update account';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel
|
||||||
|
function handleCancel() {
|
||||||
|
goto(`/accounts/${accountId}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary mb-8">Edit Account</h1>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center items-center h-64">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if successMessage}
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if account}
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<AccountForm
|
||||||
|
account={account}
|
||||||
|
isEditing={true}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Account not found.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href={`/accounts/${accountId}`} class="text-primary hover:text-primary-light font-medium">Back to Account
|
||||||
|
Details</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
97
frontend/src/routes/accounts/new/+page.svelte
Normal file
97
frontend/src/routes/accounts/new/+page.svelte
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {CreateAccountStore, type UpdateAccountInput} from '$houdini';
|
||||||
|
import AccountForm from '$lib/components/accounts/AccountForm.svelte';
|
||||||
|
import type {CreateAccountInput} from '$lib/types/accounts';
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let successMessage = $state('');
|
||||||
|
|
||||||
|
// Initialize the GraphQL store
|
||||||
|
const createAccountStore = new CreateAccountStore();
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
onMount(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
async function handleSubmit(data: CreateAccountInput | (UpdateAccountInput & { id: string })) {
|
||||||
|
// On the new page, we should only receive CreateAccountInput
|
||||||
|
// But we need to handle both types to match the AccountForm component's onSubmit prop type
|
||||||
|
|
||||||
|
// Check if we received an object with an ID (which shouldn't happen in the new page)
|
||||||
|
if ('id' in data) {
|
||||||
|
console.error('Unexpected ID in create mode');
|
||||||
|
errorMessage = 'Failed to create account: Unexpected ID';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear any previous messages
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
// Create the account
|
||||||
|
const result = await createAccountStore.mutate({
|
||||||
|
input: data
|
||||||
|
});
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
successMessage = 'Account created successfully';
|
||||||
|
|
||||||
|
// Navigate to the new account details page after a short delay
|
||||||
|
if (result.data?.createAccount?.id) {
|
||||||
|
setTimeout(() => {
|
||||||
|
goto(`/accounts/${result.data?.createAccount.id}`);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating account:', error);
|
||||||
|
errorMessage = 'Failed to create account';
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel
|
||||||
|
function handleCancel() {
|
||||||
|
goto('/accounts');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary mb-8">Create New Account</h1>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center items-center h-64">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if successMessage}
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<AccountForm
|
||||||
|
isEditing={false}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/accounts" class="text-primary hover:text-primary-light font-medium">Back to Accounts</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
126
frontend/src/routes/customers/+page.svelte
Normal file
126
frontend/src/routes/customers/+page.svelte
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {GetCustomersStore} from '$houdini';
|
||||||
|
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
|
||||||
|
// Initialize the GraphQL query
|
||||||
|
const customersStore = new GetCustomersStore();
|
||||||
|
|
||||||
|
// Fetch customers data
|
||||||
|
onMount(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await customersStore.fetch();
|
||||||
|
isLoading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customers:', error);
|
||||||
|
errorMessage = 'Failed to load customers data';
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to format date
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to customer detail page
|
||||||
|
function viewCustomer(id: string) {
|
||||||
|
goto(`/customers/${id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary">Customers</h1>
|
||||||
|
<a
|
||||||
|
href="/customers/new"
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Add New Customer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center items-center h-64">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if $customersStore.data?.customers && $customersStore.data.customers.length > 0}
|
||||||
|
<div class="overflow-x-auto bg-white rounded-lg shadow">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Primary
|
||||||
|
Contact
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">End Date
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
{#each $customersStore.data.customers as customer (customer.id)}
|
||||||
|
<tr class="hover:bg-gray-50 cursor-pointer" onclick={() => viewCustomer(customer.id)}>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{customer.name}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-900">{customer.primaryContactFirstName} {customer.primaryContactLastName}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500">{customer.primaryContactEmail}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500">{formatDate(customer.startDate)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500">{formatDate(customer.endDate)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<a href={`/customers/${customer.id}/edit`} class="text-primary hover:text-primary-dark mr-3"
|
||||||
|
onclick={(e) => e.stopPropagation()}>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">No customers found.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/home" class="text-primary hover:text-primary-light font-medium">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
258
frontend/src/routes/customers/[id]/+page.svelte
Normal file
258
frontend/src/routes/customers/[id]/+page.svelte
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
import {GetCustomerStore, DeleteCustomerStore} from '$houdini';
|
||||||
|
|
||||||
|
let customerId = $state('');
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let successMessage = $state('');
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
|
||||||
|
// Initialize the GraphQL query with the customer ID
|
||||||
|
const customerStore = new GetCustomerStore();
|
||||||
|
|
||||||
|
// Fetch customer data using the ID from the URL
|
||||||
|
onMount(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
customerId = page.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load the customer data using the ID
|
||||||
|
await customerStore.fetch({variables: {id: customerId}});
|
||||||
|
isLoading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customer:', error);
|
||||||
|
errorMessage = 'Failed to load customer data';
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the delete mutation store
|
||||||
|
const deleteCustomerStore = new DeleteCustomerStore();
|
||||||
|
|
||||||
|
// Function to format date
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to handle delete customer
|
||||||
|
async function handleDeleteCustomer() {
|
||||||
|
try {
|
||||||
|
// Clear any previous messages
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
|
||||||
|
// Execute the delete mutation
|
||||||
|
const result = await deleteCustomerStore.mutate({ id: customerId });
|
||||||
|
|
||||||
|
if (result.data?.deleteCustomer) {
|
||||||
|
successMessage = 'Customer deleted successfully';
|
||||||
|
|
||||||
|
// Navigate back to the customer list after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
goto('/customers');
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
errorMessage = 'Failed to delete customer';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting customer:', error);
|
||||||
|
errorMessage = 'Failed to delete customer';
|
||||||
|
} finally {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to show delete confirmation
|
||||||
|
function confirmDelete() {
|
||||||
|
showDeleteConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to cancel delete
|
||||||
|
function cancelDelete() {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary mb-8">Customer Details</h1>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center items-center h-64">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if successMessage}
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if $customerStore.data?.customer}
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold">{$customerStore.data.customer.name}</h2>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<a
|
||||||
|
href={`/customers/${customerId}/edit`}
|
||||||
|
class="px-4 py-2 bg-secondary text-white rounded hover:bg-secondary-light focus:outline-none focus:ring-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Edit Customer
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onclick={confirmDelete}
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
Delete Customer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium mb-3 text-gray-900">Customer Information</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Start Date</p>
|
||||||
|
<p class="font-medium">{formatDate($customerStore.data.customer.startDate)}</p>
|
||||||
|
</div>
|
||||||
|
{#if $customerStore.data.customer.endDate}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">End Date</p>
|
||||||
|
<p class="font-medium">{formatDate($customerStore.data.customer.endDate)}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium mb-3 text-gray-900">Primary Contact</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Name</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.primaryContactFirstName} {$customerStore.data.customer.primaryContactLastName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Email</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.primaryContactEmail}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Phone</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.primaryContactPhone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $customerStore.data.customer.secondaryContactFirstName}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium mb-3 text-gray-900">Secondary Contact</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Name</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.secondaryContactFirstName} {$customerStore.data.customer.secondaryContactLastName}</p>
|
||||||
|
</div>
|
||||||
|
{#if $customerStore.data.customer.secondaryContactEmail}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Email</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.secondaryContactEmail}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if $customerStore.data.customer.secondaryContactPhone}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Phone</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.secondaryContactPhone}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium mb-3 text-gray-900">Billing Information</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Billing Contact</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.billingContactFirstName} {$customerStore.data.customer.billingContactLastName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Billing Email</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.billingEmail}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Billing Address</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.billingStreetAddress}</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.billingCity}
|
||||||
|
, {$customerStore.data.customer.billingState} {$customerStore.data.customer.billingZipCode}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Billing Terms</p>
|
||||||
|
<p class="font-medium">{$customerStore.data.customer.billingTerms}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-medium mb-2">System Information</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Customer ID</p>
|
||||||
|
<p class="font-mono text-sm">{$customerStore.data.customer.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Created</p>
|
||||||
|
<p class="text-sm">{new Date($customerStore.data.customer.createdAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Last Updated</p>
|
||||||
|
<p class="text-sm">{new Date($customerStore.data.customer.updatedAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Customer not found.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/customers" class="text-primary hover:text-primary-light font-medium">Back to Customers</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDeleteConfirm}
|
||||||
|
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Confirm Delete</h3>
|
||||||
|
<p class="mb-6 text-gray-700">Are you sure you want to delete this customer? This action cannot be undone.</p>
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onclick={cancelDelete}
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleDeleteCustomer}
|
||||||
|
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
138
frontend/src/routes/customers/[id]/edit/+page.svelte
Normal file
138
frontend/src/routes/customers/[id]/edit/+page.svelte
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
import {type CreateCustomerInput, GetCustomerStore, UpdateCustomerStore} from '$houdini';
|
||||||
|
import CustomerForm from '$lib/components/customers/CustomerForm.svelte';
|
||||||
|
import type {Customer, UpdateCustomerInput} from '$lib/types/customers';
|
||||||
|
|
||||||
|
let customerId = $state('');
|
||||||
|
let customer = $state<Customer | null>(null);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let successMessage = $state('');
|
||||||
|
|
||||||
|
// Initialize the GraphQL stores
|
||||||
|
const customerStore = new GetCustomerStore();
|
||||||
|
const updateCustomerStore = new UpdateCustomerStore();
|
||||||
|
|
||||||
|
// Fetch customer data using the ID from the URL
|
||||||
|
onMount(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
customerId = page.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load the customer data using the ID
|
||||||
|
await customerStore.fetch({variables: {id: customerId}});
|
||||||
|
|
||||||
|
if ($customerStore.data?.customer) {
|
||||||
|
customer = $customerStore.data.customer;
|
||||||
|
} else {
|
||||||
|
errorMessage = 'Customer not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customer:', error);
|
||||||
|
errorMessage = 'Failed to load customer data';
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
async function handleSubmit(data: CreateCustomerInput | (UpdateCustomerInput & { id: string })) {
|
||||||
|
// Ensure we have an ID (we should always have one in edit mode)
|
||||||
|
if (!('id' in data)) {
|
||||||
|
console.error('ID is missing in edit mode');
|
||||||
|
errorMessage = 'Failed to update customer: ID is missing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Clear any previous messages
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
|
||||||
|
// Update the customer
|
||||||
|
await updateCustomerStore.mutate({
|
||||||
|
id: data.id,
|
||||||
|
input: {
|
||||||
|
name: data.name,
|
||||||
|
startDate: data.startDate,
|
||||||
|
endDate: data.endDate,
|
||||||
|
primaryContactFirstName: data.primaryContactFirstName,
|
||||||
|
primaryContactLastName: data.primaryContactLastName,
|
||||||
|
primaryContactEmail: data.primaryContactEmail,
|
||||||
|
primaryContactPhone: data.primaryContactPhone,
|
||||||
|
secondaryContactFirstName: data.secondaryContactFirstName,
|
||||||
|
secondaryContactLastName: data.secondaryContactLastName,
|
||||||
|
secondaryContactEmail: data.secondaryContactEmail,
|
||||||
|
secondaryContactPhone: data.secondaryContactPhone,
|
||||||
|
billingContactFirstName: data.billingContactFirstName,
|
||||||
|
billingContactLastName: data.billingContactLastName,
|
||||||
|
billingEmail: data.billingEmail,
|
||||||
|
billingStreetAddress: data.billingStreetAddress,
|
||||||
|
billingCity: data.billingCity,
|
||||||
|
billingState: data.billingState,
|
||||||
|
billingZipCode: data.billingZipCode,
|
||||||
|
billingTerms: data.billingTerms
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
successMessage = 'Customer updated successfully';
|
||||||
|
|
||||||
|
// Navigate back to the customer details page after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
goto(`/customers/${customerId}`);
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating customer:', error);
|
||||||
|
errorMessage = 'Failed to update customer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel
|
||||||
|
function handleCancel() {
|
||||||
|
goto(`/customers/${customerId}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary mb-8">Edit Customer</h1>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center items-center h-64">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if successMessage}
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if customer}
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<CustomerForm
|
||||||
|
customer={customer}
|
||||||
|
isEditing={true}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Customer not found.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href={`/customers/${customerId}`} class="text-primary hover:text-primary-light font-medium">Back to Customer
|
||||||
|
Details</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
97
frontend/src/routes/customers/new/+page.svelte
Normal file
97
frontend/src/routes/customers/new/+page.svelte
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {CreateCustomerStore, type UpdateCustomerInput} from '$houdini';
|
||||||
|
import CustomerForm from '$lib/components/customers/CustomerForm.svelte';
|
||||||
|
import type {CreateCustomerInput} from '$lib/types/customers';
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let successMessage = $state('');
|
||||||
|
|
||||||
|
// Initialize the GraphQL store
|
||||||
|
const createCustomerStore = new CreateCustomerStore();
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
onMount(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
async function handleSubmit(data: CreateCustomerInput | (UpdateCustomerInput & { id: string })) {
|
||||||
|
// On the new page, we should only receive CreateCustomerInput
|
||||||
|
// But we need to handle both types to match the CustomerForm component's onSubmit prop type
|
||||||
|
|
||||||
|
// Check if we received an object with an ID (which shouldn't happen in the new page)
|
||||||
|
if ('id' in data) {
|
||||||
|
console.error('Unexpected ID in create mode');
|
||||||
|
errorMessage = 'Failed to create customer: Unexpected ID';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear any previous messages
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
// Create the customer
|
||||||
|
const result = await createCustomerStore.mutate({
|
||||||
|
input: data
|
||||||
|
});
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
successMessage = 'Customer created successfully';
|
||||||
|
|
||||||
|
// Navigate to the new customer details page after a short delay
|
||||||
|
if (result.data?.createCustomer?.id) {
|
||||||
|
setTimeout(() => {
|
||||||
|
goto(`/customers/${result.data?.createCustomer.id}`);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating customer:', error);
|
||||||
|
errorMessage = 'Failed to create customer';
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel
|
||||||
|
function handleCancel() {
|
||||||
|
goto('/customers');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary mb-8">Create New Customer</h1>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center items-center h-64">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if successMessage}
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<CustomerForm
|
||||||
|
isEditing={false}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/customers" class="text-primary hover:text-primary-light font-medium">Back to Customers</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
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