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