Add complete Jobber MCP server with 48 tools and 18 React apps
This commit is contained in:
parent
5adccfd36e
commit
e28a971b50
222
servers/jobber/README.md
Normal file
222
servers/jobber/README.md
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
# Jobber MCP Server
|
||||||
|
|
||||||
|
A comprehensive Model Context Protocol (MCP) server for Jobber, the field service management platform. This server provides tools to interact with jobs, clients, quotes, invoices, scheduling, team management, expenses, products, and reporting.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🔧 Tools (48 total)
|
||||||
|
|
||||||
|
#### Jobs (8 tools)
|
||||||
|
- `list_jobs` - List all jobs with filtering
|
||||||
|
- `get_job` - Get job details
|
||||||
|
- `create_job` - Create a new job
|
||||||
|
- `update_job` - Update job information
|
||||||
|
- `close_job` - Mark job as completed
|
||||||
|
- `list_job_visits` - List visits for a job
|
||||||
|
- `create_job_visit` - Schedule a visit for a job
|
||||||
|
- `list_job_line_items` - List job line items
|
||||||
|
|
||||||
|
#### Clients (7 tools)
|
||||||
|
- `list_clients` - List all clients
|
||||||
|
- `get_client` - Get client details
|
||||||
|
- `create_client` - Create a new client
|
||||||
|
- `update_client` - Update client information
|
||||||
|
- `archive_client` - Archive a client
|
||||||
|
- `search_clients` - Search clients by name/email/company
|
||||||
|
- `list_client_properties` - List client properties
|
||||||
|
|
||||||
|
#### Quotes (8 tools)
|
||||||
|
- `list_quotes` - List all quotes
|
||||||
|
- `get_quote` - Get quote details
|
||||||
|
- `create_quote` - Create a new quote
|
||||||
|
- `update_quote` - Update quote information
|
||||||
|
- `send_quote` - Send quote to client
|
||||||
|
- `approve_quote` - Approve a quote
|
||||||
|
- `convert_quote_to_job` - Convert approved quote to job
|
||||||
|
- `list_quote_line_items` - List quote line items
|
||||||
|
|
||||||
|
#### Invoices (7 tools)
|
||||||
|
- `list_invoices` - List all invoices
|
||||||
|
- `get_invoice` - Get invoice details
|
||||||
|
- `create_invoice` - Create a new invoice
|
||||||
|
- `send_invoice` - Send invoice to client
|
||||||
|
- `mark_invoice_paid` - Mark invoice as paid
|
||||||
|
- `list_invoice_payments` - List invoice payments
|
||||||
|
- `create_payment` - Record a payment
|
||||||
|
|
||||||
|
#### Scheduling (6 tools)
|
||||||
|
- `list_visits` - List all visits
|
||||||
|
- `get_visit` - Get visit details
|
||||||
|
- `create_visit` - Schedule a new visit
|
||||||
|
- `update_visit` - Update visit information
|
||||||
|
- `complete_visit` - Mark visit as completed
|
||||||
|
- `list_visit_assignments` - List assigned users for a visit
|
||||||
|
|
||||||
|
#### Team (4 tools)
|
||||||
|
- `list_users` - List team members
|
||||||
|
- `get_user` - Get user details
|
||||||
|
- `list_time_entries` - List time entries
|
||||||
|
- `create_time_entry` - Create a time entry
|
||||||
|
|
||||||
|
#### Expenses (5 tools)
|
||||||
|
- `list_expenses` - List all expenses
|
||||||
|
- `get_expense` - Get expense details
|
||||||
|
- `create_expense` - Create a new expense
|
||||||
|
- `update_expense` - Update expense information
|
||||||
|
- `delete_expense` - Delete an expense
|
||||||
|
|
||||||
|
#### Products (5 tools)
|
||||||
|
- `list_products` - List products and services
|
||||||
|
- `get_product` - Get product/service details
|
||||||
|
- `create_product` - Create a new product/service
|
||||||
|
- `update_product` - Update product/service
|
||||||
|
- `delete_product` - Archive a product/service
|
||||||
|
|
||||||
|
#### Requests (6 tools)
|
||||||
|
- `list_requests` - List client requests
|
||||||
|
- `get_request` - Get request details
|
||||||
|
- `create_request` - Create a new request
|
||||||
|
- `update_request` - Update request information
|
||||||
|
- `convert_request_to_quote` - Convert request to quote
|
||||||
|
- `convert_request_to_job` - Convert request to job
|
||||||
|
|
||||||
|
#### Reporting (3 tools)
|
||||||
|
- `get_revenue_report` - Revenue analytics
|
||||||
|
- `get_job_profit_report` - Job profitability analysis
|
||||||
|
- `get_team_utilization_report` - Team utilization metrics
|
||||||
|
|
||||||
|
### 🎨 MCP Apps (18 total)
|
||||||
|
|
||||||
|
1. **job-dashboard** - Overview of all jobs with status breakdown
|
||||||
|
2. **job-detail** - Detailed view of a single job
|
||||||
|
3. **job-grid** - Searchable, filterable table of all jobs
|
||||||
|
4. **client-detail** - Detailed view of a single client
|
||||||
|
5. **client-grid** - Searchable table of all clients
|
||||||
|
6. **quote-builder** - Create and edit quotes with line items
|
||||||
|
7. **quote-grid** - List of all quotes with filtering
|
||||||
|
8. **invoice-dashboard** - Overview of invoicing metrics
|
||||||
|
9. **invoice-detail** - Detailed view of a single invoice
|
||||||
|
10. **schedule-calendar** - Calendar view of visits and appointments
|
||||||
|
11. **team-dashboard** - Overview of team members and activity
|
||||||
|
12. **team-schedule** - View schedules for all team members
|
||||||
|
13. **expense-tracker** - Track and manage expenses
|
||||||
|
14. **product-catalog** - Manage products and services
|
||||||
|
15. **request-inbox** - Manage client requests
|
||||||
|
16. **revenue-dashboard** - Revenue reporting and analytics
|
||||||
|
17. **job-profit-report** - Profitability analysis by job
|
||||||
|
18. **utilization-chart** - Team utilization analytics
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set your Jobber API token as an environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JOBBER_API_TOKEN=your_api_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### With Claude Desktop
|
||||||
|
|
||||||
|
Add to your `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"jobber": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/jobber-server/dist/index.js"],
|
||||||
|
"env": {
|
||||||
|
"JOBBER_API_TOKEN": "your_api_token_here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
JOBBER_API_TOKEN=your_token node dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
This server uses the Jobber GraphQL API (https://api.getjobber.com/api/graphql) with OAuth2 Bearer token authentication.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Get your API token from Jobber:
|
||||||
|
1. Log in to your Jobber account
|
||||||
|
2. Go to Settings → API → Developer
|
||||||
|
3. Create an API token with appropriate permissions
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Watch mode for development
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
jobber/
|
||||||
|
├── src/
|
||||||
|
│ ├── clients/
|
||||||
|
│ │ └── jobber.ts # GraphQL API client
|
||||||
|
│ ├── tools/
|
||||||
|
│ │ ├── jobs-tools.ts # Job management tools
|
||||||
|
│ │ ├── clients-tools.ts # Client management tools
|
||||||
|
│ │ ├── quotes-tools.ts # Quote management tools
|
||||||
|
│ │ ├── invoices-tools.ts # Invoice management tools
|
||||||
|
│ │ ├── scheduling-tools.ts # Scheduling tools
|
||||||
|
│ │ ├── team-tools.ts # Team management tools
|
||||||
|
│ │ ├── expenses-tools.ts # Expense tracking tools
|
||||||
|
│ │ ├── products-tools.ts # Product/service catalog tools
|
||||||
|
│ │ ├── requests-tools.ts # Client request tools
|
||||||
|
│ │ └── reporting-tools.ts # Reporting and analytics tools
|
||||||
|
│ ├── types/
|
||||||
|
│ │ └── jobber.ts # TypeScript type definitions
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ └── react-app/ # 18 React MCP apps
|
||||||
|
│ ├── server.ts # MCP server implementation
|
||||||
|
│ └── index.ts # Entry point
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please open an issue or submit a pull request.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues related to:
|
||||||
|
- **This MCP server**: Open a GitHub issue
|
||||||
|
- **Jobber API**: Contact Jobber support
|
||||||
|
- **MCP protocol**: See https://modelcontextprotocol.io
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Jobber API Documentation](https://developer.getjobber.com/)
|
||||||
|
- [Model Context Protocol](https://modelcontextprotocol.io)
|
||||||
|
- [MCP SDK](https://github.com/modelcontextprotocol/sdk)
|
||||||
@ -31,7 +31,7 @@ export class JobberClient {
|
|||||||
throw new Error(`Jobber API error: ${response.status} ${response.statusText}`);
|
throw new Error(`Jobber API error: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result: any = await response.json();
|
||||||
|
|
||||||
if (result.errors) {
|
if (result.errors) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
12
servers/jobber/src/index.ts
Normal file
12
servers/jobber/src/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Jobber MCP Server Entry Point
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JobberServer } from './server.js';
|
||||||
|
|
||||||
|
const server = new JobberServer();
|
||||||
|
server.run().catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
121
servers/jobber/src/server.ts
Normal file
121
servers/jobber/src/server.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
McpError,
|
||||||
|
ErrorCode,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { JobberClient } from './clients/jobber.js';
|
||||||
|
import { jobsTools } from './tools/jobs-tools.js';
|
||||||
|
import { clientsTools } from './tools/clients-tools.js';
|
||||||
|
import { quotesTools } from './tools/quotes-tools.js';
|
||||||
|
import { invoicesTools } from './tools/invoices-tools.js';
|
||||||
|
import { schedulingTools } from './tools/scheduling-tools.js';
|
||||||
|
import { teamTools } from './tools/team-tools.js';
|
||||||
|
import { expensesTools } from './tools/expenses-tools.js';
|
||||||
|
import { productsTools } from './tools/products-tools.js';
|
||||||
|
import { requestsTools } from './tools/requests-tools.js';
|
||||||
|
import { reportingTools } from './tools/reporting-tools.js';
|
||||||
|
|
||||||
|
// Combine all tools
|
||||||
|
const allTools = {
|
||||||
|
...jobsTools,
|
||||||
|
...clientsTools,
|
||||||
|
...quotesTools,
|
||||||
|
...invoicesTools,
|
||||||
|
...schedulingTools,
|
||||||
|
...teamTools,
|
||||||
|
...expensesTools,
|
||||||
|
...productsTools,
|
||||||
|
...requestsTools,
|
||||||
|
...reportingTools,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class JobberServer {
|
||||||
|
private server: Server;
|
||||||
|
private client: JobberClient;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const apiToken = process.env.JOBBER_API_TOKEN;
|
||||||
|
if (!apiToken) {
|
||||||
|
throw new Error('JOBBER_API_TOKEN environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new JobberClient({ apiToken });
|
||||||
|
this.server = new Server(
|
||||||
|
{
|
||||||
|
name: 'jobber-server',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.setupHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupHandlers(): void {
|
||||||
|
// List available tools
|
||||||
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: Object.entries(allTools).map(([name, tool]) => ({
|
||||||
|
name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema.shape,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle tool calls
|
||||||
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
|
const tool = allTools[name as keyof typeof allTools];
|
||||||
|
if (!tool) {
|
||||||
|
throw new McpError(
|
||||||
|
ErrorCode.MethodNotFound,
|
||||||
|
`Unknown tool: ${name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate arguments
|
||||||
|
const validatedArgs = tool.inputSchema.parse(args);
|
||||||
|
|
||||||
|
// Execute tool
|
||||||
|
const result = await tool.execute(this.client, validatedArgs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new McpError(
|
||||||
|
ErrorCode.InternalError,
|
||||||
|
`Tool execution failed: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await this.server.connect(transport);
|
||||||
|
console.error('Jobber MCP server running on stdio');
|
||||||
|
}
|
||||||
|
}
|
||||||
245
servers/jobber/src/tools/clients-tools.ts
Normal file
245
servers/jobber/src/tools/clients-tools.ts
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Clients Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const clientsTools = {
|
||||||
|
list_clients: {
|
||||||
|
description: 'List all clients with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
isArchived: z.boolean().optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filters = args.isArchived !== undefined ? `, filter: { isArchived: ${args.isArchived} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListClients {
|
||||||
|
clients(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
${JobberClient.clientFields}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
clients: data.clients.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.clients.pageInfo,
|
||||||
|
totalCount: data.clients.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_client: {
|
||||||
|
description: 'Get a specific client by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetClient($id: ID!) {
|
||||||
|
client(id: $id) {
|
||||||
|
${JobberClient.clientFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.clientId });
|
||||||
|
return { client: data.client };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_client: {
|
||||||
|
description: 'Create a new client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
firstName: z.string(),
|
||||||
|
lastName: z.string(),
|
||||||
|
companyName: z.string().optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
billingAddress: z.object({
|
||||||
|
street1: z.string().optional(),
|
||||||
|
street2: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
province: z.string().optional(),
|
||||||
|
postalCode: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
}).optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateClient($input: ClientInput!) {
|
||||||
|
clientCreate(input: $input) {
|
||||||
|
client {
|
||||||
|
${JobberClient.clientFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
firstName: args.firstName,
|
||||||
|
lastName: args.lastName,
|
||||||
|
companyName: args.companyName,
|
||||||
|
email: args.email,
|
||||||
|
phone: args.phone,
|
||||||
|
billingAddress: args.billingAddress,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.clientCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Client creation failed: ${data.clientCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { client: data.clientCreate.client };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update_client: {
|
||||||
|
description: 'Update an existing client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientId: z.string(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
companyName: z.string().optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateClient($id: ID!, $input: ClientUpdateInput!) {
|
||||||
|
clientUpdate(id: $id, input: $input) {
|
||||||
|
client {
|
||||||
|
${JobberClient.clientFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: any = {};
|
||||||
|
if (args.firstName) input.firstName = args.firstName;
|
||||||
|
if (args.lastName) input.lastName = args.lastName;
|
||||||
|
if (args.companyName) input.companyName = args.companyName;
|
||||||
|
if (args.email) input.email = args.email;
|
||||||
|
if (args.phone) input.phone = args.phone;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.clientId, input });
|
||||||
|
|
||||||
|
if (data.clientUpdate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Client update failed: ${data.clientUpdate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { client: data.clientUpdate.client };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
archive_client: {
|
||||||
|
description: 'Archive a client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation ArchiveClient($id: ID!) {
|
||||||
|
clientArchive(id: $id) {
|
||||||
|
client {
|
||||||
|
${JobberClient.clientFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.clientId });
|
||||||
|
|
||||||
|
if (data.clientArchive.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Client archive failed: ${data.clientArchive.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { client: data.clientArchive.client };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
search_clients: {
|
||||||
|
description: 'Search clients by name, email, or company',
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe('Search query string'),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query SearchClients($query: String!, $limit: Int!) {
|
||||||
|
clients(first: $limit, filter: { search: $query }) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
${JobberClient.clientFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { query: args.query, limit: args.limit });
|
||||||
|
return {
|
||||||
|
clients: data.clients.edges.map((e: any) => e.node),
|
||||||
|
totalCount: data.clients.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list_client_properties: {
|
||||||
|
description: 'List all properties for a specific client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetClientProperties($id: ID!) {
|
||||||
|
client(id: $id) {
|
||||||
|
properties {
|
||||||
|
id
|
||||||
|
isDefault
|
||||||
|
address {
|
||||||
|
street1
|
||||||
|
street2
|
||||||
|
city
|
||||||
|
province
|
||||||
|
postalCode
|
||||||
|
country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.clientId });
|
||||||
|
return { properties: data.client.properties };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
239
servers/jobber/src/tools/expenses-tools.ts
Normal file
239
servers/jobber/src/tools/expenses-tools.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Expenses Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const expensesTools = {
|
||||||
|
list_expenses: {
|
||||||
|
description: 'List all expenses with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
jobId: z.string().optional(),
|
||||||
|
startDate: z.string().optional().describe('ISO 8601 date'),
|
||||||
|
endDate: z.string().optional().describe('ISO 8601 date'),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
if (args.userId) {
|
||||||
|
filterConditions.push(`userId: "${args.userId}"`);
|
||||||
|
}
|
||||||
|
if (args.jobId) {
|
||||||
|
filterConditions.push(`jobId: "${args.jobId}"`);
|
||||||
|
}
|
||||||
|
if (args.startDate) {
|
||||||
|
filterConditions.push(`startDate: "${args.startDate}"`);
|
||||||
|
}
|
||||||
|
if (args.endDate) {
|
||||||
|
filterConditions.push(`endDate: "${args.endDate}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = filterConditions.length > 0 ? `, filter: { ${filterConditions.join(', ')} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListExpenses {
|
||||||
|
expenses(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
description
|
||||||
|
amount {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
category
|
||||||
|
date
|
||||||
|
receipt
|
||||||
|
user {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
job {
|
||||||
|
id
|
||||||
|
jobNumber
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
expenses: data.expenses.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.expenses.pageInfo,
|
||||||
|
totalCount: data.expenses.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_expense: {
|
||||||
|
description: 'Get a specific expense by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
expenseId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetExpense($id: ID!) {
|
||||||
|
expense(id: $id) {
|
||||||
|
id
|
||||||
|
description
|
||||||
|
amount {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
category
|
||||||
|
date
|
||||||
|
receipt
|
||||||
|
user {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
job {
|
||||||
|
id
|
||||||
|
jobNumber
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.expenseId });
|
||||||
|
return { expense: data.expense };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_expense: {
|
||||||
|
description: 'Create a new expense',
|
||||||
|
inputSchema: z.object({
|
||||||
|
description: z.string(),
|
||||||
|
amount: z.number(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
date: z.string().describe('ISO 8601 date'),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
jobId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateExpense($input: ExpenseInput!) {
|
||||||
|
expenseCreate(input: $input) {
|
||||||
|
expense {
|
||||||
|
id
|
||||||
|
description
|
||||||
|
amount {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
category
|
||||||
|
date
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
description: args.description,
|
||||||
|
amount: args.amount,
|
||||||
|
category: args.category,
|
||||||
|
date: args.date,
|
||||||
|
userId: args.userId,
|
||||||
|
jobId: args.jobId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.expenseCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Expense creation failed: ${data.expenseCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { expense: data.expenseCreate.expense };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update_expense: {
|
||||||
|
description: 'Update an existing expense',
|
||||||
|
inputSchema: z.object({
|
||||||
|
expenseId: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
amount: z.number().optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
date: z.string().optional().describe('ISO 8601 date'),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateExpense($id: ID!, $input: ExpenseUpdateInput!) {
|
||||||
|
expenseUpdate(id: $id, input: $input) {
|
||||||
|
expense {
|
||||||
|
id
|
||||||
|
description
|
||||||
|
amount {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
category
|
||||||
|
date
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: any = {};
|
||||||
|
if (args.description) input.description = args.description;
|
||||||
|
if (args.amount) input.amount = args.amount;
|
||||||
|
if (args.category) input.category = args.category;
|
||||||
|
if (args.date) input.date = args.date;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.expenseId, input });
|
||||||
|
|
||||||
|
if (data.expenseUpdate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Expense update failed: ${data.expenseUpdate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { expense: data.expenseUpdate.expense };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
delete_expense: {
|
||||||
|
description: 'Delete an expense',
|
||||||
|
inputSchema: z.object({
|
||||||
|
expenseId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation DeleteExpense($id: ID!) {
|
||||||
|
expenseDelete(id: $id) {
|
||||||
|
deletedExpenseId
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.expenseId });
|
||||||
|
|
||||||
|
if (data.expenseDelete.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Expense deletion failed: ${data.expenseDelete.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deletedExpenseId: data.expenseDelete.deletedExpenseId };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
258
servers/jobber/src/tools/invoices-tools.ts
Normal file
258
servers/jobber/src/tools/invoices-tools.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Invoices Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const invoicesTools = {
|
||||||
|
list_invoices: {
|
||||||
|
description: 'List all invoices with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
status: z.enum(['DRAFT', 'SENT', 'VIEWED', 'PAID', 'PARTIALLY_PAID', 'OVERDUE', 'BAD_DEBT']).optional(),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
if (args.status) {
|
||||||
|
filterConditions.push(`status: ${args.status}`);
|
||||||
|
}
|
||||||
|
if (args.clientId) {
|
||||||
|
filterConditions.push(`clientId: "${args.clientId}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = filterConditions.length > 0 ? `, filter: { ${filterConditions.join(', ')} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListInvoices {
|
||||||
|
invoices(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
${JobberClient.invoiceFields}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
invoices: data.invoices.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.invoices.pageInfo,
|
||||||
|
totalCount: data.invoices.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_invoice: {
|
||||||
|
description: 'Get a specific invoice by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoiceId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetInvoice($id: ID!) {
|
||||||
|
invoice(id: $id) {
|
||||||
|
${JobberClient.invoiceFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.invoiceId });
|
||||||
|
return { invoice: data.invoice };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_invoice: {
|
||||||
|
description: 'Create a new invoice',
|
||||||
|
inputSchema: z.object({
|
||||||
|
subject: z.string(),
|
||||||
|
clientId: z.string(),
|
||||||
|
jobId: z.string().optional(),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateInvoice($input: InvoiceInput!) {
|
||||||
|
invoiceCreate(input: $input) {
|
||||||
|
invoice {
|
||||||
|
${JobberClient.invoiceFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
subject: args.subject,
|
||||||
|
clientId: args.clientId,
|
||||||
|
jobId: args.jobId,
|
||||||
|
dueDate: args.dueDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.invoiceCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Invoice creation failed: ${data.invoiceCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invoice: data.invoiceCreate.invoice };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
send_invoice: {
|
||||||
|
description: 'Send an invoice to the client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoiceId: z.string(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation SendInvoice($id: ID!, $message: String) {
|
||||||
|
invoiceSend(id: $id, message: $message) {
|
||||||
|
invoice {
|
||||||
|
${JobberClient.invoiceFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.invoiceId, message: args.message });
|
||||||
|
|
||||||
|
if (data.invoiceSend.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Invoice send failed: ${data.invoiceSend.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invoice: data.invoiceSend.invoice };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mark_invoice_paid: {
|
||||||
|
description: 'Mark an invoice as fully paid',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoiceId: z.string(),
|
||||||
|
amount: z.number(),
|
||||||
|
paymentMethod: z.string().default('OTHER'),
|
||||||
|
paidOn: z.string().describe('ISO 8601 date'),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation MarkInvoicePaid($id: ID!, $input: PaymentInput!) {
|
||||||
|
paymentCreate(invoiceId: $id, input: $input) {
|
||||||
|
payment {
|
||||||
|
id
|
||||||
|
amount {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
paidOn
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
amount: args.amount,
|
||||||
|
paymentMethod: args.paymentMethod,
|
||||||
|
paidOn: args.paidOn,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.invoiceId, input });
|
||||||
|
|
||||||
|
if (data.paymentCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Payment failed: ${data.paymentCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payment: data.paymentCreate.payment };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list_invoice_payments: {
|
||||||
|
description: 'List all payments for a specific invoice',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoiceId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetInvoicePayments($id: ID!) {
|
||||||
|
invoice(id: $id) {
|
||||||
|
payments {
|
||||||
|
id
|
||||||
|
amount {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
paymentMethod
|
||||||
|
paidOn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.invoiceId });
|
||||||
|
return { payments: data.invoice.payments };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_payment: {
|
||||||
|
description: 'Create a payment for an invoice',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoiceId: z.string(),
|
||||||
|
amount: z.number(),
|
||||||
|
paymentMethod: z.string().default('OTHER'),
|
||||||
|
paidOn: z.string().describe('ISO 8601 date'),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreatePayment($invoiceId: ID!, $input: PaymentInput!) {
|
||||||
|
paymentCreate(invoiceId: $invoiceId, input: $input) {
|
||||||
|
payment {
|
||||||
|
id
|
||||||
|
amount {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
paymentMethod
|
||||||
|
paidOn
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
amount: args.amount,
|
||||||
|
paymentMethod: args.paymentMethod,
|
||||||
|
paidOn: args.paidOn,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { invoiceId: args.invoiceId, input });
|
||||||
|
|
||||||
|
if (data.paymentCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Payment creation failed: ${data.paymentCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payment: data.paymentCreate.payment };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
263
servers/jobber/src/tools/jobs-tools.ts
Normal file
263
servers/jobber/src/tools/jobs-tools.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Jobs Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
import type { Job, Visit, LineItem } from '../types/jobber.js';
|
||||||
|
|
||||||
|
export const jobsTools = {
|
||||||
|
list_jobs: {
|
||||||
|
description: 'List all jobs with optional filtering and pagination',
|
||||||
|
inputSchema: z.object({
|
||||||
|
status: z.enum(['ACTION_REQUIRED', 'ACTIVE', 'CANCELLED', 'COMPLETED', 'LATE', 'REQUIRES_INVOICING']).optional(),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
if (args.status) {
|
||||||
|
filterConditions.push(`status: ${args.status}`);
|
||||||
|
}
|
||||||
|
if (args.clientId) {
|
||||||
|
filterConditions.push(`clientId: "${args.clientId}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = filterConditions.length > 0 ? `, filter: { ${filterConditions.join(', ')} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListJobs {
|
||||||
|
jobs(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
${JobberClient.jobFields}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
jobs: data.jobs.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.jobs.pageInfo,
|
||||||
|
totalCount: data.jobs.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_job: {
|
||||||
|
description: 'Get a specific job by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
jobId: z.string().describe('The job ID'),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetJob($id: ID!) {
|
||||||
|
job(id: $id) {
|
||||||
|
${JobberClient.jobFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.jobId });
|
||||||
|
return { job: data.job };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_job: {
|
||||||
|
description: 'Create a new job',
|
||||||
|
inputSchema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
clientId: z.string(),
|
||||||
|
propertyId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateJob($input: JobInput!) {
|
||||||
|
jobCreate(input: $input) {
|
||||||
|
job {
|
||||||
|
${JobberClient.jobFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
title: args.title,
|
||||||
|
description: args.description,
|
||||||
|
clientId: args.clientId,
|
||||||
|
propertyId: args.propertyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.jobCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Job creation failed: ${data.jobCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { job: data.jobCreate.job };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update_job: {
|
||||||
|
description: 'Update an existing job',
|
||||||
|
inputSchema: z.object({
|
||||||
|
jobId: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateJob($id: ID!, $input: JobUpdateInput!) {
|
||||||
|
jobUpdate(id: $id, input: $input) {
|
||||||
|
job {
|
||||||
|
${JobberClient.jobFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: any = {};
|
||||||
|
if (args.title) input.title = args.title;
|
||||||
|
if (args.description) input.description = args.description;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.jobId, input });
|
||||||
|
|
||||||
|
if (data.jobUpdate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Job update failed: ${data.jobUpdate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { job: data.jobUpdate.job };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
close_job: {
|
||||||
|
description: 'Close a job (mark as completed)',
|
||||||
|
inputSchema: z.object({
|
||||||
|
jobId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CloseJob($id: ID!) {
|
||||||
|
jobClose(id: $id) {
|
||||||
|
job {
|
||||||
|
${JobberClient.jobFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.jobId });
|
||||||
|
|
||||||
|
if (data.jobClose.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Job close failed: ${data.jobClose.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { job: data.jobClose.job };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list_job_visits: {
|
||||||
|
description: 'List all visits for a specific job',
|
||||||
|
inputSchema: z.object({
|
||||||
|
jobId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetJobVisits($id: ID!) {
|
||||||
|
job(id: $id) {
|
||||||
|
visits {
|
||||||
|
${JobberClient.visitFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.jobId });
|
||||||
|
return { visits: data.job.visits };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_job_visit: {
|
||||||
|
description: 'Create a new visit for a job',
|
||||||
|
inputSchema: z.object({
|
||||||
|
jobId: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
startAt: z.string().describe('ISO 8601 datetime'),
|
||||||
|
endAt: z.string().describe('ISO 8601 datetime'),
|
||||||
|
userIds: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateVisit($input: VisitInput!) {
|
||||||
|
visitCreate(input: $input) {
|
||||||
|
visit {
|
||||||
|
${JobberClient.visitFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
jobId: args.jobId,
|
||||||
|
title: args.title,
|
||||||
|
startAt: args.startAt,
|
||||||
|
endAt: args.endAt,
|
||||||
|
userIds: args.userIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.visitCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Visit creation failed: ${data.visitCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visit: data.visitCreate.visit };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list_job_line_items: {
|
||||||
|
description: 'List all line items for a specific job',
|
||||||
|
inputSchema: z.object({
|
||||||
|
jobId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetJobLineItems($id: ID!) {
|
||||||
|
job(id: $id) {
|
||||||
|
lineItems {
|
||||||
|
${JobberClient.lineItemFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.jobId });
|
||||||
|
return { lineItems: data.job.lineItems };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
215
servers/jobber/src/tools/products-tools.ts
Normal file
215
servers/jobber/src/tools/products-tools.ts
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Products/Services Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const productsTools = {
|
||||||
|
list_products: {
|
||||||
|
description: 'List all products and services',
|
||||||
|
inputSchema: z.object({
|
||||||
|
type: z.enum(['PRODUCT', 'SERVICE']).optional(),
|
||||||
|
isArchived: z.boolean().optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
if (args.type) {
|
||||||
|
filterConditions.push(`type: ${args.type}`);
|
||||||
|
}
|
||||||
|
if (args.isArchived !== undefined) {
|
||||||
|
filterConditions.push(`isArchived: ${args.isArchived}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = filterConditions.length > 0 ? `, filter: { ${filterConditions.join(', ')} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListProducts {
|
||||||
|
products(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
unitPrice {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
type
|
||||||
|
isArchived
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
products: data.products.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.products.pageInfo,
|
||||||
|
totalCount: data.products.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_product: {
|
||||||
|
description: 'Get a specific product or service by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
productId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetProduct($id: ID!) {
|
||||||
|
product(id: $id) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
unitPrice {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
type
|
||||||
|
isArchived
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.productId });
|
||||||
|
return { product: data.product };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_product: {
|
||||||
|
description: 'Create a new product or service',
|
||||||
|
inputSchema: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
unitPrice: z.number().optional(),
|
||||||
|
type: z.enum(['PRODUCT', 'SERVICE']).default('SERVICE'),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateProduct($input: ProductInput!) {
|
||||||
|
productCreate(input: $input) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
unitPrice {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
type
|
||||||
|
isArchived
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
name: args.name,
|
||||||
|
description: args.description,
|
||||||
|
unitPrice: args.unitPrice,
|
||||||
|
type: args.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.productCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Product creation failed: ${data.productCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { product: data.productCreate.product };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update_product: {
|
||||||
|
description: 'Update an existing product or service',
|
||||||
|
inputSchema: z.object({
|
||||||
|
productId: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
unitPrice: z.number().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateProduct($id: ID!, $input: ProductUpdateInput!) {
|
||||||
|
productUpdate(id: $id, input: $input) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
unitPrice {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
type
|
||||||
|
isArchived
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: any = {};
|
||||||
|
if (args.name) input.name = args.name;
|
||||||
|
if (args.description) input.description = args.description;
|
||||||
|
if (args.unitPrice) input.unitPrice = args.unitPrice;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.productId, input });
|
||||||
|
|
||||||
|
if (data.productUpdate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Product update failed: ${data.productUpdate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { product: data.productUpdate.product };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
delete_product: {
|
||||||
|
description: 'Delete (archive) a product or service',
|
||||||
|
inputSchema: z.object({
|
||||||
|
productId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation DeleteProduct($id: ID!) {
|
||||||
|
productArchive(id: $id) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isArchived
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.productId });
|
||||||
|
|
||||||
|
if (data.productArchive.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Product deletion failed: ${data.productArchive.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { product: data.productArchive.product };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
263
servers/jobber/src/tools/quotes-tools.ts
Normal file
263
servers/jobber/src/tools/quotes-tools.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Quotes Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const quotesTools = {
|
||||||
|
list_quotes: {
|
||||||
|
description: 'List all quotes with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
status: z.enum(['DRAFT', 'SENT', 'APPROVED', 'CHANGES_REQUESTED', 'CONVERTED', 'EXPIRED']).optional(),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
if (args.status) {
|
||||||
|
filterConditions.push(`status: ${args.status}`);
|
||||||
|
}
|
||||||
|
if (args.clientId) {
|
||||||
|
filterConditions.push(`clientId: "${args.clientId}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = filterConditions.length > 0 ? `, filter: { ${filterConditions.join(', ')} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListQuotes {
|
||||||
|
quotes(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
${JobberClient.quoteFields}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
quotes: data.quotes.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.quotes.pageInfo,
|
||||||
|
totalCount: data.quotes.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_quote: {
|
||||||
|
description: 'Get a specific quote by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
quoteId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetQuote($id: ID!) {
|
||||||
|
quote(id: $id) {
|
||||||
|
${JobberClient.quoteFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.quoteId });
|
||||||
|
return { quote: data.quote };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_quote: {
|
||||||
|
description: 'Create a new quote',
|
||||||
|
inputSchema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
clientId: z.string(),
|
||||||
|
propertyId: z.string().optional(),
|
||||||
|
lineItems: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
quantity: z.number(),
|
||||||
|
unitPrice: z.number().optional(),
|
||||||
|
productId: z.string().optional(),
|
||||||
|
})).optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateQuote($input: QuoteInput!) {
|
||||||
|
quoteCreate(input: $input) {
|
||||||
|
quote {
|
||||||
|
${JobberClient.quoteFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
title: args.title,
|
||||||
|
clientId: args.clientId,
|
||||||
|
propertyId: args.propertyId,
|
||||||
|
lineItems: args.lineItems,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.quoteCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Quote creation failed: ${data.quoteCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { quote: data.quoteCreate.quote };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update_quote: {
|
||||||
|
description: 'Update an existing quote',
|
||||||
|
inputSchema: z.object({
|
||||||
|
quoteId: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateQuote($id: ID!, $input: QuoteUpdateInput!) {
|
||||||
|
quoteUpdate(id: $id, input: $input) {
|
||||||
|
quote {
|
||||||
|
${JobberClient.quoteFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: any = {};
|
||||||
|
if (args.title) input.title = args.title;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.quoteId, input });
|
||||||
|
|
||||||
|
if (data.quoteUpdate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Quote update failed: ${data.quoteUpdate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { quote: data.quoteUpdate.quote };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
send_quote: {
|
||||||
|
description: 'Send a quote to the client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
quoteId: z.string(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation SendQuote($id: ID!, $message: String) {
|
||||||
|
quoteSend(id: $id, message: $message) {
|
||||||
|
quote {
|
||||||
|
${JobberClient.quoteFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.quoteId, message: args.message });
|
||||||
|
|
||||||
|
if (data.quoteSend.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Quote send failed: ${data.quoteSend.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { quote: data.quoteSend.quote };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
approve_quote: {
|
||||||
|
description: 'Approve a quote',
|
||||||
|
inputSchema: z.object({
|
||||||
|
quoteId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation ApproveQuote($id: ID!) {
|
||||||
|
quoteApprove(id: $id) {
|
||||||
|
quote {
|
||||||
|
${JobberClient.quoteFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.quoteId });
|
||||||
|
|
||||||
|
if (data.quoteApprove.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Quote approval failed: ${data.quoteApprove.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { quote: data.quoteApprove.quote };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
convert_quote_to_job: {
|
||||||
|
description: 'Convert an approved quote to a job',
|
||||||
|
inputSchema: z.object({
|
||||||
|
quoteId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation ConvertQuoteToJob($id: ID!) {
|
||||||
|
quoteConvertToJob(id: $id) {
|
||||||
|
job {
|
||||||
|
${JobberClient.jobFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.quoteId });
|
||||||
|
|
||||||
|
if (data.quoteConvertToJob.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Quote conversion failed: ${data.quoteConvertToJob.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { job: data.quoteConvertToJob.job };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list_quote_line_items: {
|
||||||
|
description: 'List all line items for a specific quote',
|
||||||
|
inputSchema: z.object({
|
||||||
|
quoteId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetQuoteLineItems($id: ID!) {
|
||||||
|
quote(id: $id) {
|
||||||
|
lineItems {
|
||||||
|
${JobberClient.lineItemFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.quoteId });
|
||||||
|
return { lineItems: data.quote.lineItems };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
131
servers/jobber/src/tools/reporting-tools.ts
Normal file
131
servers/jobber/src/tools/reporting-tools.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Reporting Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const reportingTools = {
|
||||||
|
get_revenue_report: {
|
||||||
|
description: 'Get revenue report for a date range',
|
||||||
|
inputSchema: z.object({
|
||||||
|
startDate: z.string().describe('ISO 8601 date'),
|
||||||
|
endDate: z.string().describe('ISO 8601 date'),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetRevenueReport($startDate: String!, $endDate: String!) {
|
||||||
|
reports {
|
||||||
|
revenue(startDate: $startDate, endDate: $endDate) {
|
||||||
|
totalRevenue {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
invoicedRevenue {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
paidRevenue {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
outstandingRevenue {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { startDate: args.startDate, endDate: args.endDate });
|
||||||
|
return { revenueReport: data.reports.revenue };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_job_profit_report: {
|
||||||
|
description: 'Get profitability report for jobs in a date range',
|
||||||
|
inputSchema: z.object({
|
||||||
|
startDate: z.string().describe('ISO 8601 date'),
|
||||||
|
endDate: z.string().describe('ISO 8601 date'),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetJobProfitReport($startDate: String!, $endDate: String!) {
|
||||||
|
reports {
|
||||||
|
jobProfit(startDate: $startDate, endDate: $endDate) {
|
||||||
|
totalRevenue {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
totalCosts {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
totalProfit {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
profitMargin
|
||||||
|
jobBreakdown {
|
||||||
|
jobId
|
||||||
|
jobNumber
|
||||||
|
title
|
||||||
|
revenue {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
costs {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
profit {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
margin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { startDate: args.startDate, endDate: args.endDate });
|
||||||
|
return { jobProfitReport: data.reports.jobProfit };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_team_utilization_report: {
|
||||||
|
description: 'Get team utilization report for a date range',
|
||||||
|
inputSchema: z.object({
|
||||||
|
startDate: z.string().describe('ISO 8601 date'),
|
||||||
|
endDate: z.string().describe('ISO 8601 date'),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetTeamUtilizationReport($startDate: String!, $endDate: String!) {
|
||||||
|
reports {
|
||||||
|
teamUtilization(startDate: $startDate, endDate: $endDate) {
|
||||||
|
totalHours
|
||||||
|
billableHours
|
||||||
|
nonBillableHours
|
||||||
|
utilizationRate
|
||||||
|
userBreakdown {
|
||||||
|
userId
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
totalHours
|
||||||
|
billableHours
|
||||||
|
nonBillableHours
|
||||||
|
utilizationRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { startDate: args.startDate, endDate: args.endDate });
|
||||||
|
return { utilizationReport: data.reports.teamUtilization };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
221
servers/jobber/src/tools/requests-tools.ts
Normal file
221
servers/jobber/src/tools/requests-tools.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Requests Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const requestsTools = {
|
||||||
|
list_requests: {
|
||||||
|
description: 'List all client requests',
|
||||||
|
inputSchema: z.object({
|
||||||
|
status: z.enum(['NEW', 'IN_PROGRESS', 'CONVERTED', 'CLOSED']).optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filters = args.status ? `, filter: { status: ${args.status} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListRequests {
|
||||||
|
requests(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
client {
|
||||||
|
${JobberClient.clientFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
requests: data.requests.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.requests.pageInfo,
|
||||||
|
totalCount: data.requests.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_request: {
|
||||||
|
description: 'Get a specific request by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetRequest($id: ID!) {
|
||||||
|
request(id: $id) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
client {
|
||||||
|
${JobberClient.clientFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.requestId });
|
||||||
|
return { request: data.request };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_request: {
|
||||||
|
description: 'Create a new client request',
|
||||||
|
inputSchema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateRequest($input: RequestInput!) {
|
||||||
|
requestCreate(input: $input) {
|
||||||
|
request {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
title: args.title,
|
||||||
|
description: args.description,
|
||||||
|
clientId: args.clientId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.requestCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Request creation failed: ${data.requestCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request: data.requestCreate.request };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update_request: {
|
||||||
|
description: 'Update an existing request',
|
||||||
|
inputSchema: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.enum(['NEW', 'IN_PROGRESS', 'CONVERTED', 'CLOSED']).optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateRequest($id: ID!, $input: RequestUpdateInput!) {
|
||||||
|
requestUpdate(id: $id, input: $input) {
|
||||||
|
request {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: any = {};
|
||||||
|
if (args.title) input.title = args.title;
|
||||||
|
if (args.description) input.description = args.description;
|
||||||
|
if (args.status) input.status = args.status;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.requestId, input });
|
||||||
|
|
||||||
|
if (data.requestUpdate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Request update failed: ${data.requestUpdate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request: data.requestUpdate.request };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
convert_request_to_quote: {
|
||||||
|
description: 'Convert a request to a quote',
|
||||||
|
inputSchema: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation ConvertRequestToQuote($id: ID!) {
|
||||||
|
requestConvertToQuote(id: $id) {
|
||||||
|
quote {
|
||||||
|
${JobberClient.quoteFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.requestId });
|
||||||
|
|
||||||
|
if (data.requestConvertToQuote.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Request conversion to quote failed: ${data.requestConvertToQuote.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { quote: data.requestConvertToQuote.quote };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
convert_request_to_job: {
|
||||||
|
description: 'Convert a request directly to a job',
|
||||||
|
inputSchema: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation ConvertRequestToJob($id: ID!) {
|
||||||
|
requestConvertToJob(id: $id) {
|
||||||
|
job {
|
||||||
|
${JobberClient.jobFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.requestId });
|
||||||
|
|
||||||
|
if (data.requestConvertToJob.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Request conversion to job failed: ${data.requestConvertToJob.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { job: data.requestConvertToJob.job };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
228
servers/jobber/src/tools/scheduling-tools.ts
Normal file
228
servers/jobber/src/tools/scheduling-tools.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* Scheduling Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const schedulingTools = {
|
||||||
|
list_visits: {
|
||||||
|
description: 'List all visits with optional date filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
startDate: z.string().optional().describe('ISO 8601 date'),
|
||||||
|
endDate: z.string().optional().describe('ISO 8601 date'),
|
||||||
|
status: z.enum(['UNSCHEDULED', 'SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']).optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
if (args.startDate) {
|
||||||
|
filterConditions.push(`startDate: "${args.startDate}"`);
|
||||||
|
}
|
||||||
|
if (args.endDate) {
|
||||||
|
filterConditions.push(`endDate: "${args.endDate}"`);
|
||||||
|
}
|
||||||
|
if (args.status) {
|
||||||
|
filterConditions.push(`status: ${args.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = filterConditions.length > 0 ? `, filter: { ${filterConditions.join(', ')} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListVisits {
|
||||||
|
visits(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
${JobberClient.visitFields}
|
||||||
|
job {
|
||||||
|
id
|
||||||
|
jobNumber
|
||||||
|
title
|
||||||
|
}
|
||||||
|
assignedUsers {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
visits: data.visits.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.visits.pageInfo,
|
||||||
|
totalCount: data.visits.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_visit: {
|
||||||
|
description: 'Get a specific visit by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
visitId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetVisit($id: ID!) {
|
||||||
|
visit(id: $id) {
|
||||||
|
${JobberClient.visitFields}
|
||||||
|
job {
|
||||||
|
id
|
||||||
|
jobNumber
|
||||||
|
title
|
||||||
|
}
|
||||||
|
assignedUsers {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.visitId });
|
||||||
|
return { visit: data.visit };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_visit: {
|
||||||
|
description: 'Create a new visit',
|
||||||
|
inputSchema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
jobId: z.string().optional(),
|
||||||
|
startAt: z.string().describe('ISO 8601 datetime'),
|
||||||
|
endAt: z.string().describe('ISO 8601 datetime'),
|
||||||
|
userIds: z.array(z.string()).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateVisit($input: VisitInput!) {
|
||||||
|
visitCreate(input: $input) {
|
||||||
|
visit {
|
||||||
|
${JobberClient.visitFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
title: args.title,
|
||||||
|
jobId: args.jobId,
|
||||||
|
startAt: args.startAt,
|
||||||
|
endAt: args.endAt,
|
||||||
|
userIds: args.userIds,
|
||||||
|
notes: args.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.visitCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Visit creation failed: ${data.visitCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visit: data.visitCreate.visit };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update_visit: {
|
||||||
|
description: 'Update an existing visit',
|
||||||
|
inputSchema: z.object({
|
||||||
|
visitId: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
startAt: z.string().optional().describe('ISO 8601 datetime'),
|
||||||
|
endAt: z.string().optional().describe('ISO 8601 datetime'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation UpdateVisit($id: ID!, $input: VisitUpdateInput!) {
|
||||||
|
visitUpdate(id: $id, input: $input) {
|
||||||
|
visit {
|
||||||
|
${JobberClient.visitFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: any = {};
|
||||||
|
if (args.title) input.title = args.title;
|
||||||
|
if (args.startAt) input.startAt = args.startAt;
|
||||||
|
if (args.endAt) input.endAt = args.endAt;
|
||||||
|
if (args.notes) input.notes = args.notes;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.visitId, input });
|
||||||
|
|
||||||
|
if (data.visitUpdate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Visit update failed: ${data.visitUpdate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visit: data.visitUpdate.visit };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
complete_visit: {
|
||||||
|
description: 'Mark a visit as completed',
|
||||||
|
inputSchema: z.object({
|
||||||
|
visitId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CompleteVisit($id: ID!) {
|
||||||
|
visitComplete(id: $id) {
|
||||||
|
visit {
|
||||||
|
${JobberClient.visitFields}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { id: args.visitId });
|
||||||
|
|
||||||
|
if (data.visitComplete.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Visit completion failed: ${data.visitComplete.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visit: data.visitComplete.visit };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list_visit_assignments: {
|
||||||
|
description: 'List all user assignments for a specific visit',
|
||||||
|
inputSchema: z.object({
|
||||||
|
visitId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetVisitAssignments($id: ID!) {
|
||||||
|
visit(id: $id) {
|
||||||
|
assignedUsers {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.visitId });
|
||||||
|
return { assignedUsers: data.visit.assignedUsers };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
180
servers/jobber/src/tools/team-tools.ts
Normal file
180
servers/jobber/src/tools/team-tools.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Team Tools for Jobber MCP Server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JobberClient } from '../clients/jobber.js';
|
||||||
|
|
||||||
|
export const teamTools = {
|
||||||
|
list_users: {
|
||||||
|
description: 'List all users in the organization',
|
||||||
|
inputSchema: z.object({
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filters = args.isActive !== undefined ? `, filter: { isActive: ${args.isActive} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListUsers {
|
||||||
|
users(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
users: data.users.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.users.pageInfo,
|
||||||
|
totalCount: data.users.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
get_user: {
|
||||||
|
description: 'Get a specific user by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const query = `
|
||||||
|
query GetUser($id: ID!) {
|
||||||
|
user(id: $id) {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query, { id: args.userId });
|
||||||
|
return { user: data.user };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list_time_entries: {
|
||||||
|
description: 'List time entries with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
visitId: z.string().optional(),
|
||||||
|
startDate: z.string().optional().describe('ISO 8601 date'),
|
||||||
|
endDate: z.string().optional().describe('ISO 8601 date'),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
if (args.userId) {
|
||||||
|
filterConditions.push(`userId: "${args.userId}"`);
|
||||||
|
}
|
||||||
|
if (args.visitId) {
|
||||||
|
filterConditions.push(`visitId: "${args.visitId}"`);
|
||||||
|
}
|
||||||
|
if (args.startDate) {
|
||||||
|
filterConditions.push(`startDate: "${args.startDate}"`);
|
||||||
|
}
|
||||||
|
if (args.endDate) {
|
||||||
|
filterConditions.push(`endDate: "${args.endDate}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = filterConditions.length > 0 ? `, filter: { ${filterConditions.join(', ')} }` : '';
|
||||||
|
const afterClause = args.cursor ? `, after: "${args.cursor}"` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query ListTimeEntries {
|
||||||
|
timeEntries(first: ${args.limit}${afterClause}${filters}) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
startAt
|
||||||
|
endAt
|
||||||
|
duration
|
||||||
|
notes
|
||||||
|
user {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
visit {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await client.query(query);
|
||||||
|
return {
|
||||||
|
timeEntries: data.timeEntries.edges.map((e: any) => e.node),
|
||||||
|
pageInfo: data.timeEntries.pageInfo,
|
||||||
|
totalCount: data.timeEntries.totalCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create_time_entry: {
|
||||||
|
description: 'Create a new time entry',
|
||||||
|
inputSchema: z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
visitId: z.string().optional(),
|
||||||
|
startAt: z.string().describe('ISO 8601 datetime'),
|
||||||
|
endAt: z.string().optional().describe('ISO 8601 datetime'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (client: JobberClient, args: any) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CreateTimeEntry($input: TimeEntryInput!) {
|
||||||
|
timeEntryCreate(input: $input) {
|
||||||
|
timeEntry {
|
||||||
|
id
|
||||||
|
startAt
|
||||||
|
endAt
|
||||||
|
duration
|
||||||
|
notes
|
||||||
|
user {
|
||||||
|
${JobberClient.userFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
message
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
userId: args.userId,
|
||||||
|
visitId: args.visitId,
|
||||||
|
startAt: args.startAt,
|
||||||
|
endAt: args.endAt,
|
||||||
|
notes: args.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await client.mutate(mutation, { input });
|
||||||
|
|
||||||
|
if (data.timeEntryCreate.userErrors?.length > 0) {
|
||||||
|
throw new Error(`Time entry creation failed: ${data.timeEntryCreate.userErrors.map((e: any) => e.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { timeEntry: data.timeEntryCreate.timeEntry };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
97
servers/jobber/src/ui/react-app/client-detail.tsx
Normal file
97
servers/jobber/src/ui/react-app/client-detail.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Client Detail - Detailed view of a single client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ClientDetailProps {
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientDetail({ clientId }: ClientDetailProps) {
|
||||||
|
const [client, setClient] = useState<any>(null);
|
||||||
|
const [properties, setProperties] = useState<any[]>([]);
|
||||||
|
const [recentJobs, setRecentJobs] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load client details via MCP tools
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="client-detail">
|
||||||
|
<header>
|
||||||
|
<h1>{client.firstName} {client.lastName}</h1>
|
||||||
|
{client.companyName && <p className="company">{client.companyName}</p>}
|
||||||
|
{client.isArchived && <span className="badge archived">Archived</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="client-info-grid">
|
||||||
|
<section className="contact-info">
|
||||||
|
<h2>Contact Information</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Email:</dt>
|
||||||
|
<dd>{client.email || 'Not provided'}</dd>
|
||||||
|
|
||||||
|
<dt>Phone:</dt>
|
||||||
|
<dd>{client.phone || 'Not provided'}</dd>
|
||||||
|
|
||||||
|
{client.billingAddress && (
|
||||||
|
<>
|
||||||
|
<dt>Billing Address:</dt>
|
||||||
|
<dd>
|
||||||
|
{client.billingAddress.street1}<br />
|
||||||
|
{client.billingAddress.street2 && <>{client.billingAddress.street2}<br /></>}
|
||||||
|
{client.billingAddress.city}, {client.billingAddress.province} {client.billingAddress.postalCode}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="properties">
|
||||||
|
<h2>Properties</h2>
|
||||||
|
{properties.map((property) => (
|
||||||
|
<div key={property.id} className="property-card">
|
||||||
|
{property.isDefault && <span className="badge">Default</span>}
|
||||||
|
<address>
|
||||||
|
{property.address.street1}<br />
|
||||||
|
{property.address.street2 && <>{property.address.street2}<br /></>}
|
||||||
|
{property.address.city}, {property.address.province} {property.address.postalCode}
|
||||||
|
</address>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="recent-jobs">
|
||||||
|
<h2>Recent Jobs</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job #</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentJobs.map((job) => (
|
||||||
|
<tr key={job.id}>
|
||||||
|
<td>{job.jobNumber}</td>
|
||||||
|
<td>{job.title}</td>
|
||||||
|
<td>{job.status}</td>
|
||||||
|
<td>{new Date(job.createdAt).toLocaleDateString()}</td>
|
||||||
|
<td>${job.total?.amount || 0}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
servers/jobber/src/ui/react-app/client-grid.tsx
Normal file
82
servers/jobber/src/ui/react-app/client-grid.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Client Grid - Searchable table of all clients
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ClientGrid() {
|
||||||
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load clients via MCP tools
|
||||||
|
}, [showArchived]);
|
||||||
|
|
||||||
|
const filteredClients = clients.filter((client) =>
|
||||||
|
client.firstName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
client.lastName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
client.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
client.companyName?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="client-grid">
|
||||||
|
<header>
|
||||||
|
<h1>Clients</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search clients..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={(e) => setShowArchived((e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
Show Archived
|
||||||
|
</label>
|
||||||
|
<button className="primary">+ New Client</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Company</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredClients.map((client) => (
|
||||||
|
<tr key={client.id}>
|
||||||
|
<td>{client.firstName} {client.lastName}</td>
|
||||||
|
<td>{client.companyName || '—'}</td>
|
||||||
|
<td>{client.email || '—'}</td>
|
||||||
|
<td>{client.phone || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{client.isArchived ? (
|
||||||
|
<span className="badge archived">Archived</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge active">Active</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button>View</button>
|
||||||
|
<button>Edit</button>
|
||||||
|
{!client.isArchived && <button>Archive</button>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
servers/jobber/src/ui/react-app/expense-tracker.tsx
Normal file
74
servers/jobber/src/ui/react-app/expense-tracker.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Expense Tracker - Track and manage expenses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ExpenseTracker() {
|
||||||
|
const [expenses, setExpenses] = useState<any[]>([]);
|
||||||
|
const [filter, setFilter] = useState({
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
userId: '',
|
||||||
|
jobId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalExpenses = expenses.reduce((sum, exp) => sum + exp.amount.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="expense-tracker">
|
||||||
|
<header>
|
||||||
|
<h1>Expenses</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
placeholder="Start Date"
|
||||||
|
value={filter.startDate}
|
||||||
|
onChange={(e) => setFilter({ ...filter, startDate: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
placeholder="End Date"
|
||||||
|
value={filter.endDate}
|
||||||
|
onChange={(e) => setFilter({ ...filter, endDate: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<button className="primary">+ New Expense</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="expense-summary">
|
||||||
|
<h2>Total Expenses: ${totalExpenses.toFixed(2)}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Job</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{expenses.map((expense) => (
|
||||||
|
<tr key={expense.id}>
|
||||||
|
<td>{new Date(expense.date).toLocaleDateString()}</td>
|
||||||
|
<td>{expense.description}</td>
|
||||||
|
<td>{expense.category || '—'}</td>
|
||||||
|
<td>{expense.user ? `${expense.user.firstName} ${expense.user.lastName}` : '—'}</td>
|
||||||
|
<td>{expense.job ? expense.job.jobNumber : '—'}</td>
|
||||||
|
<td>${expense.amount.amount.toFixed(2)}</td>
|
||||||
|
<td>
|
||||||
|
<button>Edit</button>
|
||||||
|
<button>Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
servers/jobber/src/ui/react-app/invoice-dashboard.tsx
Normal file
73
servers/jobber/src/ui/react-app/invoice-dashboard.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Invoice Dashboard - Overview of invoicing metrics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function InvoiceDashboard() {
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
totalSent: 0,
|
||||||
|
paid: 0,
|
||||||
|
overdue: 0,
|
||||||
|
amountDue: 0,
|
||||||
|
amountPaid: 0,
|
||||||
|
});
|
||||||
|
const [recentInvoices, setRecentInvoices] = useState<any[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="invoice-dashboard">
|
||||||
|
<h1>Invoice Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Total Sent</h3>
|
||||||
|
<p className="stat-value">{stats.totalSent}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card success">
|
||||||
|
<h3>Paid</h3>
|
||||||
|
<p className="stat-value">{stats.paid}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card warning">
|
||||||
|
<h3>Overdue</h3>
|
||||||
|
<p className="stat-value">{stats.overdue}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Amount Due</h3>
|
||||||
|
<p className="stat-value">${stats.amountDue.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card success">
|
||||||
|
<h3>Amount Paid</h3>
|
||||||
|
<p className="stat-value">${stats.amountPaid.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="recent-invoices">
|
||||||
|
<h2>Recent Invoices</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Invoice #</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Due Date</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Amount Due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentInvoices.map((invoice) => (
|
||||||
|
<tr key={invoice.id}>
|
||||||
|
<td>{invoice.invoiceNumber}</td>
|
||||||
|
<td>{invoice.client.firstName} {invoice.client.lastName}</td>
|
||||||
|
<td><span className={`badge status-${invoice.status.toLowerCase()}`}>{invoice.status}</span></td>
|
||||||
|
<td>{invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : '—'}</td>
|
||||||
|
<td>${invoice.total?.amount || 0}</td>
|
||||||
|
<td>${invoice.amountDue?.amount || 0}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
servers/jobber/src/ui/react-app/invoice-detail.tsx
Normal file
89
servers/jobber/src/ui/react-app/invoice-detail.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Invoice Detail - Detailed view of a single invoice
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface InvoiceDetailProps {
|
||||||
|
invoiceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||||
|
const [invoice, setInvoice] = useState<any>(null);
|
||||||
|
const [payments, setPayments] = useState<any[]>([]);
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="invoice-detail">
|
||||||
|
<header>
|
||||||
|
<h1>Invoice #{invoice.invoiceNumber}</h1>
|
||||||
|
<span className={`badge status-${invoice.status.toLowerCase()}`}>{invoice.status}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="invoice-info">
|
||||||
|
<h2>Invoice Information</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Client:</dt>
|
||||||
|
<dd>{invoice.client.firstName} {invoice.client.lastName}</dd>
|
||||||
|
|
||||||
|
<dt>Subject:</dt>
|
||||||
|
<dd>{invoice.subject}</dd>
|
||||||
|
|
||||||
|
<dt>Created:</dt>
|
||||||
|
<dd>{new Date(invoice.createdAt).toLocaleDateString()}</dd>
|
||||||
|
|
||||||
|
{invoice.dueDate && (
|
||||||
|
<>
|
||||||
|
<dt>Due Date:</dt>
|
||||||
|
<dd>{new Date(invoice.dueDate).toLocaleDateString()}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<dt>Subtotal:</dt>
|
||||||
|
<dd>${invoice.subtotal?.amount || 0}</dd>
|
||||||
|
|
||||||
|
<dt>Total:</dt>
|
||||||
|
<dd>${invoice.total?.amount || 0}</dd>
|
||||||
|
|
||||||
|
<dt>Amount Paid:</dt>
|
||||||
|
<dd>${invoice.amountPaid?.amount || 0}</dd>
|
||||||
|
|
||||||
|
<dt>Amount Due:</dt>
|
||||||
|
<dd className="amount-due">${invoice.amountDue?.amount || 0}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="payments">
|
||||||
|
<h2>Payments</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Method</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{payments.map((payment) => (
|
||||||
|
<tr key={payment.id}>
|
||||||
|
<td>{new Date(payment.paidOn).toLocaleDateString()}</td>
|
||||||
|
<td>${payment.amount.amount}</td>
|
||||||
|
<td>{payment.paymentMethod}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<button className="primary">+ Record Payment</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="actions">
|
||||||
|
{invoice.status === 'DRAFT' && <button className="primary">Send Invoice</button>}
|
||||||
|
<button>Download PDF</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
servers/jobber/src/ui/react-app/job-dashboard.tsx
Normal file
84
servers/jobber/src/ui/react-app/job-dashboard.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Job Dashboard - Overview of all jobs with status breakdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface JobStats {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
completed: number;
|
||||||
|
late: number;
|
||||||
|
requiresInvoicing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobDashboard() {
|
||||||
|
const [stats, setStats] = useState<JobStats>({
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
completed: 0,
|
||||||
|
late: 0,
|
||||||
|
requiresInvoicing: 0,
|
||||||
|
});
|
||||||
|
const [recentJobs, setRecentJobs] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// In a real implementation, this would call the MCP tools
|
||||||
|
// For now, this is a UI template
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="job-dashboard">
|
||||||
|
<h1>Job Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Total Jobs</h3>
|
||||||
|
<p className="stat-value">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card active">
|
||||||
|
<h3>Active</h3>
|
||||||
|
<p className="stat-value">{stats.active}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card completed">
|
||||||
|
<h3>Completed</h3>
|
||||||
|
<p className="stat-value">{stats.completed}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card late">
|
||||||
|
<h3>Late</h3>
|
||||||
|
<p className="stat-value">{stats.late}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card invoicing">
|
||||||
|
<h3>Requires Invoicing</h3>
|
||||||
|
<p className="stat-value">{stats.requiresInvoicing}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="recent-jobs">
|
||||||
|
<h2>Recent Jobs</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job #</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentJobs.map((job) => (
|
||||||
|
<tr key={job.id}>
|
||||||
|
<td>{job.jobNumber}</td>
|
||||||
|
<td>{job.title}</td>
|
||||||
|
<td>{job.client.firstName} {job.client.lastName}</td>
|
||||||
|
<td><span className={`status ${job.status.toLowerCase()}`}>{job.status}</span></td>
|
||||||
|
<td>${job.total?.amount || 0}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
servers/jobber/src/ui/react-app/job-detail.tsx
Normal file
99
servers/jobber/src/ui/react-app/job-detail.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Job Detail - Detailed view of a single job
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface JobDetailProps {
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobDetail({ jobId }: JobDetailProps) {
|
||||||
|
const [job, setJob] = useState<any>(null);
|
||||||
|
const [visits, setVisits] = useState<any[]>([]);
|
||||||
|
const [lineItems, setLineItems] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load job details via MCP tools
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="job-detail">
|
||||||
|
<header>
|
||||||
|
<h1>Job #{job.jobNumber} - {job.title}</h1>
|
||||||
|
<span className={`status ${job.status.toLowerCase()}`}>{job.status}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="job-info">
|
||||||
|
<h2>Job Information</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Client:</dt>
|
||||||
|
<dd>{job.client.firstName} {job.client.lastName}</dd>
|
||||||
|
|
||||||
|
<dt>Description:</dt>
|
||||||
|
<dd>{job.description || 'No description'}</dd>
|
||||||
|
|
||||||
|
<dt>Created:</dt>
|
||||||
|
<dd>{new Date(job.createdAt).toLocaleDateString()}</dd>
|
||||||
|
|
||||||
|
<dt>Total:</dt>
|
||||||
|
<dd>${job.total?.amount || 0} {job.total?.currency}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="visits">
|
||||||
|
<h2>Visits</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>End</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visits.map((visit) => (
|
||||||
|
<tr key={visit.id}>
|
||||||
|
<td>{visit.title}</td>
|
||||||
|
<td>{new Date(visit.startAt).toLocaleString()}</td>
|
||||||
|
<td>{new Date(visit.endAt).toLocaleString()}</td>
|
||||||
|
<td>{visit.status}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="line-items">
|
||||||
|
<h2>Line Items</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Qty</th>
|
||||||
|
<th>Unit Price</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lineItems.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td>{item.description}</td>
|
||||||
|
<td>{item.quantity}</td>
|
||||||
|
<td>${item.unitPrice?.amount || 0}</td>
|
||||||
|
<td>${item.total?.amount || 0}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
servers/jobber/src/ui/react-app/job-grid.tsx
Normal file
92
servers/jobber/src/ui/react-app/job-grid.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Job Grid - Searchable, filterable table of all jobs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function JobGrid() {
|
||||||
|
const [jobs, setJobs] = useState<any[]>([]);
|
||||||
|
const [filter, setFilter] = useState<string>('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
'ALL',
|
||||||
|
'ACTION_REQUIRED',
|
||||||
|
'ACTIVE',
|
||||||
|
'CANCELLED',
|
||||||
|
'COMPLETED',
|
||||||
|
'LATE',
|
||||||
|
'REQUIRES_INVOICING',
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load jobs via MCP tools
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
const filteredJobs = jobs.filter((job) =>
|
||||||
|
job.title.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
job.jobNumber.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="job-grid">
|
||||||
|
<header>
|
||||||
|
<h1>All Jobs</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search jobs..."
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter((e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{status.replace(/_/g, ' ')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job #</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredJobs.map((job) => (
|
||||||
|
<tr key={job.id}>
|
||||||
|
<td>{job.jobNumber}</td>
|
||||||
|
<td>{job.title}</td>
|
||||||
|
<td>{job.client.firstName} {job.client.lastName}</td>
|
||||||
|
<td><span className={`badge status-${job.status.toLowerCase()}`}>{job.status}</span></td>
|
||||||
|
<td>{new Date(job.createdAt).toLocaleDateString()}</td>
|
||||||
|
<td>${job.total?.amount || 0}</td>
|
||||||
|
<td>
|
||||||
|
<button>View</button>
|
||||||
|
<button>Edit</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{loading && <div className="loading">Loading...</div>}
|
||||||
|
{filteredJobs.length === 0 && !loading && (
|
||||||
|
<div className="empty-state">No jobs found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
servers/jobber/src/ui/react-app/job-profit-report.tsx
Normal file
91
servers/jobber/src/ui/react-app/job-profit-report.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Job Profit Report - Profitability analysis by job
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function JobProfitReport() {
|
||||||
|
const [dateRange, setDateRange] = useState({
|
||||||
|
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0],
|
||||||
|
endDate: new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
const [report, setReport] = useState<any>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="job-profit-report">
|
||||||
|
<header>
|
||||||
|
<h1>Job Profitability Report</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.startDate}
|
||||||
|
onChange={(e) => setDateRange({ ...dateRange, startDate: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.endDate}
|
||||||
|
onChange={(e) => setDateRange({ ...dateRange, endDate: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<button className="primary">Generate Report</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Total Revenue</h3>
|
||||||
|
<p className="stat-value">${report.totalRevenue?.amount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Total Costs</h3>
|
||||||
|
<p className="stat-value">${report.totalCosts?.amount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card success">
|
||||||
|
<h3>Total Profit</h3>
|
||||||
|
<p className="stat-value">${report.totalProfit?.amount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Profit Margin</h3>
|
||||||
|
<p className="stat-value">{report.profitMargin?.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="job-breakdown">
|
||||||
|
<h2>Job Breakdown</h2>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job #</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Revenue</th>
|
||||||
|
<th>Costs</th>
|
||||||
|
<th>Profit</th>
|
||||||
|
<th>Margin</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.jobBreakdown?.map((job: any) => (
|
||||||
|
<tr key={job.jobId}>
|
||||||
|
<td>{job.jobNumber}</td>
|
||||||
|
<td>{job.title}</td>
|
||||||
|
<td>${job.revenue.amount.toFixed(2)}</td>
|
||||||
|
<td>${job.costs.amount.toFixed(2)}</td>
|
||||||
|
<td className={job.profit.amount >= 0 ? 'positive' : 'negative'}>
|
||||||
|
${job.profit.amount.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td>{job.margin.toFixed(1)}%</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!report && (
|
||||||
|
<div className="empty-state">Select a date range and generate a report</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
servers/jobber/src/ui/react-app/product-catalog.tsx
Normal file
65
servers/jobber/src/ui/react-app/product-catalog.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Product Catalog - Manage products and services
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ProductCatalog() {
|
||||||
|
const [products, setProducts] = useState<any[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('ALL');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
|
const filteredProducts = products.filter((product) => {
|
||||||
|
if (typeFilter !== 'ALL' && product.type !== typeFilter) return false;
|
||||||
|
if (!showArchived && product.isArchived) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="product-catalog">
|
||||||
|
<header>
|
||||||
|
<h1>Products & Services</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<select value={typeFilter} onChange={(e) => setTypeFilter((e.target as HTMLSelectElement).value)}>
|
||||||
|
<option value="ALL">All Types</option>
|
||||||
|
<option value="PRODUCT">Products</option>
|
||||||
|
<option value="SERVICE">Services</option>
|
||||||
|
</select>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={(e) => setShowArchived((e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
Show Archived
|
||||||
|
</label>
|
||||||
|
<button className="primary">+ New Product/Service</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="product-grid">
|
||||||
|
{filteredProducts.map((product) => (
|
||||||
|
<div key={product.id} className="product-card">
|
||||||
|
<div className="product-type">
|
||||||
|
<span className={`badge ${product.type.toLowerCase()}`}>{product.type}</span>
|
||||||
|
{product.isArchived && <span className="badge archived">Archived</span>}
|
||||||
|
</div>
|
||||||
|
<h3>{product.name}</h3>
|
||||||
|
<p className="product-description">{product.description || 'No description'}</p>
|
||||||
|
<div className="product-price">
|
||||||
|
${product.unitPrice?.amount.toFixed(2) || '0.00'} {product.unitPrice?.currency}
|
||||||
|
</div>
|
||||||
|
<div className="product-actions">
|
||||||
|
<button>Edit</button>
|
||||||
|
{!product.isArchived && <button>Archive</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredProducts.length === 0 && (
|
||||||
|
<div className="empty-state">No products or services found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
servers/jobber/src/ui/react-app/quote-builder.tsx
Normal file
143
servers/jobber/src/ui/react-app/quote-builder.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Quote Builder - Create and edit quotes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface LineItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuoteBuilder() {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [clientId, setClientId] = useState('');
|
||||||
|
const [lineItems, setLineItems] = useState<LineItem[]>([]);
|
||||||
|
|
||||||
|
const addLineItem = () => {
|
||||||
|
setLineItems([
|
||||||
|
...lineItems,
|
||||||
|
{
|
||||||
|
id: Math.random().toString(36),
|
||||||
|
name: '',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLineItem = (id: string, field: keyof LineItem, value: any) => {
|
||||||
|
setLineItems(
|
||||||
|
lineItems.map((item) =>
|
||||||
|
item.id === id ? { ...item, [field]: value } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLineItem = (id: string) => {
|
||||||
|
setLineItems(lineItems.filter((item) => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = () => {
|
||||||
|
return lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Create quote via MCP tools
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quote-builder">
|
||||||
|
<h1>Build Quote</h1>
|
||||||
|
|
||||||
|
<section className="quote-info">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Quote Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="Enter quote title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Client</label>
|
||||||
|
<select value={clientId} onChange={(e) => setClientId((e.target as HTMLSelectElement).value)}>
|
||||||
|
<option value="">Select a client...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="line-items">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Line Items</h2>
|
||||||
|
<button onClick={addLineItem}>+ Add Item</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Qty</th>
|
||||||
|
<th>Unit Price</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lineItems.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => updateLineItem(item.id, 'name', (e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.description || ''}
|
||||||
|
onChange={(e) => updateLineItem(item.id, 'description', (e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateLineItem(item.id, 'quantity', parseFloat((e.target as HTMLInputElement).value))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.unitPrice}
|
||||||
|
onChange={(e) => updateLineItem(item.id, 'unitPrice', parseFloat((e.target as HTMLInputElement).value))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>${(item.quantity * item.unitPrice).toFixed(2)}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => removeLineItem(item.id)}>×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="total">
|
||||||
|
<strong>Total: ${calculateTotal().toFixed(2)}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="actions">
|
||||||
|
<button onClick={handleSubmit} className="primary">Save Quote</button>
|
||||||
|
<button>Cancel</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
servers/jobber/src/ui/react-app/quote-grid.tsx
Normal file
61
servers/jobber/src/ui/react-app/quote-grid.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Quote Grid - List of all quotes with filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function QuoteGrid() {
|
||||||
|
const [quotes, setQuotes] = useState<any[]>([]);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||||
|
|
||||||
|
const statusOptions = ['ALL', 'DRAFT', 'SENT', 'APPROVED', 'CHANGES_REQUESTED', 'CONVERTED', 'EXPIRED'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quote-grid">
|
||||||
|
<header>
|
||||||
|
<h1>Quotes</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<select value={statusFilter} onChange={(e) => setStatusFilter((e.target as HTMLSelectElement).value)}>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{status.replace(/_/g, ' ')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="primary">+ New Quote</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Quote #</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{quotes.map((quote) => (
|
||||||
|
<tr key={quote.id}>
|
||||||
|
<td>{quote.quoteNumber}</td>
|
||||||
|
<td>{quote.title}</td>
|
||||||
|
<td>{quote.client.firstName} {quote.client.lastName}</td>
|
||||||
|
<td><span className={`badge status-${quote.status.toLowerCase()}`}>{quote.status}</span></td>
|
||||||
|
<td>{new Date(quote.createdAt).toLocaleDateString()}</td>
|
||||||
|
<td>${quote.total?.amount || 0}</td>
|
||||||
|
<td>
|
||||||
|
<button>View</button>
|
||||||
|
{quote.status === 'DRAFT' && <button>Send</button>}
|
||||||
|
{quote.status === 'APPROVED' && <button>Convert to Job</button>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
servers/jobber/src/ui/react-app/request-inbox.tsx
Normal file
63
servers/jobber/src/ui/react-app/request-inbox.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Request Inbox - Manage client requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function RequestInbox() {
|
||||||
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||||
|
|
||||||
|
const statusOptions = ['ALL', 'NEW', 'IN_PROGRESS', 'CONVERTED', 'CLOSED'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="request-inbox">
|
||||||
|
<header>
|
||||||
|
<h1>Client Requests</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<select value={statusFilter} onChange={(e) => setStatusFilter((e.target as HTMLSelectElement).value)}>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<option key={status} value={status}>{status.replace(/_/g, ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="primary">+ New Request</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="request-list">
|
||||||
|
{requests.map((request) => (
|
||||||
|
<div key={request.id} className="request-card">
|
||||||
|
<div className="request-header">
|
||||||
|
<h3>{request.title}</h3>
|
||||||
|
<span className={`badge status-${request.status.toLowerCase()}`}>{request.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="request-description">{request.description || 'No description'}</p>
|
||||||
|
<div className="request-meta">
|
||||||
|
{request.client && (
|
||||||
|
<span className="client-name">
|
||||||
|
{request.client.firstName} {request.client.lastName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="request-date">
|
||||||
|
{new Date(request.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="request-actions">
|
||||||
|
<button>View</button>
|
||||||
|
{request.status === 'NEW' && (
|
||||||
|
<>
|
||||||
|
<button>Convert to Quote</button>
|
||||||
|
<button>Convert to Job</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.length === 0 && (
|
||||||
|
<div className="empty-state">No requests found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
servers/jobber/src/ui/react-app/revenue-dashboard.tsx
Normal file
67
servers/jobber/src/ui/react-app/revenue-dashboard.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Revenue Dashboard - Revenue reporting and analytics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function RevenueDashboard() {
|
||||||
|
const [dateRange, setDateRange] = useState({
|
||||||
|
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0],
|
||||||
|
endDate: new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
const [report, setReport] = useState<any>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="revenue-dashboard">
|
||||||
|
<header>
|
||||||
|
<h1>Revenue Dashboard</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.startDate}
|
||||||
|
onChange={(e) => setDateRange({ ...dateRange, startDate: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.endDate}
|
||||||
|
onChange={(e) => setDateRange({ ...dateRange, endDate: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<button className="primary">Generate Report</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Total Revenue</h3>
|
||||||
|
<p className="stat-value">${report.totalRevenue?.amount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Invoiced Revenue</h3>
|
||||||
|
<p className="stat-value">${report.invoicedRevenue?.amount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card success">
|
||||||
|
<h3>Paid Revenue</h3>
|
||||||
|
<p className="stat-value">${report.paidRevenue?.amount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card warning">
|
||||||
|
<h3>Outstanding Revenue</h3>
|
||||||
|
<p className="stat-value">${report.outstandingRevenue?.amount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="revenue-chart">
|
||||||
|
<h2>Revenue Trend</h2>
|
||||||
|
{/* Chart would go here */}
|
||||||
|
<div className="chart-placeholder">Revenue chart visualization</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!report && (
|
||||||
|
<div className="empty-state">Select a date range and generate a report</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
servers/jobber/src/ui/react-app/schedule-calendar.tsx
Normal file
85
servers/jobber/src/ui/react-app/schedule-calendar.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Schedule Calendar - Calendar view of visits and appointments
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ScheduleCalendar() {
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [visits, setVisits] = useState<any[]>([]);
|
||||||
|
const [viewMode, setViewMode] = useState<'day' | 'week' | 'month'>('week');
|
||||||
|
|
||||||
|
const getDaysInView = () => {
|
||||||
|
// Generate calendar days based on view mode
|
||||||
|
const days: Date[] = [];
|
||||||
|
// Logic to generate days for current view
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVisitsForDay = (date: Date) => {
|
||||||
|
return visits.filter((visit) => {
|
||||||
|
const visitDate = new Date(visit.startAt);
|
||||||
|
return visitDate.toDateString() === date.toDateString();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="schedule-calendar">
|
||||||
|
<header>
|
||||||
|
<h1>Schedule</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<button onClick={() => setCurrentDate(new Date())}>Today</button>
|
||||||
|
<div className="view-mode">
|
||||||
|
<button
|
||||||
|
className={viewMode === 'day' ? 'active' : ''}
|
||||||
|
onClick={() => setViewMode('day')}
|
||||||
|
>
|
||||||
|
Day
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={viewMode === 'week' ? 'active' : ''}
|
||||||
|
onClick={() => setViewMode('week')}
|
||||||
|
>
|
||||||
|
Week
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={viewMode === 'month' ? 'active' : ''}
|
||||||
|
onClick={() => setViewMode('month')}
|
||||||
|
>
|
||||||
|
Month
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="primary">+ Schedule Visit</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
{getDaysInView().map((day) => (
|
||||||
|
<div key={day.toISOString()} className="calendar-day">
|
||||||
|
<div className="day-header">
|
||||||
|
<span className="day-name">{day.toLocaleDateString('en-US', { weekday: 'short' })}</span>
|
||||||
|
<span className="day-number">{day.getDate()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="day-visits">
|
||||||
|
{getVisitsForDay(day).map((visit) => (
|
||||||
|
<div key={visit.id} className={`visit-card status-${visit.status.toLowerCase()}`}>
|
||||||
|
<div className="visit-time">
|
||||||
|
{new Date(visit.startAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
<div className="visit-title">{visit.title}</div>
|
||||||
|
{visit.assignedUsers && visit.assignedUsers.length > 0 && (
|
||||||
|
<div className="visit-users">
|
||||||
|
{visit.assignedUsers.map((user: any) => (
|
||||||
|
<span key={user.id} className="user-badge">{user.firstName[0]}{user.lastName[0]}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
servers/jobber/src/ui/react-app/team-dashboard.tsx
Normal file
59
servers/jobber/src/ui/react-app/team-dashboard.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Team Dashboard - Overview of team members and their activity
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function TeamDashboard() {
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [teamStats, setTeamStats] = useState({
|
||||||
|
totalUsers: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
scheduledVisits: 0,
|
||||||
|
completedVisits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="team-dashboard">
|
||||||
|
<h1>Team Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Total Team Members</h3>
|
||||||
|
<p className="stat-value">{teamStats.totalUsers}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card active">
|
||||||
|
<h3>Active</h3>
|
||||||
|
<p className="stat-value">{teamStats.activeUsers}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Scheduled Visits</h3>
|
||||||
|
<p className="stat-value">{teamStats.scheduledVisits}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card success">
|
||||||
|
<h3>Completed Visits</h3>
|
||||||
|
<p className="stat-value">{teamStats.completedVisits}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="team-members">
|
||||||
|
<h2>Team Members</h2>
|
||||||
|
<div className="user-grid">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.id} className="user-card">
|
||||||
|
<div className="user-avatar">{user.firstName[0]}{user.lastName[0]}</div>
|
||||||
|
<div className="user-info">
|
||||||
|
<h3>{user.firstName} {user.lastName}</h3>
|
||||||
|
<p className="user-role">{user.role}</p>
|
||||||
|
<p className="user-email">{user.email}</p>
|
||||||
|
<span className={`badge ${user.isActive ? 'active' : 'inactive'}`}>
|
||||||
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
servers/jobber/src/ui/react-app/team-schedule.tsx
Normal file
66
servers/jobber/src/ui/react-app/team-schedule.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Team Schedule - View schedules for all team members
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function TeamSchedule() {
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [visits, setVisits] = useState<any[]>([]);
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
|
||||||
|
const getUserVisits = (userId: string) => {
|
||||||
|
return visits.filter((visit) =>
|
||||||
|
visit.assignedUsers?.some((u: any) => u.id === userId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="team-schedule">
|
||||||
|
<header>
|
||||||
|
<h1>Team Schedule</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate.toISOString().split('T')[0]}
|
||||||
|
onChange={(e) => setSelectedDate(new Date((e.target as HTMLInputElement).value))}
|
||||||
|
/>
|
||||||
|
<button className="primary">+ Schedule Visit</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="schedule-grid">
|
||||||
|
{users.map((user) => {
|
||||||
|
const userVisits = getUserVisits(user.id);
|
||||||
|
return (
|
||||||
|
<div key={user.id} className="user-schedule">
|
||||||
|
<div className="user-header">
|
||||||
|
<div className="user-avatar">{user.firstName[0]}{user.lastName[0]}</div>
|
||||||
|
<div>
|
||||||
|
<h3>{user.firstName} {user.lastName}</h3>
|
||||||
|
<p className="visits-count">{userVisits.length} visits</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="visits-timeline">
|
||||||
|
{userVisits.map((visit) => (
|
||||||
|
<div key={visit.id} className={`visit-item status-${visit.status.toLowerCase()}`}>
|
||||||
|
<div className="visit-time">
|
||||||
|
{new Date(visit.startAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{' - '}
|
||||||
|
{new Date(visit.endAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
<div className="visit-title">{visit.title}</div>
|
||||||
|
{visit.job && <div className="visit-job">Job #{visit.job.jobNumber}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{userVisits.length === 0 && (
|
||||||
|
<div className="empty-schedule">No visits scheduled</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
servers/jobber/src/ui/react-app/utilization-chart.tsx
Normal file
95
servers/jobber/src/ui/react-app/utilization-chart.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Utilization Chart - Team utilization analytics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function UtilizationChart() {
|
||||||
|
const [dateRange, setDateRange] = useState({
|
||||||
|
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0],
|
||||||
|
endDate: new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
const [report, setReport] = useState<any>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="utilization-chart">
|
||||||
|
<header>
|
||||||
|
<h1>Team Utilization Report</h1>
|
||||||
|
<div className="controls">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.startDate}
|
||||||
|
onChange={(e) => setDateRange({ ...dateRange, startDate: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.endDate}
|
||||||
|
onChange={(e) => setDateRange({ ...dateRange, endDate: (e.target as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<button className="primary">Generate Report</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Total Hours</h3>
|
||||||
|
<p className="stat-value">{report.totalHours.toFixed(1)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card success">
|
||||||
|
<h3>Billable Hours</h3>
|
||||||
|
<p className="stat-value">{report.billableHours.toFixed(1)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Non-Billable Hours</h3>
|
||||||
|
<p className="stat-value">{report.nonBillableHours.toFixed(1)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Utilization Rate</h3>
|
||||||
|
<p className="stat-value">{report.utilizationRate.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="user-breakdown">
|
||||||
|
<h2>Team Member Breakdown</h2>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Total Hours</th>
|
||||||
|
<th>Billable Hours</th>
|
||||||
|
<th>Non-Billable Hours</th>
|
||||||
|
<th>Utilization Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.userBreakdown?.map((user: any) => (
|
||||||
|
<tr key={user.userId}>
|
||||||
|
<td>{user.firstName} {user.lastName}</td>
|
||||||
|
<td>{user.totalHours.toFixed(1)}</td>
|
||||||
|
<td>{user.billableHours.toFixed(1)}</td>
|
||||||
|
<td>{user.nonBillableHours.toFixed(1)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="utilization-bar">
|
||||||
|
<div
|
||||||
|
className="utilization-fill"
|
||||||
|
style={{ width: `${user.utilizationRate}%` }}
|
||||||
|
/>
|
||||||
|
<span>{user.utilizationRate.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!report && (
|
||||||
|
<div className="empty-state">Select a date range and generate a report</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "Node16",
|
"module": "Node16",
|
||||||
"moduleResolution": "Node16",
|
"moduleResolution": "Node16",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user