Add complete Jobber MCP server with 48 tools and 18 React apps

This commit is contained in:
Jake Shore 2026-02-12 17:41:02 -05:00
parent 5adccfd36e
commit e28a971b50
33 changed files with 4085 additions and 2 deletions

222
servers/jobber/README.md Normal file
View 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)

View File

@ -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(

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -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,