public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 10:55:11 -05:00
commit 72d5e2d984
156 changed files with 18031 additions and 0 deletions

6
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
engine-strict=true

6
frontend/.prettierignore Normal file
View File

@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

15
frontend/.prettierrc Normal file
View 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
View 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
View 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
}
}
}
);

View 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

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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 {};
}
})

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteAccount($id: ID!) {
deleteAccount(id: $id)
}

View 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
}
}

View File

@ -0,0 +1,15 @@
query GetAccounts {
accounts {
id
name
customerId
startDate
endDate
primaryContactFirstName
primaryContactLastName
primaryContactEmail
primaryContactPhone
createdAt
updatedAt
}
}

View 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
}
}

View 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
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteCustomer($id: ID!) {
deleteCustomer(id: $id)
}

View 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
}
}

View File

@ -0,0 +1,14 @@
query GetCustomers {
customers {
id
name
startDate
endDate
primaryContactFirstName
primaryContactLastName
primaryContactEmail
primaryContactPhone
createdAt
updatedAt
}
}

View 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
}
}

View File

@ -0,0 +1,14 @@
mutation CreateInvoice($input: CreateInvoiceInput!) {
createInvoice(input: $input) {
id
customerId
date
datePaid
paymentType
sentAt
status
totalAmount
createdAt
updatedAt
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteInvoice($id: ID!) {
deleteInvoice(id: $id)
}

View File

@ -0,0 +1,14 @@
query GetInvoice($id: ID!) {
invoice(id: $id) {
id
customerId
date
datePaid
paymentType
sentAt
status
totalAmount
createdAt
updatedAt
}
}

View File

@ -0,0 +1,12 @@
query GetInvoices {
invoices {
id
customerId
date
status
totalAmount
datePaid
createdAt
updatedAt
}
}

View 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
}
}

View File

@ -0,0 +1,11 @@
mutation CreateLabor($input: CreateLaborInput!) {
createLabor(input: $input) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteLabor($id: ID!) {
deleteLabor(id: $id)
}

View File

@ -0,0 +1,11 @@
query GetLabor($id: ID!) {
labor(id: $id) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
query GetLabors {
labors {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
query LaborsByAccount($accountId: ID!) {
laborsByAccount(accountId: $accountId) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
mutation UpdateLabor($id: ID!, $input: UpdateLaborInput!) {
updateLabor(id: $id, input: $input) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,14 @@
mutation CreateProfile($input: CreateProfileInput!) {
createProfile(input: $input) {
id
firstName
lastName
email
primaryPhone
secondaryPhone
role
userId
createdAt
updatedAt
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteProfile($id: ID!) {
deleteProfile(id: $id)
}

View File

@ -0,0 +1,14 @@
query GetProfile($id: ID!) {
profile(id: $id) {
id
firstName
lastName
email
primaryPhone
secondaryPhone
role
userId
createdAt
updatedAt
}
}

View File

@ -0,0 +1,14 @@
query GetProfiles {
profiles {
id
firstName
lastName
email
primaryPhone
secondaryPhone
role
userId
createdAt
updatedAt
}
}

View 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
}
}

View File

@ -0,0 +1,15 @@
mutation CreateProject($input: CreateProjectInput!) {
createProject(input: $input) {
id
customerId
accountId
date
status
notes
labor
amount
createdAt
updatedAt
completedAt
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteProject($id: ID!) {
deleteProject(id: $id)
}

View File

@ -0,0 +1,15 @@
query GetProject($id: ID!) {
project(id: $id) {
id
customerId
accountId
date
status
notes
labor
amount
createdAt
updatedAt
completedAt
}
}

View File

@ -0,0 +1,15 @@
query GetProjects {
projects {
id
customerId
accountId
date
status
notes
labor
amount
createdAt
updatedAt
completedAt
}
}

View File

@ -0,0 +1,15 @@
query ProjectsByAccount($accountId: ID!) {
projectsByAccount(accountId: $accountId) {
id
accountId
customerId
date
status
amount
labor
completedAt
notes
createdAt
updatedAt
}
}

View 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
}
}

View File

@ -0,0 +1,10 @@
mutation CreateReport($input: CreateReportInput!) {
createReport(input: $input) {
id
teamMemberId
date
notes
createdAt
updatedAt
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteReport($id: ID!) {
deleteReport(id: $id)
}

View File

@ -0,0 +1,10 @@
query GetReport($id: ID!) {
report(id: $id) {
id
teamMemberId
date
notes
createdAt
updatedAt
}
}

View File

@ -0,0 +1,10 @@
query GetReports {
reports {
id
teamMemberId
date
notes
createdAt
updatedAt
}
}

View File

@ -0,0 +1,10 @@
mutation UpdateReport($id: ID!, $input: UpdateReportInput!) {
updateReport(id: $id, input: $input) {
id
teamMemberId
date
notes
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
mutation CreateRevenue($input: CreateRevenueInput!) {
createRevenue(input: $input) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteRevenue($id: ID!) {
deleteRevenue(id: $id)
}

View File

@ -0,0 +1,11 @@
query GetRevenue($id: ID!) {
revenue(id: $id) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
query GetRevenues($limit: Int!, $offset: Int!) {
revenues(limit: $limit, offset: $offset) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
query RevenuesByAccount($accountId: ID!) {
revenuesByAccount(accountId: $accountId) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View File

@ -0,0 +1,11 @@
mutation UpdateRevenue($id: ID!, $input: UpdateRevenueInput!) {
updateRevenue(id: $id, input: $input) {
id
accountId
amount
startDate
endDate
createdAt
updatedAt
}
}

View 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
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteSchedule($id: ID!) {
deleteSchedule(id: $id)
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

@ -0,0 +1,14 @@
mutation CreateService($input: CreateServiceInput!) {
createService(input: $input) {
id
accountId
date
deadlineStart
deadlineEnd
status
notes
completedAt
createdAt
updatedAt
}
}

View File

@ -0,0 +1,3 @@
mutation DeleteService($id: ID!) {
deleteService(id: $id)
}

View File

@ -0,0 +1,14 @@
query GetService($id: ID!) {
service(id: $id) {
id
accountId
date
deadlineStart
deadlineEnd
status
notes
completedAt
createdAt
updatedAt
}
}

View File

@ -0,0 +1,14 @@
query GetServices {
services {
id
accountId
date
deadlineStart
deadlineEnd
status
notes
completedAt
createdAt
updatedAt
}
}

View File

@ -0,0 +1,14 @@
query ServicesByAccount($accountId: ID!) {
servicesByAccount(accountId: $accountId) {
id
accountId
date
status
completedAt
deadlineStart
deadlineEnd
notes
createdAt
updatedAt
}
}

View 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
}
}

View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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