diff --git a/servers/jobber/README.md b/servers/jobber/README.md new file mode 100644 index 0000000..706377a --- /dev/null +++ b/servers/jobber/README.md @@ -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) diff --git a/servers/jobber/src/clients/jobber.ts b/servers/jobber/src/clients/jobber.ts index 4218f4b..9fc2d80 100644 --- a/servers/jobber/src/clients/jobber.ts +++ b/servers/jobber/src/clients/jobber.ts @@ -31,7 +31,7 @@ export class JobberClient { throw new Error(`Jobber API error: ${response.status} ${response.statusText}`); } - const result = await response.json(); + const result: any = await response.json(); if (result.errors) { throw new Error( diff --git a/servers/jobber/src/index.ts b/servers/jobber/src/index.ts new file mode 100644 index 0000000..b90d74a --- /dev/null +++ b/servers/jobber/src/index.ts @@ -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); +}); diff --git a/servers/jobber/src/server.ts b/servers/jobber/src/server.ts new file mode 100644 index 0000000..40191bd --- /dev/null +++ b/servers/jobber/src/server.ts @@ -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 { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('Jobber MCP server running on stdio'); + } +} diff --git a/servers/jobber/src/tools/clients-tools.ts b/servers/jobber/src/tools/clients-tools.ts new file mode 100644 index 0000000..96e269c --- /dev/null +++ b/servers/jobber/src/tools/clients-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/expenses-tools.ts b/servers/jobber/src/tools/expenses-tools.ts new file mode 100644 index 0000000..3263d14 --- /dev/null +++ b/servers/jobber/src/tools/expenses-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/invoices-tools.ts b/servers/jobber/src/tools/invoices-tools.ts new file mode 100644 index 0000000..42bd2fe --- /dev/null +++ b/servers/jobber/src/tools/invoices-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/jobs-tools.ts b/servers/jobber/src/tools/jobs-tools.ts new file mode 100644 index 0000000..7591bcc --- /dev/null +++ b/servers/jobber/src/tools/jobs-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/products-tools.ts b/servers/jobber/src/tools/products-tools.ts new file mode 100644 index 0000000..d667cc0 --- /dev/null +++ b/servers/jobber/src/tools/products-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/quotes-tools.ts b/servers/jobber/src/tools/quotes-tools.ts new file mode 100644 index 0000000..8d28759 --- /dev/null +++ b/servers/jobber/src/tools/quotes-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/reporting-tools.ts b/servers/jobber/src/tools/reporting-tools.ts new file mode 100644 index 0000000..fd8001c --- /dev/null +++ b/servers/jobber/src/tools/reporting-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/requests-tools.ts b/servers/jobber/src/tools/requests-tools.ts new file mode 100644 index 0000000..1d1c2c4 --- /dev/null +++ b/servers/jobber/src/tools/requests-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/scheduling-tools.ts b/servers/jobber/src/tools/scheduling-tools.ts new file mode 100644 index 0000000..153ca5a --- /dev/null +++ b/servers/jobber/src/tools/scheduling-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/tools/team-tools.ts b/servers/jobber/src/tools/team-tools.ts new file mode 100644 index 0000000..7e6b7ed --- /dev/null +++ b/servers/jobber/src/tools/team-tools.ts @@ -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 }; + }, + }, +}; diff --git a/servers/jobber/src/ui/react-app/client-detail.tsx b/servers/jobber/src/ui/react-app/client-detail.tsx new file mode 100644 index 0000000..cff765d --- /dev/null +++ b/servers/jobber/src/ui/react-app/client-detail.tsx @@ -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(null); + const [properties, setProperties] = useState([]); + const [recentJobs, setRecentJobs] = useState([]); + + useEffect(() => { + // Load client details via MCP tools + }, [clientId]); + + if (!client) { + return
Loading...
; + } + + return ( +
+
+

{client.firstName} {client.lastName}

+ {client.companyName &&

{client.companyName}

} + {client.isArchived && Archived} +
+ +
+
+

Contact Information

+
+
Email:
+
{client.email || 'Not provided'}
+ +
Phone:
+
{client.phone || 'Not provided'}
+ + {client.billingAddress && ( + <> +
Billing Address:
+
+ {client.billingAddress.street1}
+ {client.billingAddress.street2 && <>{client.billingAddress.street2}
} + {client.billingAddress.city}, {client.billingAddress.province} {client.billingAddress.postalCode} +
+ + )} +
+
+ +
+

Properties

+ {properties.map((property) => ( +
+ {property.isDefault && Default} +
+ {property.address.street1}
+ {property.address.street2 && <>{property.address.street2}
} + {property.address.city}, {property.address.province} {property.address.postalCode} +
+
+ ))} +
+
+ +
+

Recent Jobs

+ + + + + + + + + + + + {recentJobs.map((job) => ( + + + + + + + + ))} + +
Job #TitleStatusCreatedTotal
{job.jobNumber}{job.title}{job.status}{new Date(job.createdAt).toLocaleDateString()}${job.total?.amount || 0}
+
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/client-grid.tsx b/servers/jobber/src/ui/react-app/client-grid.tsx new file mode 100644 index 0000000..1602b5e --- /dev/null +++ b/servers/jobber/src/ui/react-app/client-grid.tsx @@ -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([]); + const [searchQuery, setSearchQuery] = useState(''); + 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 ( +
+
+

Clients

+
+ setSearchQuery(e.target.value)} + /> + + +
+
+ + + + + + + + + + + + + + {filteredClients.map((client) => ( + + + + + + + + + ))} + +
NameCompanyEmailPhoneStatusActions
{client.firstName} {client.lastName}{client.companyName || '—'}{client.email || '—'}{client.phone || '—'} + {client.isArchived ? ( + Archived + ) : ( + Active + )} + + + + {!client.isArchived && } +
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/expense-tracker.tsx b/servers/jobber/src/ui/react-app/expense-tracker.tsx new file mode 100644 index 0000000..f7657b9 --- /dev/null +++ b/servers/jobber/src/ui/react-app/expense-tracker.tsx @@ -0,0 +1,74 @@ +/** + * Expense Tracker - Track and manage expenses + */ + +import React, { useState, useEffect } from 'react'; + +export default function ExpenseTracker() { + const [expenses, setExpenses] = useState([]); + const [filter, setFilter] = useState({ + startDate: '', + endDate: '', + userId: '', + jobId: '', + }); + + const totalExpenses = expenses.reduce((sum, exp) => sum + exp.amount.amount, 0); + + return ( +
+
+

Expenses

+
+ setFilter({ ...filter, startDate: (e.target as HTMLInputElement).value })} + /> + setFilter({ ...filter, endDate: (e.target as HTMLInputElement).value })} + /> + +
+
+ +
+

Total Expenses: ${totalExpenses.toFixed(2)}

+
+ + + + + + + + + + + + + + + {expenses.map((expense) => ( + + + + + + + + + + ))} + +
DateDescriptionCategoryUserJobAmountActions
{new Date(expense.date).toLocaleDateString()}{expense.description}{expense.category || '—'}{expense.user ? `${expense.user.firstName} ${expense.user.lastName}` : '—'}{expense.job ? expense.job.jobNumber : '—'}${expense.amount.amount.toFixed(2)} + + +
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/invoice-dashboard.tsx b/servers/jobber/src/ui/react-app/invoice-dashboard.tsx new file mode 100644 index 0000000..b031f63 --- /dev/null +++ b/servers/jobber/src/ui/react-app/invoice-dashboard.tsx @@ -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([]); + + return ( +
+

Invoice Dashboard

+ +
+
+

Total Sent

+

{stats.totalSent}

+
+
+

Paid

+

{stats.paid}

+
+
+

Overdue

+

{stats.overdue}

+
+
+

Amount Due

+

${stats.amountDue.toFixed(2)}

+
+
+

Amount Paid

+

${stats.amountPaid.toFixed(2)}

+
+
+ +
+

Recent Invoices

+ + + + + + + + + + + + + {recentInvoices.map((invoice) => ( + + + + + + + + + ))} + +
Invoice #ClientStatusDue DateTotalAmount Due
{invoice.invoiceNumber}{invoice.client.firstName} {invoice.client.lastName}{invoice.status}{invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : '—'}${invoice.total?.amount || 0}${invoice.amountDue?.amount || 0}
+
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/invoice-detail.tsx b/servers/jobber/src/ui/react-app/invoice-detail.tsx new file mode 100644 index 0000000..7c600e2 --- /dev/null +++ b/servers/jobber/src/ui/react-app/invoice-detail.tsx @@ -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(null); + const [payments, setPayments] = useState([]); + + if (!invoice) { + return
Loading...
; + } + + return ( +
+
+

Invoice #{invoice.invoiceNumber}

+ {invoice.status} +
+ +
+

Invoice Information

+
+
Client:
+
{invoice.client.firstName} {invoice.client.lastName}
+ +
Subject:
+
{invoice.subject}
+ +
Created:
+
{new Date(invoice.createdAt).toLocaleDateString()}
+ + {invoice.dueDate && ( + <> +
Due Date:
+
{new Date(invoice.dueDate).toLocaleDateString()}
+ + )} + +
Subtotal:
+
${invoice.subtotal?.amount || 0}
+ +
Total:
+
${invoice.total?.amount || 0}
+ +
Amount Paid:
+
${invoice.amountPaid?.amount || 0}
+ +
Amount Due:
+
${invoice.amountDue?.amount || 0}
+
+
+ +
+

Payments

+ + + + + + + + + + {payments.map((payment) => ( + + + + + + ))} + +
DateAmountMethod
{new Date(payment.paidOn).toLocaleDateString()}${payment.amount.amount}{payment.paymentMethod}
+ + +
+ +
+ {invoice.status === 'DRAFT' && } + +
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/job-dashboard.tsx b/servers/jobber/src/ui/react-app/job-dashboard.tsx new file mode 100644 index 0000000..160064d --- /dev/null +++ b/servers/jobber/src/ui/react-app/job-dashboard.tsx @@ -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({ + total: 0, + active: 0, + completed: 0, + late: 0, + requiresInvoicing: 0, + }); + const [recentJobs, setRecentJobs] = useState([]); + + useEffect(() => { + // In a real implementation, this would call the MCP tools + // For now, this is a UI template + }, []); + + return ( +
+

Job Dashboard

+ +
+
+

Total Jobs

+

{stats.total}

+
+
+

Active

+

{stats.active}

+
+
+

Completed

+

{stats.completed}

+
+
+

Late

+

{stats.late}

+
+
+

Requires Invoicing

+

{stats.requiresInvoicing}

+
+
+ +
+

Recent Jobs

+ + + + + + + + + + + + {recentJobs.map((job) => ( + + + + + + + + ))} + +
Job #TitleClientStatusTotal
{job.jobNumber}{job.title}{job.client.firstName} {job.client.lastName}{job.status}${job.total?.amount || 0}
+
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/job-detail.tsx b/servers/jobber/src/ui/react-app/job-detail.tsx new file mode 100644 index 0000000..a01f4fe --- /dev/null +++ b/servers/jobber/src/ui/react-app/job-detail.tsx @@ -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(null); + const [visits, setVisits] = useState([]); + const [lineItems, setLineItems] = useState([]); + + useEffect(() => { + // Load job details via MCP tools + }, [jobId]); + + if (!job) { + return
Loading...
; + } + + return ( +
+
+

Job #{job.jobNumber} - {job.title}

+ {job.status} +
+ +
+

Job Information

+
+
Client:
+
{job.client.firstName} {job.client.lastName}
+ +
Description:
+
{job.description || 'No description'}
+ +
Created:
+
{new Date(job.createdAt).toLocaleDateString()}
+ +
Total:
+
${job.total?.amount || 0} {job.total?.currency}
+
+
+ +
+

Visits

+ + + + + + + + + + + {visits.map((visit) => ( + + + + + + + ))} + +
TitleStartEndStatus
{visit.title}{new Date(visit.startAt).toLocaleString()}{new Date(visit.endAt).toLocaleString()}{visit.status}
+
+ +
+

Line Items

+ + + + + + + + + + + + {lineItems.map((item) => ( + + + + + + + + ))} + +
NameDescriptionQtyUnit PriceTotal
{item.name}{item.description}{item.quantity}${item.unitPrice?.amount || 0}${item.total?.amount || 0}
+
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/job-grid.tsx b/servers/jobber/src/ui/react-app/job-grid.tsx new file mode 100644 index 0000000..18d8883 --- /dev/null +++ b/servers/jobber/src/ui/react-app/job-grid.tsx @@ -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([]); + const [filter, setFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState('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 ( +
+
+

All Jobs

+
+ setFilter((e.target as HTMLInputElement).value)} + /> + +
+
+ + + + + + + + + + + + + + + {filteredJobs.map((job) => ( + + + + + + + + + + ))} + +
Job #TitleClientStatusCreatedTotalActions
{job.jobNumber}{job.title}{job.client.firstName} {job.client.lastName}{job.status}{new Date(job.createdAt).toLocaleDateString()}${job.total?.amount || 0} + + +
+ + {loading &&
Loading...
} + {filteredJobs.length === 0 && !loading && ( +
No jobs found
+ )} +
+ ); +} diff --git a/servers/jobber/src/ui/react-app/job-profit-report.tsx b/servers/jobber/src/ui/react-app/job-profit-report.tsx new file mode 100644 index 0000000..16d14f5 --- /dev/null +++ b/servers/jobber/src/ui/react-app/job-profit-report.tsx @@ -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(null); + + return ( +
+
+

Job Profitability Report

+
+ setDateRange({ ...dateRange, startDate: (e.target as HTMLInputElement).value })} + /> + setDateRange({ ...dateRange, endDate: (e.target as HTMLInputElement).value })} + /> + +
+
+ + {report && ( + <> +
+
+

Total Revenue

+

${report.totalRevenue?.amount.toFixed(2)}

+
+
+

Total Costs

+

${report.totalCosts?.amount.toFixed(2)}

+
+
+

Total Profit

+

${report.totalProfit?.amount.toFixed(2)}

+
+
+

Profit Margin

+

{report.profitMargin?.toFixed(1)}%

+
+
+ +
+

Job Breakdown

+ + + + + + + + + + + + + {report.jobBreakdown?.map((job: any) => ( + + + + + + + + + ))} + +
Job #TitleRevenueCostsProfitMargin
{job.jobNumber}{job.title}${job.revenue.amount.toFixed(2)}${job.costs.amount.toFixed(2)}= 0 ? 'positive' : 'negative'}> + ${job.profit.amount.toFixed(2)} + {job.margin.toFixed(1)}%
+
+ + )} + + {!report && ( +
Select a date range and generate a report
+ )} +
+ ); +} diff --git a/servers/jobber/src/ui/react-app/product-catalog.tsx b/servers/jobber/src/ui/react-app/product-catalog.tsx new file mode 100644 index 0000000..2cc4444 --- /dev/null +++ b/servers/jobber/src/ui/react-app/product-catalog.tsx @@ -0,0 +1,65 @@ +/** + * Product Catalog - Manage products and services + */ + +import React, { useState, useEffect } from 'react'; + +export default function ProductCatalog() { + const [products, setProducts] = useState([]); + const [typeFilter, setTypeFilter] = useState('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 ( +
+
+

Products & Services

+
+ + + +
+
+ +
+ {filteredProducts.map((product) => ( +
+
+ {product.type} + {product.isArchived && Archived} +
+

{product.name}

+

{product.description || 'No description'}

+
+ ${product.unitPrice?.amount.toFixed(2) || '0.00'} {product.unitPrice?.currency} +
+
+ + {!product.isArchived && } +
+
+ ))} +
+ + {filteredProducts.length === 0 && ( +
No products or services found
+ )} +
+ ); +} diff --git a/servers/jobber/src/ui/react-app/quote-builder.tsx b/servers/jobber/src/ui/react-app/quote-builder.tsx new file mode 100644 index 0000000..c923049 --- /dev/null +++ b/servers/jobber/src/ui/react-app/quote-builder.tsx @@ -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([]); + + 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 ( +
+

Build Quote

+ +
+
+ + setTitle((e.target as HTMLInputElement).value)} + placeholder="Enter quote title" + /> +
+ +
+ + +
+
+ +
+
+

Line Items

+ +
+ + + + + + + + + + + + + + {lineItems.map((item) => ( + + + + + + + + + ))} + +
NameDescriptionQtyUnit PriceTotal
+ updateLineItem(item.id, 'name', (e.target as HTMLInputElement).value)} + /> + + updateLineItem(item.id, 'description', (e.target as HTMLInputElement).value)} + /> + + updateLineItem(item.id, 'quantity', parseFloat((e.target as HTMLInputElement).value))} + /> + + updateLineItem(item.id, 'unitPrice', parseFloat((e.target as HTMLInputElement).value))} + /> + ${(item.quantity * item.unitPrice).toFixed(2)} + +
+ +
+ Total: ${calculateTotal().toFixed(2)} +
+
+ +
+ + +
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/quote-grid.tsx b/servers/jobber/src/ui/react-app/quote-grid.tsx new file mode 100644 index 0000000..e93877d --- /dev/null +++ b/servers/jobber/src/ui/react-app/quote-grid.tsx @@ -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([]); + const [statusFilter, setStatusFilter] = useState('ALL'); + + const statusOptions = ['ALL', 'DRAFT', 'SENT', 'APPROVED', 'CHANGES_REQUESTED', 'CONVERTED', 'EXPIRED']; + + return ( +
+
+

Quotes

+
+ + +
+
+ + + + + + + + + + + + + + + {quotes.map((quote) => ( + + + + + + + + + + ))} + +
Quote #TitleClientStatusCreatedTotalActions
{quote.quoteNumber}{quote.title}{quote.client.firstName} {quote.client.lastName}{quote.status}{new Date(quote.createdAt).toLocaleDateString()}${quote.total?.amount || 0} + + {quote.status === 'DRAFT' && } + {quote.status === 'APPROVED' && } +
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/request-inbox.tsx b/servers/jobber/src/ui/react-app/request-inbox.tsx new file mode 100644 index 0000000..22244aa --- /dev/null +++ b/servers/jobber/src/ui/react-app/request-inbox.tsx @@ -0,0 +1,63 @@ +/** + * Request Inbox - Manage client requests + */ + +import React, { useState, useEffect } from 'react'; + +export default function RequestInbox() { + const [requests, setRequests] = useState([]); + const [statusFilter, setStatusFilter] = useState('ALL'); + + const statusOptions = ['ALL', 'NEW', 'IN_PROGRESS', 'CONVERTED', 'CLOSED']; + + return ( +
+
+

Client Requests

+
+ + +
+
+ +
+ {requests.map((request) => ( +
+
+

{request.title}

+ {request.status} +
+

{request.description || 'No description'}

+
+ {request.client && ( + + {request.client.firstName} {request.client.lastName} + + )} + + {new Date(request.createdAt).toLocaleDateString()} + +
+
+ + {request.status === 'NEW' && ( + <> + + + + )} +
+
+ ))} +
+ + {requests.length === 0 && ( +
No requests found
+ )} +
+ ); +} diff --git a/servers/jobber/src/ui/react-app/revenue-dashboard.tsx b/servers/jobber/src/ui/react-app/revenue-dashboard.tsx new file mode 100644 index 0000000..95d6d56 --- /dev/null +++ b/servers/jobber/src/ui/react-app/revenue-dashboard.tsx @@ -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(null); + + return ( +
+
+

Revenue Dashboard

+
+ setDateRange({ ...dateRange, startDate: (e.target as HTMLInputElement).value })} + /> + setDateRange({ ...dateRange, endDate: (e.target as HTMLInputElement).value })} + /> + +
+
+ + {report && ( + <> +
+
+

Total Revenue

+

${report.totalRevenue?.amount.toFixed(2)}

+
+
+

Invoiced Revenue

+

${report.invoicedRevenue?.amount.toFixed(2)}

+
+
+

Paid Revenue

+

${report.paidRevenue?.amount.toFixed(2)}

+
+
+

Outstanding Revenue

+

${report.outstandingRevenue?.amount.toFixed(2)}

+
+
+ +
+

Revenue Trend

+ {/* Chart would go here */} +
Revenue chart visualization
+
+ + )} + + {!report && ( +
Select a date range and generate a report
+ )} +
+ ); +} diff --git a/servers/jobber/src/ui/react-app/schedule-calendar.tsx b/servers/jobber/src/ui/react-app/schedule-calendar.tsx new file mode 100644 index 0000000..50277b3 --- /dev/null +++ b/servers/jobber/src/ui/react-app/schedule-calendar.tsx @@ -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([]); + 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 ( +
+
+

Schedule

+
+ +
+ + + +
+ +
+
+ +
+ {getDaysInView().map((day) => ( +
+
+ {day.toLocaleDateString('en-US', { weekday: 'short' })} + {day.getDate()} +
+
+ {getVisitsForDay(day).map((visit) => ( +
+
+ {new Date(visit.startAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
{visit.title}
+ {visit.assignedUsers && visit.assignedUsers.length > 0 && ( +
+ {visit.assignedUsers.map((user: any) => ( + {user.firstName[0]}{user.lastName[0]} + ))} +
+ )} +
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/team-dashboard.tsx b/servers/jobber/src/ui/react-app/team-dashboard.tsx new file mode 100644 index 0000000..a19867c --- /dev/null +++ b/servers/jobber/src/ui/react-app/team-dashboard.tsx @@ -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([]); + const [teamStats, setTeamStats] = useState({ + totalUsers: 0, + activeUsers: 0, + scheduledVisits: 0, + completedVisits: 0, + }); + + return ( +
+

Team Dashboard

+ +
+
+

Total Team Members

+

{teamStats.totalUsers}

+
+
+

Active

+

{teamStats.activeUsers}

+
+
+

Scheduled Visits

+

{teamStats.scheduledVisits}

+
+
+

Completed Visits

+

{teamStats.completedVisits}

+
+
+ +
+

Team Members

+
+ {users.map((user) => ( +
+
{user.firstName[0]}{user.lastName[0]}
+
+

{user.firstName} {user.lastName}

+

{user.role}

+

{user.email}

+ + {user.isActive ? 'Active' : 'Inactive'} + +
+
+ ))} +
+
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/team-schedule.tsx b/servers/jobber/src/ui/react-app/team-schedule.tsx new file mode 100644 index 0000000..0b041c8 --- /dev/null +++ b/servers/jobber/src/ui/react-app/team-schedule.tsx @@ -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([]); + const [visits, setVisits] = useState([]); + const [selectedDate, setSelectedDate] = useState(new Date()); + + const getUserVisits = (userId: string) => { + return visits.filter((visit) => + visit.assignedUsers?.some((u: any) => u.id === userId) + ); + }; + + return ( +
+
+

Team Schedule

+
+ setSelectedDate(new Date((e.target as HTMLInputElement).value))} + /> + +
+
+ +
+ {users.map((user) => { + const userVisits = getUserVisits(user.id); + return ( +
+
+
{user.firstName[0]}{user.lastName[0]}
+
+

{user.firstName} {user.lastName}

+

{userVisits.length} visits

+
+
+
+ {userVisits.map((visit) => ( +
+
+ {new Date(visit.startAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {' - '} + {new Date(visit.endAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
{visit.title}
+ {visit.job &&
Job #{visit.job.jobNumber}
} +
+ ))} + {userVisits.length === 0 && ( +
No visits scheduled
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/servers/jobber/src/ui/react-app/utilization-chart.tsx b/servers/jobber/src/ui/react-app/utilization-chart.tsx new file mode 100644 index 0000000..6c4dafc --- /dev/null +++ b/servers/jobber/src/ui/react-app/utilization-chart.tsx @@ -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(null); + + return ( +
+
+

Team Utilization Report

+
+ setDateRange({ ...dateRange, startDate: (e.target as HTMLInputElement).value })} + /> + setDateRange({ ...dateRange, endDate: (e.target as HTMLInputElement).value })} + /> + +
+
+ + {report && ( + <> +
+
+

Total Hours

+

{report.totalHours.toFixed(1)}

+
+
+

Billable Hours

+

{report.billableHours.toFixed(1)}

+
+
+

Non-Billable Hours

+

{report.nonBillableHours.toFixed(1)}

+
+
+

Utilization Rate

+

{report.utilizationRate.toFixed(1)}%

+
+
+ +
+

Team Member Breakdown

+ + + + + + + + + + + + {report.userBreakdown?.map((user: any) => ( + + + + + + + + ))} + +
NameTotal HoursBillable HoursNon-Billable HoursUtilization Rate
{user.firstName} {user.lastName}{user.totalHours.toFixed(1)}{user.billableHours.toFixed(1)}{user.nonBillableHours.toFixed(1)} +
+
+ {user.utilizationRate.toFixed(1)}% +
+
+
+ + )} + + {!report && ( +
Select a date range and generate a report
+ )} +
+ ); +} diff --git a/servers/jobber/tsconfig.json b/servers/jobber/tsconfig.json index 16e6fcd..3329397 100644 --- a/servers/jobber/tsconfig.json +++ b/servers/jobber/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "outDir": "./dist", "rootDir": "./src", "strict": true,