FreshBooks: Complete MCP server with 55+ tools and 22 React apps
- 55+ tools across 12 categories (invoices, clients, expenses, estimates, time, projects, payments, items, taxes, reports, recurring, accounts) - FreshBooks API client with OAuth2, pagination, error handling - 22 dark-themed React MCP apps (invoice dashboard, builder, client dashboard, expense tracker, time tracker, project dashboard, reports, etc.) - Full TypeScript types for all FreshBooks entities - Comprehensive README with examples and architecture docs
This commit is contained in:
parent
8e9d1ffb87
commit
ff349dc88f
241
servers/freshbooks/README.md
Normal file
241
servers/freshbooks/README.md
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
# FreshBooks MCP Server
|
||||||
|
|
||||||
|
Complete Model Context Protocol server for FreshBooks accounting platform. Manage invoices, clients, expenses, time tracking, projects, payments, and financial reporting.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎯 55+ Tools
|
||||||
|
|
||||||
|
**Invoices** (10 tools)
|
||||||
|
- List, get, create, update, delete invoices
|
||||||
|
- Send invoices via email
|
||||||
|
- Mark paid/unpaid, create payments
|
||||||
|
- Get payment history
|
||||||
|
|
||||||
|
**Clients** (6 tools)
|
||||||
|
- List, get, create, update, delete clients
|
||||||
|
- List client contacts
|
||||||
|
|
||||||
|
**Expenses** (6 tools)
|
||||||
|
- List, get, create, update, delete expenses
|
||||||
|
- List expense categories
|
||||||
|
|
||||||
|
**Estimates** (7 tools)
|
||||||
|
- List, get, create, update, delete estimates
|
||||||
|
- Send estimates, convert to invoices
|
||||||
|
|
||||||
|
**Time Tracking** (5 tools)
|
||||||
|
- List, get, create, update, delete time entries
|
||||||
|
|
||||||
|
**Projects** (6 tools)
|
||||||
|
- List, get, create, update, delete projects
|
||||||
|
- List project services
|
||||||
|
|
||||||
|
**Payments** (5 tools)
|
||||||
|
- List, get, create, update, delete payments
|
||||||
|
|
||||||
|
**Items** (5 tools)
|
||||||
|
- List, get, create, update, delete items (products/services)
|
||||||
|
|
||||||
|
**Taxes** (5 tools)
|
||||||
|
- List, get, create, update, delete taxes
|
||||||
|
|
||||||
|
**Reports** (5 tools)
|
||||||
|
- Profit & Loss report
|
||||||
|
- Tax summary
|
||||||
|
- Accounts aging
|
||||||
|
- Expense report
|
||||||
|
- Revenue by client
|
||||||
|
|
||||||
|
**Recurring** (5 tools)
|
||||||
|
- List, get, create, update, delete recurring profiles
|
||||||
|
|
||||||
|
**Accounts** (3 tools)
|
||||||
|
- Get account details
|
||||||
|
- List staff members
|
||||||
|
- Get current user
|
||||||
|
|
||||||
|
### 🎨 22 React MCP Apps
|
||||||
|
|
||||||
|
Dark-themed, client-side state React apps (inline HTML):
|
||||||
|
|
||||||
|
1. **invoice-dashboard** - Overview of all invoices with stats
|
||||||
|
2. **invoice-detail** - Single invoice view
|
||||||
|
3. **invoice-builder** - Create/edit invoices
|
||||||
|
4. **invoice-grid** - Grid view of invoices
|
||||||
|
5. **client-dashboard** - Client overview with metrics
|
||||||
|
6. **client-detail** - Single client view
|
||||||
|
7. **client-grid** - Grid view of clients
|
||||||
|
8. **expense-dashboard** - Expense overview
|
||||||
|
9. **expense-tracker** - Add and track expenses
|
||||||
|
10. **estimate-builder** - Create/edit estimates
|
||||||
|
11. **estimate-grid** - Grid view of estimates
|
||||||
|
12. **time-tracker** - Real-time timer for tracking hours
|
||||||
|
13. **time-entries** - List of time entries
|
||||||
|
14. **project-dashboard** - Project overview with progress
|
||||||
|
15. **project-detail** - Single project view
|
||||||
|
16. **payment-history** - List of all payments
|
||||||
|
17. **reports-dashboard** - Reports menu
|
||||||
|
18. **profit-loss** - Profit & loss report
|
||||||
|
19. **tax-summary** - Tax summary report
|
||||||
|
20. **aging-report** - Accounts aging report
|
||||||
|
21. **recurring-invoices** - Recurring invoice profiles
|
||||||
|
22. **revenue-chart** - Revenue visualization
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FRESHBOOKS_ACCOUNT_ID="your_account_id"
|
||||||
|
export FRESHBOOKS_BEARER_TOKEN="your_bearer_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### As MCP Server
|
||||||
|
|
||||||
|
Add to your MCP settings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"freshbooks": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/freshbooks/dist/main.js"],
|
||||||
|
"env": {
|
||||||
|
"FRESHBOOKS_ACCOUNT_ID": "your_account_id",
|
||||||
|
"FRESHBOOKS_BEARER_TOKEN": "your_bearer_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── clients/
|
||||||
|
│ └── freshbooks.ts # API client with OAuth2, pagination, error handling
|
||||||
|
├── tools/
|
||||||
|
│ ├── invoices-tools.ts # 10 invoice tools
|
||||||
|
│ ├── clients-tools.ts # 6 client tools
|
||||||
|
│ ├── expenses-tools.ts # 6 expense tools
|
||||||
|
│ ├── estimates-tools.ts # 7 estimate tools
|
||||||
|
│ ├── time-entries-tools.ts # 5 time tracking tools
|
||||||
|
│ ├── projects-tools.ts # 6 project tools
|
||||||
|
│ ├── payments-tools.ts # 5 payment tools
|
||||||
|
│ ├── items-tools.ts # 5 item tools
|
||||||
|
│ ├── taxes-tools.ts # 5 tax tools
|
||||||
|
│ ├── reports-tools.ts # 5 report tools
|
||||||
|
│ ├── recurring-tools.ts # 5 recurring tools
|
||||||
|
│ └── accounts-tools.ts # 3 account tools
|
||||||
|
├── types/
|
||||||
|
│ └── index.ts # TypeScript types for FreshBooks API
|
||||||
|
├── ui/
|
||||||
|
│ └── react-app/ # 22 standalone React apps
|
||||||
|
├── server.ts # MCP server implementation
|
||||||
|
└── main.ts # Entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Client Features
|
||||||
|
|
||||||
|
- **OAuth2 Bearer Authentication**
|
||||||
|
- **Automatic Pagination** - Fetch all pages or paginated results
|
||||||
|
- **Error Handling** - Structured error responses
|
||||||
|
- **Rate Limiting** - Respects FreshBooks API limits
|
||||||
|
- **Type Safety** - Full TypeScript support
|
||||||
|
|
||||||
|
## Example Tool Calls
|
||||||
|
|
||||||
|
### Create Invoice
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"name": "freshbooks_create_invoice",
|
||||||
|
"arguments": {
|
||||||
|
"clientid": 12345,
|
||||||
|
"lines": [
|
||||||
|
{ "name": "Website Design", "qty": 1, "unit_cost": "2500.00" },
|
||||||
|
{ "name": "Hosting Setup", "qty": 1, "unit_cost": "150.00" }
|
||||||
|
],
|
||||||
|
"currency_code": "USD",
|
||||||
|
"notes": "Thank you for your business!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Overdue Invoices
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"name": "freshbooks_list_invoices",
|
||||||
|
"arguments": {
|
||||||
|
"status": "overdue",
|
||||||
|
"per_page": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Track Time
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"name": "freshbooks_create_time_entry",
|
||||||
|
"arguments": {
|
||||||
|
"duration": 7200,
|
||||||
|
"note": "Website development",
|
||||||
|
"started_at": "2024-01-15T09:00:00Z",
|
||||||
|
"projectid": 456
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Profit/Loss Report
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"name": "freshbooks_profit_loss_report",
|
||||||
|
"arguments": {
|
||||||
|
"start_date": "2024-01-01",
|
||||||
|
"end_date": "2024-01-31",
|
||||||
|
"currency_code": "USD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watch Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
MCPEngine - Complete MCP implementations for modern platforms
|
||||||
@ -1,20 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-server-freshbooks",
|
"name": "@mcpengine/freshbooks",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"description": "FreshBooks MCP Server - Complete accounting, invoicing, time tracking, and financial management",
|
||||||
|
"main": "dist/main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"watch": "tsc --watch",
|
||||||
"dev": "tsx src/index.ts"
|
"prepare": "npm run build",
|
||||||
|
"start": "node dist/main.js"
|
||||||
},
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"freshbooks",
|
||||||
|
"accounting",
|
||||||
|
"invoicing",
|
||||||
|
"time-tracking",
|
||||||
|
"expenses",
|
||||||
|
"estimates",
|
||||||
|
"payments"
|
||||||
|
],
|
||||||
|
"author": "MCPEngine",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^0.5.0",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
"zod": "^3.22.4"
|
"axios": "^1.7.9",
|
||||||
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^22.10.2",
|
||||||
"tsx": "^4.7.0",
|
"typescript": "^5.7.2"
|
||||||
"typescript": "^5.3.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
144
servers/freshbooks/src/clients/freshbooks.ts
Normal file
144
servers/freshbooks/src/clients/freshbooks.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||||
|
import type {
|
||||||
|
FreshBooksConfig,
|
||||||
|
PaginatedResponse,
|
||||||
|
FreshBooksError,
|
||||||
|
} from '../types/index.js';
|
||||||
|
|
||||||
|
export class FreshBooksClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private accountId: string;
|
||||||
|
|
||||||
|
constructor(config: FreshBooksConfig) {
|
||||||
|
this.accountId = config.accountId;
|
||||||
|
const baseURL = config.baseUrl || `https://api.freshbooks.com/accounting/account/${config.accountId}`;
|
||||||
|
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${config.bearerToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Api-Version': 'alpha',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor for logging
|
||||||
|
this.client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
console.error(`[FreshBooks] ${config.method?.toUpperCase()} ${config.url}`);
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor for error handling
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
return Promise.reject(this.handleError(error));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(error: AxiosError): FreshBooksError {
|
||||||
|
if (error.response) {
|
||||||
|
const data = error.response.data as any;
|
||||||
|
return {
|
||||||
|
message: data.message || data.error || `HTTP ${error.response.status}: ${error.response.statusText}`,
|
||||||
|
code: data.code || `${error.response.status}`,
|
||||||
|
errors: data.errors || data.response?.errors,
|
||||||
|
};
|
||||||
|
} else if (error.request) {
|
||||||
|
return {
|
||||||
|
message: 'No response from FreshBooks API',
|
||||||
|
code: 'NETWORK_ERROR',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
message: error.message || 'Unknown error',
|
||||||
|
code: 'UNKNOWN_ERROR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic GET with pagination support
|
||||||
|
async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
|
||||||
|
const response = await this.client.get(endpoint, { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic GET with automatic pagination (fetch all pages)
|
||||||
|
async getAll<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params?: Record<string, any>,
|
||||||
|
resultKey: string = 'result'
|
||||||
|
): Promise<T[]> {
|
||||||
|
let page = 1;
|
||||||
|
let allResults: T[] = [];
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const response = await this.client.get<PaginatedResponse<any>>(endpoint, {
|
||||||
|
params: { ...params, page, per_page: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data.response.result;
|
||||||
|
const items = Array.isArray(result[resultKey]) ? result[resultKey] : [];
|
||||||
|
allResults = allResults.concat(items);
|
||||||
|
|
||||||
|
const { page: currentPage, pages } = response.data.response;
|
||||||
|
hasMore = currentPage < pages;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginated GET (single page)
|
||||||
|
async getPaginated<T>(
|
||||||
|
endpoint: string,
|
||||||
|
page: number = 1,
|
||||||
|
perPage: number = 30,
|
||||||
|
params?: Record<string, any>
|
||||||
|
): Promise<PaginatedResponse<T>> {
|
||||||
|
const response = await this.client.get<PaginatedResponse<T>>(endpoint, {
|
||||||
|
params: { ...params, page, per_page: perPage },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST
|
||||||
|
async post<T>(endpoint: string, data: any): Promise<T> {
|
||||||
|
const response = await this.client.post(endpoint, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT
|
||||||
|
async put<T>(endpoint: string, data: any): Promise<T> {
|
||||||
|
const response = await this.client.put(endpoint, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
async delete<T>(endpoint: string): Promise<T> {
|
||||||
|
const response = await this.client.delete(endpoint);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience method: search with filters
|
||||||
|
async search<T>(
|
||||||
|
endpoint: string,
|
||||||
|
searchFields: Record<string, any>,
|
||||||
|
page: number = 1,
|
||||||
|
perPage: number = 30
|
||||||
|
): Promise<PaginatedResponse<T>> {
|
||||||
|
return this.getPaginated<T>(endpoint, page, perPage, {
|
||||||
|
search: searchFields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccountId(): string {
|
||||||
|
return this.accountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,445 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
import {
|
|
||||||
CallToolRequestSchema,
|
|
||||||
ListToolsRequestSchema,
|
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// CONFIGURATION
|
|
||||||
// ============================================
|
|
||||||
const MCP_NAME = "freshbooks";
|
|
||||||
const MCP_VERSION = "1.0.0";
|
|
||||||
const API_BASE_URL = "https://api.freshbooks.com";
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// API CLIENT
|
|
||||||
// ============================================
|
|
||||||
class FreshBooksClient {
|
|
||||||
private accessToken: string;
|
|
||||||
private accountId: string;
|
|
||||||
private baseUrl: string;
|
|
||||||
|
|
||||||
constructor(accessToken: string, accountId: string) {
|
|
||||||
this.accessToken = accessToken;
|
|
||||||
this.accountId = accountId;
|
|
||||||
this.baseUrl = `${API_BASE_URL}/accounting/account/${accountId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async request(endpoint: string, options: RequestInit = {}) {
|
|
||||||
const url = `${this.baseUrl}${endpoint}`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${this.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Api-Version": "alpha",
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`FreshBooks API error: ${response.status} ${response.statusText} - ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(endpoint: string) {
|
|
||||||
return this.request(endpoint, { method: "GET" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async post(endpoint: string, data: any) {
|
|
||||||
return this.request(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(endpoint: string, data: any) {
|
|
||||||
return this.request(endpoint, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invoice methods
|
|
||||||
async listInvoices(options?: { page?: number; perPage?: number; status?: string }) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options?.page) params.append("page", options.page.toString());
|
|
||||||
if (options?.perPage) params.append("per_page", options.perPage.toString());
|
|
||||||
if (options?.status) params.append("search[v3_status]", options.status);
|
|
||||||
const query = params.toString();
|
|
||||||
return this.get(`/invoices/invoices${query ? `?${query}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getInvoice(invoiceId: string) {
|
|
||||||
return this.get(`/invoices/invoices/${invoiceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createInvoice(data: {
|
|
||||||
customerid: number;
|
|
||||||
create_date: string;
|
|
||||||
due_offset_days?: number;
|
|
||||||
currency_code?: string;
|
|
||||||
language?: string;
|
|
||||||
notes?: string;
|
|
||||||
terms?: string;
|
|
||||||
lines?: Array<{
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
qty: number;
|
|
||||||
unit_cost: { amount: string; code?: string };
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
return this.post("/invoices/invoices", { invoice: data });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendInvoice(invoiceId: string, emailData: {
|
|
||||||
email_recipients?: string[];
|
|
||||||
email_subject?: string;
|
|
||||||
email_body?: string;
|
|
||||||
action_email?: boolean;
|
|
||||||
}) {
|
|
||||||
// To send an invoice, update status to "sent"
|
|
||||||
return this.put(`/invoices/invoices/${invoiceId}`, {
|
|
||||||
invoice: {
|
|
||||||
action_email: emailData.action_email ?? true,
|
|
||||||
email_recipients: emailData.email_recipients,
|
|
||||||
email_subject: emailData.email_subject,
|
|
||||||
email_body: emailData.email_body,
|
|
||||||
status: 2, // 2 = sent
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client methods
|
|
||||||
async listClients(options?: { page?: number; perPage?: number }) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options?.page) params.append("page", options.page.toString());
|
|
||||||
if (options?.perPage) params.append("per_page", options.perPage.toString());
|
|
||||||
const query = params.toString();
|
|
||||||
return this.get(`/users/clients${query ? `?${query}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getClient(clientId: string) {
|
|
||||||
return this.get(`/users/clients/${clientId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createClient(data: {
|
|
||||||
email?: string;
|
|
||||||
fname?: string;
|
|
||||||
lname?: string;
|
|
||||||
organization?: string;
|
|
||||||
p_street?: string;
|
|
||||||
p_street2?: string;
|
|
||||||
p_city?: string;
|
|
||||||
p_province?: string;
|
|
||||||
p_code?: string;
|
|
||||||
p_country?: string;
|
|
||||||
currency_code?: string;
|
|
||||||
language?: string;
|
|
||||||
bus_phone?: string;
|
|
||||||
mob_phone?: string;
|
|
||||||
note?: string;
|
|
||||||
}) {
|
|
||||||
return this.post("/users/clients", { client: data });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expense methods
|
|
||||||
async listExpenses(options?: { page?: number; perPage?: number }) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options?.page) params.append("page", options.page.toString());
|
|
||||||
if (options?.perPage) params.append("per_page", options.perPage.toString());
|
|
||||||
const query = params.toString();
|
|
||||||
return this.get(`/expenses/expenses${query ? `?${query}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getExpense(expenseId: string) {
|
|
||||||
return this.get(`/expenses/expenses/${expenseId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payment methods
|
|
||||||
async listPayments(options?: { page?: number; perPage?: number }) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options?.page) params.append("page", options.page.toString());
|
|
||||||
if (options?.perPage) params.append("per_page", options.perPage.toString());
|
|
||||||
const query = params.toString();
|
|
||||||
return this.get(`/payments/payments${query ? `?${query}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPayment(paymentId: string) {
|
|
||||||
return this.get(`/payments/payments/${paymentId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// TOOL DEFINITIONS
|
|
||||||
// ============================================
|
|
||||||
const tools = [
|
|
||||||
{
|
|
||||||
name: "list_invoices",
|
|
||||||
description: "List invoices from FreshBooks",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object" as const,
|
|
||||||
properties: {
|
|
||||||
page: { type: "number", description: "Page number (default 1)" },
|
|
||||||
per_page: { type: "number", description: "Results per page (default 15)" },
|
|
||||||
status: {
|
|
||||||
type: "string",
|
|
||||||
description: "Filter by status",
|
|
||||||
enum: ["draft", "sent", "viewed", "paid", "overdue", "disputed"]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "get_invoice",
|
|
||||||
description: "Get a specific invoice by ID",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object" as const,
|
|
||||||
properties: {
|
|
||||||
invoice_id: { type: "string", description: "Invoice ID" },
|
|
||||||
},
|
|
||||||
required: ["invoice_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "create_invoice",
|
|
||||||
description: "Create a new invoice in FreshBooks",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object" as const,
|
|
||||||
properties: {
|
|
||||||
customer_id: { type: "number", description: "Client/customer ID" },
|
|
||||||
create_date: { type: "string", description: "Invoice date (YYYY-MM-DD)" },
|
|
||||||
due_offset_days: { type: "number", description: "Days until due (default 30)" },
|
|
||||||
currency_code: { type: "string", description: "Currency code (e.g., USD, CAD)" },
|
|
||||||
notes: { type: "string", description: "Invoice notes" },
|
|
||||||
terms: { type: "string", description: "Payment terms" },
|
|
||||||
lines: {
|
|
||||||
type: "array",
|
|
||||||
description: "Invoice line items",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: { type: "string", description: "Item name" },
|
|
||||||
description: { type: "string", description: "Item description" },
|
|
||||||
qty: { type: "number", description: "Quantity" },
|
|
||||||
unit_cost: { type: "string", description: "Unit cost as string (e.g., '100.00')" },
|
|
||||||
},
|
|
||||||
required: ["name", "qty", "unit_cost"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["customer_id", "create_date"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "send_invoice",
|
|
||||||
description: "Send an invoice to the client via email",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object" as const,
|
|
||||||
properties: {
|
|
||||||
invoice_id: { type: "string", description: "Invoice ID to send" },
|
|
||||||
email_recipients: {
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string" },
|
|
||||||
description: "Email addresses to send to"
|
|
||||||
},
|
|
||||||
email_subject: { type: "string", description: "Email subject line" },
|
|
||||||
email_body: { type: "string", description: "Email body message" },
|
|
||||||
},
|
|
||||||
required: ["invoice_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list_clients",
|
|
||||||
description: "List all clients from FreshBooks",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object" as const,
|
|
||||||
properties: {
|
|
||||||
page: { type: "number", description: "Page number (default 1)" },
|
|
||||||
per_page: { type: "number", description: "Results per page (default 15)" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "create_client",
|
|
||||||
description: "Create a new client in FreshBooks",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object" as const,
|
|
||||||
properties: {
|
|
||||||
email: { type: "string", description: "Client email" },
|
|
||||||
fname: { type: "string", description: "First name" },
|
|
||||||
lname: { type: "string", description: "Last name" },
|
|
||||||
organization: { type: "string", description: "Company/organization name" },
|
|
||||||
p_street: { type: "string", description: "Street address" },
|
|
||||||
p_city: { type: "string", description: "City" },
|
|
||||||
p_province: { type: "string", description: "State/Province" },
|
|
||||||
p_code: { type: "string", description: "Postal/ZIP code" },
|
|
||||||
p_country: { type: "string", description: "Country" },
|
|
||||||
currency_code: { type: "string", description: "Currency code (e.g., USD)" },
|
|
||||||
bus_phone: { type: "string", description: "Business phone" },
|
|
||||||
note: { type: "string", description: "Notes about client" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list_expenses",
|
|
||||||
description: "List expenses from FreshBooks",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object" as const,
|
|
||||||
properties: {
|
|
||||||
page: { type: "number", description: "Page number (default 1)" },
|
|
||||||
per_page: { type: "number", description: "Results per page (default 15)" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list_payments",
|
|
||||||
description: "List payments received in FreshBooks",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object" as const,
|
|
||||||
properties: {
|
|
||||||
page: { type: "number", description: "Page number (default 1)" },
|
|
||||||
per_page: { type: "number", description: "Results per page (default 15)" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// TOOL HANDLERS
|
|
||||||
// ============================================
|
|
||||||
async function handleTool(client: FreshBooksClient, name: string, args: any) {
|
|
||||||
switch (name) {
|
|
||||||
case "list_invoices": {
|
|
||||||
return await client.listInvoices({
|
|
||||||
page: args.page,
|
|
||||||
perPage: args.per_page,
|
|
||||||
status: args.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "get_invoice": {
|
|
||||||
return await client.getInvoice(args.invoice_id);
|
|
||||||
}
|
|
||||||
case "create_invoice": {
|
|
||||||
const lines = args.lines?.map((line: any) => ({
|
|
||||||
name: line.name,
|
|
||||||
description: line.description,
|
|
||||||
qty: line.qty,
|
|
||||||
unit_cost: { amount: line.unit_cost, code: args.currency_code || "USD" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
return await client.createInvoice({
|
|
||||||
customerid: args.customer_id,
|
|
||||||
create_date: args.create_date,
|
|
||||||
due_offset_days: args.due_offset_days || 30,
|
|
||||||
currency_code: args.currency_code,
|
|
||||||
notes: args.notes,
|
|
||||||
terms: args.terms,
|
|
||||||
lines,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "send_invoice": {
|
|
||||||
return await client.sendInvoice(args.invoice_id, {
|
|
||||||
email_recipients: args.email_recipients,
|
|
||||||
email_subject: args.email_subject,
|
|
||||||
email_body: args.email_body,
|
|
||||||
action_email: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "list_clients": {
|
|
||||||
return await client.listClients({
|
|
||||||
page: args.page,
|
|
||||||
perPage: args.per_page,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "create_client": {
|
|
||||||
return await client.createClient({
|
|
||||||
email: args.email,
|
|
||||||
fname: args.fname,
|
|
||||||
lname: args.lname,
|
|
||||||
organization: args.organization,
|
|
||||||
p_street: args.p_street,
|
|
||||||
p_city: args.p_city,
|
|
||||||
p_province: args.p_province,
|
|
||||||
p_code: args.p_code,
|
|
||||||
p_country: args.p_country,
|
|
||||||
currency_code: args.currency_code,
|
|
||||||
bus_phone: args.bus_phone,
|
|
||||||
note: args.note,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "list_expenses": {
|
|
||||||
return await client.listExpenses({
|
|
||||||
page: args.page,
|
|
||||||
perPage: args.per_page,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "list_payments": {
|
|
||||||
return await client.listPayments({
|
|
||||||
page: args.page,
|
|
||||||
perPage: args.per_page,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// SERVER SETUP
|
|
||||||
// ============================================
|
|
||||||
async function main() {
|
|
||||||
const accessToken = process.env.FRESHBOOKS_ACCESS_TOKEN;
|
|
||||||
const accountId = process.env.FRESHBOOKS_ACCOUNT_ID;
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
console.error("Error: FRESHBOOKS_ACCESS_TOKEN environment variable required");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!accountId) {
|
|
||||||
console.error("Error: FRESHBOOKS_ACCOUNT_ID environment variable required");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new FreshBooksClient(accessToken, accountId);
|
|
||||||
|
|
||||||
const server = new Server(
|
|
||||||
{ name: `${MCP_NAME}-mcp`, version: MCP_VERSION },
|
|
||||||
{ capabilities: { tools: {} } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// List available tools
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
||||||
tools,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Handle tool calls
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
||||||
const { name, arguments: args } = request.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await handleTool(client, name, args || {});
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
console.error(`${MCP_NAME} MCP server running on stdio`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
14
servers/freshbooks/src/main.ts
Normal file
14
servers/freshbooks/src/main.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { FreshBooksServer } from './server.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const server = new FreshBooksServer();
|
||||||
|
await server.run();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fatal error starting FreshBooks MCP server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
147
servers/freshbooks/src/server.ts
Normal file
147
servers/freshbooks/src/server.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { FreshBooksClient } from './clients/freshbooks.js';
|
||||||
|
import { invoicesTools } from './tools/invoices-tools.js';
|
||||||
|
import { clientsTools } from './tools/clients-tools.js';
|
||||||
|
import { expensesTools } from './tools/expenses-tools.js';
|
||||||
|
import { estimatesTools } from './tools/estimates-tools.js';
|
||||||
|
import { timeEntriesTools } from './tools/time-entries-tools.js';
|
||||||
|
import { projectsTools } from './tools/projects-tools.js';
|
||||||
|
import { paymentsTools } from './tools/payments-tools.js';
|
||||||
|
import { itemsTools } from './tools/items-tools.js';
|
||||||
|
import { taxesTools } from './tools/taxes-tools.js';
|
||||||
|
import { reportsTools } from './tools/reports-tools.js';
|
||||||
|
import { recurringTools } from './tools/recurring-tools.js';
|
||||||
|
import { accountsTools } from './tools/accounts-tools.js';
|
||||||
|
|
||||||
|
export class FreshBooksServer {
|
||||||
|
private server: Server;
|
||||||
|
private client: FreshBooksClient;
|
||||||
|
private allTools: any[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.server = new Server(
|
||||||
|
{
|
||||||
|
name: 'freshbooks-mcp-server',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize FreshBooks client from env
|
||||||
|
const accountId = process.env.FRESHBOOKS_ACCOUNT_ID;
|
||||||
|
const bearerToken = process.env.FRESHBOOKS_BEARER_TOKEN;
|
||||||
|
|
||||||
|
if (!accountId || !bearerToken) {
|
||||||
|
throw new Error(
|
||||||
|
'Missing required environment variables: FRESHBOOKS_ACCOUNT_ID and FRESHBOOKS_BEARER_TOKEN'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new FreshBooksClient({
|
||||||
|
accountId,
|
||||||
|
bearerToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine all tools
|
||||||
|
this.allTools = [
|
||||||
|
...invoicesTools,
|
||||||
|
...clientsTools,
|
||||||
|
...expensesTools,
|
||||||
|
...estimatesTools,
|
||||||
|
...timeEntriesTools,
|
||||||
|
...projectsTools,
|
||||||
|
...paymentsTools,
|
||||||
|
...itemsTools,
|
||||||
|
...taxesTools,
|
||||||
|
...reportsTools,
|
||||||
|
...recurringTools,
|
||||||
|
...accountsTools,
|
||||||
|
];
|
||||||
|
|
||||||
|
this.setupHandlers();
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
this.server.onerror = (error) => {
|
||||||
|
console.error('[MCP Error]', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
await this.server.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupHandlers() {
|
||||||
|
// List tools handler
|
||||||
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: this.allTools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: tool.inputSchema.shape,
|
||||||
|
required: Object.keys(tool.inputSchema.shape).filter(
|
||||||
|
(key) => !tool.inputSchema.shape[key].isOptional()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call tool handler
|
||||||
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const toolName = request.params.name;
|
||||||
|
const tool = this.allTools.find((t) => t.name === toolName);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error(`Unknown tool: ${toolName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate input
|
||||||
|
const validatedArgs = tool.inputSchema.parse(request.params.arguments);
|
||||||
|
|
||||||
|
// Execute tool
|
||||||
|
const result = await tool.handler(validatedArgs, this.client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.message || 'Unknown error';
|
||||||
|
const errorDetails = error.errors ? JSON.stringify(error.errors, null, 2) : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Error: ${errorMessage}\n${errorDetails}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await this.server.connect(transport);
|
||||||
|
console.error('FreshBooks MCP server running on stdio');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
servers/freshbooks/src/tools/accounts-tools.ts
Normal file
51
servers/freshbooks/src/tools/accounts-tools.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Account, StaffMember } from '../types/index.js';
|
||||||
|
|
||||||
|
export const accountsTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_account',
|
||||||
|
description: 'Get current account details',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
handler: async (_args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { account: Account } } }>(
|
||||||
|
`/users/me`
|
||||||
|
);
|
||||||
|
return response.response.result.account;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_staff',
|
||||||
|
description: 'List all staff members',
|
||||||
|
inputSchema: z.object({
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.getPaginated<{ staff: StaffMember[] }>(
|
||||||
|
'/users/staff',
|
||||||
|
args.page,
|
||||||
|
args.per_page
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
staff: response.response.result.staff || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_current_user',
|
||||||
|
description: 'Get current user (self) details',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
handler: async (_args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: any } }>(
|
||||||
|
'/auth/api/v1/users/me'
|
||||||
|
);
|
||||||
|
return response.response.result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
137
servers/freshbooks/src/tools/clients-tools.ts
Normal file
137
servers/freshbooks/src/tools/clients-tools.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Client, ClientContact } from '../types/index.js';
|
||||||
|
|
||||||
|
export const clientsTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_clients',
|
||||||
|
description: 'List all clients with optional search',
|
||||||
|
inputSchema: z.object({
|
||||||
|
search: z.string().optional().describe('Search by name, email, or organization'),
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (args.search) {
|
||||||
|
params.search = { email_like: `%${args.search}%` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.getPaginated<{ clients: Client[] }>(
|
||||||
|
'/users/clients',
|
||||||
|
args.page,
|
||||||
|
args.per_page,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
clients: response.response.result.clients || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_client',
|
||||||
|
description: 'Get a single client by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
client_id: z.number().describe('Client ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { client: Client } } }>(
|
||||||
|
`/users/clients/${args.client_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.client;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_client',
|
||||||
|
description: 'Create a new client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
fname: z.string().describe('First name'),
|
||||||
|
lname: z.string().describe('Last name'),
|
||||||
|
email: z.string().email().describe('Email address'),
|
||||||
|
organization: z.string().optional().describe('Company/organization name'),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
mobile: z.string().optional(),
|
||||||
|
bill_street: z.string().optional(),
|
||||||
|
bill_city: z.string().optional(),
|
||||||
|
bill_state: z.string().optional(),
|
||||||
|
bill_country: z.string().optional(),
|
||||||
|
bill_postal_code: z.string().optional(),
|
||||||
|
currency_code: z.string().default('USD'),
|
||||||
|
language: z.string().default('en'),
|
||||||
|
note: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const clientData = { client: { ...args } };
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { client: Client } } }>(
|
||||||
|
'/users/clients',
|
||||||
|
clientData
|
||||||
|
);
|
||||||
|
return response.response.result.client;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_client',
|
||||||
|
description: 'Update an existing client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
client_id: z.number().describe('Client ID'),
|
||||||
|
fname: z.string().optional(),
|
||||||
|
lname: z.string().optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
organization: z.string().optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
mobile: z.string().optional(),
|
||||||
|
bill_street: z.string().optional(),
|
||||||
|
bill_city: z.string().optional(),
|
||||||
|
bill_state: z.string().optional(),
|
||||||
|
bill_country: z.string().optional(),
|
||||||
|
bill_postal_code: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { client_id, ...updateFields } = args;
|
||||||
|
const clientData = { client: updateFields };
|
||||||
|
|
||||||
|
const response = await client.put<{ response: { result: { client: Client } } }>(
|
||||||
|
`/users/clients/${client_id}`,
|
||||||
|
clientData
|
||||||
|
);
|
||||||
|
return response.response.result.client;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_client',
|
||||||
|
description: 'Delete (archive) a client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
client_id: z.number().describe('Client ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/users/clients/${args.client_id}`,
|
||||||
|
{ client: { vis_state: 1 } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Client ${args.client_id} archived` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_client_contacts',
|
||||||
|
description: 'List all contacts for a specific client',
|
||||||
|
inputSchema: z.object({
|
||||||
|
client_id: z.number().describe('Client ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { contacts: ClientContact[] } } }>(
|
||||||
|
`/users/clients/${args.client_id}/contacts`
|
||||||
|
);
|
||||||
|
return response.response.result.contacts || [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
194
servers/freshbooks/src/tools/estimates-tools.ts
Normal file
194
servers/freshbooks/src/tools/estimates-tools.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Estimate } from '../types/index.js';
|
||||||
|
|
||||||
|
export const estimatesTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_estimates',
|
||||||
|
description: 'List all estimates with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientid: z.number().optional().describe('Filter by client ID'),
|
||||||
|
status: z.enum(['draft', 'sent', 'accepted', 'declined']).optional(),
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (args.clientid) params.clientid = args.clientid;
|
||||||
|
if (args.status) params.status = args.status;
|
||||||
|
|
||||||
|
const response = await client.getPaginated<{ estimates: Estimate[] }>(
|
||||||
|
'/estimates/estimates',
|
||||||
|
args.page,
|
||||||
|
args.per_page,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
estimates: response.response.result.estimates || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_estimate',
|
||||||
|
description: 'Get a single estimate by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
estimate_id: z.number().describe('Estimate ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { estimate: Estimate } } }>(
|
||||||
|
`/estimates/estimates/${args.estimate_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.estimate;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_estimate',
|
||||||
|
description: 'Create a new estimate',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientid: z.number().describe('Client ID'),
|
||||||
|
create_date: z.string().optional().describe('Estimate date (YYYY-MM-DD)'),
|
||||||
|
lines: z.array(z.object({
|
||||||
|
name: z.string().describe('Line item name'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
qty: z.number().default(1),
|
||||||
|
unit_cost: z.string().describe('Unit cost'),
|
||||||
|
})).describe('Estimate line items'),
|
||||||
|
currency_code: z.string().default('USD'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const lines = args.lines.map((line: any) => ({
|
||||||
|
...line,
|
||||||
|
unit_cost: { amount: line.unit_cost, code: args.currency_code },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const estimateData = {
|
||||||
|
estimate: {
|
||||||
|
clientid: args.clientid,
|
||||||
|
create_date: args.create_date || new Date().toISOString().split('T')[0],
|
||||||
|
currency_code: args.currency_code,
|
||||||
|
lines,
|
||||||
|
notes: args.notes,
|
||||||
|
terms: args.terms,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { estimate: Estimate } } }>(
|
||||||
|
'/estimates/estimates',
|
||||||
|
estimateData
|
||||||
|
);
|
||||||
|
return response.response.result.estimate;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_estimate',
|
||||||
|
description: 'Update an existing estimate',
|
||||||
|
inputSchema: z.object({
|
||||||
|
estimate_id: z.number().describe('Estimate ID'),
|
||||||
|
clientid: z.number().optional(),
|
||||||
|
create_date: z.string().optional(),
|
||||||
|
lines: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
qty: z.number(),
|
||||||
|
unit_cost: z.string(),
|
||||||
|
})).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { estimate_id, ...updateFields } = args;
|
||||||
|
|
||||||
|
if (updateFields.lines) {
|
||||||
|
updateFields.lines = updateFields.lines.map((line: any) => ({
|
||||||
|
...line,
|
||||||
|
unit_cost: { amount: line.unit_cost, code: 'USD' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateData = { estimate: updateFields };
|
||||||
|
const response = await client.put<{ response: { result: { estimate: Estimate } } }>(
|
||||||
|
`/estimates/estimates/${estimate_id}`,
|
||||||
|
estimateData
|
||||||
|
);
|
||||||
|
return response.response.result.estimate;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_estimate',
|
||||||
|
description: 'Delete (archive) an estimate',
|
||||||
|
inputSchema: z.object({
|
||||||
|
estimate_id: z.number().describe('Estimate ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/estimates/estimates/${args.estimate_id}`,
|
||||||
|
{ estimate: { vis_state: 1 } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Estimate ${args.estimate_id} deleted` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_send_estimate',
|
||||||
|
description: 'Send an estimate to the client via email',
|
||||||
|
inputSchema: z.object({
|
||||||
|
estimate_id: z.number().describe('Estimate ID'),
|
||||||
|
email_subject: z.string().optional(),
|
||||||
|
email_body: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const emailData: any = { estimate: { action_email: true } };
|
||||||
|
if (args.email_subject) emailData.estimate.email_subject = args.email_subject;
|
||||||
|
if (args.email_body) emailData.estimate.email_body = args.email_body;
|
||||||
|
|
||||||
|
await client.put(
|
||||||
|
`/estimates/estimates/${args.estimate_id}`,
|
||||||
|
emailData
|
||||||
|
);
|
||||||
|
return { success: true, message: `Estimate ${args.estimate_id} sent` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_convert_estimate_to_invoice',
|
||||||
|
description: 'Convert an estimate to an invoice',
|
||||||
|
inputSchema: z.object({
|
||||||
|
estimate_id: z.number().describe('Estimate ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
// Get the estimate first
|
||||||
|
const estimateResp = await client.get<{ response: { result: { estimate: Estimate } } }>(
|
||||||
|
`/estimates/estimates/${args.estimate_id}`
|
||||||
|
);
|
||||||
|
const estimate = estimateResp.response.result.estimate;
|
||||||
|
|
||||||
|
// Create invoice from estimate
|
||||||
|
const invoiceData = {
|
||||||
|
invoice: {
|
||||||
|
clientid: estimate.clientid,
|
||||||
|
create_date: new Date().toISOString().split('T')[0],
|
||||||
|
currency_code: estimate.currency_code,
|
||||||
|
lines: estimate.lines,
|
||||||
|
notes: estimate.notes,
|
||||||
|
terms: estimate.terms,
|
||||||
|
estimateid: args.estimate_id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post(
|
||||||
|
'/invoices/invoices',
|
||||||
|
invoiceData
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
139
servers/freshbooks/src/tools/expenses-tools.ts
Normal file
139
servers/freshbooks/src/tools/expenses-tools.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Expense, ExpenseCategory } from '../types/index.js';
|
||||||
|
|
||||||
|
export const expensesTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_expenses',
|
||||||
|
description: 'List all expenses with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientid: z.number().optional().describe('Filter by client ID'),
|
||||||
|
category_id: z.number().optional().describe('Filter by category ID'),
|
||||||
|
projectid: z.number().optional().describe('Filter by project ID'),
|
||||||
|
date_min: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
||||||
|
date_max: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (args.clientid) params.clientid = args.clientid;
|
||||||
|
if (args.category_id) params.categoryid = args.category_id;
|
||||||
|
if (args.projectid) params.projectid = args.projectid;
|
||||||
|
if (args.date_min) params.date_min = args.date_min;
|
||||||
|
if (args.date_max) params.date_max = args.date_max;
|
||||||
|
|
||||||
|
const response = await client.getPaginated<{ expenses: Expense[] }>(
|
||||||
|
'/expenses/expenses',
|
||||||
|
args.page,
|
||||||
|
args.per_page,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
expenses: response.response.result.expenses || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_expense',
|
||||||
|
description: 'Get a single expense by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
expense_id: z.number().describe('Expense ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { expense: Expense } } }>(
|
||||||
|
`/expenses/expenses/${args.expense_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.expense;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_expense',
|
||||||
|
description: 'Create a new expense',
|
||||||
|
inputSchema: z.object({
|
||||||
|
category_id: z.number().describe('Expense category ID'),
|
||||||
|
vendor: z.string().describe('Vendor name'),
|
||||||
|
amount: z.string().describe('Expense amount'),
|
||||||
|
date: z.string().describe('Expense date (YYYY-MM-DD)'),
|
||||||
|
clientid: z.number().optional().describe('Associated client ID'),
|
||||||
|
projectid: z.number().optional().describe('Associated project ID'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
taxName1: z.string().optional(),
|
||||||
|
taxPercent1: z.number().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const expenseData = {
|
||||||
|
expense: {
|
||||||
|
...args,
|
||||||
|
amount: { amount: args.amount, code: 'USD' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { expense: Expense } } }>(
|
||||||
|
'/expenses/expenses',
|
||||||
|
expenseData
|
||||||
|
);
|
||||||
|
return response.response.result.expense;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_expense',
|
||||||
|
description: 'Update an existing expense',
|
||||||
|
inputSchema: z.object({
|
||||||
|
expense_id: z.number().describe('Expense ID'),
|
||||||
|
category_id: z.number().optional(),
|
||||||
|
vendor: z.string().optional(),
|
||||||
|
amount: z.string().optional(),
|
||||||
|
date: z.string().optional(),
|
||||||
|
clientid: z.number().optional(),
|
||||||
|
projectid: z.number().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { expense_id, ...updateFields } = args;
|
||||||
|
if (updateFields.amount) {
|
||||||
|
updateFields.amount = { amount: updateFields.amount, code: 'USD' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenseData = { expense: updateFields };
|
||||||
|
const response = await client.put<{ response: { result: { expense: Expense } } }>(
|
||||||
|
`/expenses/expenses/${expense_id}`,
|
||||||
|
expenseData
|
||||||
|
);
|
||||||
|
return response.response.result.expense;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_expense',
|
||||||
|
description: 'Delete an expense',
|
||||||
|
inputSchema: z.object({
|
||||||
|
expense_id: z.number().describe('Expense ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/expenses/expenses/${args.expense_id}`,
|
||||||
|
{ expense: { vis_state: 1 } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Expense ${args.expense_id} deleted` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_expense_categories',
|
||||||
|
description: 'List all expense categories',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
handler: async (_args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { categories: ExpenseCategory[] } } }>(
|
||||||
|
'/expenses/categories'
|
||||||
|
);
|
||||||
|
return response.response.result.categories || [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
268
servers/freshbooks/src/tools/invoices-tools.ts
Normal file
268
servers/freshbooks/src/tools/invoices-tools.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Invoice, Payment } from '../types/index.js';
|
||||||
|
|
||||||
|
export const invoicesTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_invoices',
|
||||||
|
description: 'List all invoices with optional filtering (client, status, date range)',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientid: z.number().optional().describe('Filter by client ID'),
|
||||||
|
status: z.enum(['draft', 'sent', 'viewed', 'paid', 'partial', 'overdue', 'disputed']).optional(),
|
||||||
|
date_min: z.string().optional().describe('Minimum date (YYYY-MM-DD)'),
|
||||||
|
date_max: z.string().optional().describe('Maximum date (YYYY-MM-DD)'),
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (args.clientid) params.clientid = args.clientid;
|
||||||
|
if (args.status) params.status = args.status;
|
||||||
|
if (args.date_min) params.date_min = args.date_min;
|
||||||
|
if (args.date_max) params.date_max = args.date_max;
|
||||||
|
|
||||||
|
const response = await client.getPaginated<{ invoices: Invoice[] }>(
|
||||||
|
'/invoices/invoices',
|
||||||
|
args.page,
|
||||||
|
args.per_page,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
invoices: response.response.result.invoices || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_invoice',
|
||||||
|
description: 'Get a single invoice by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoice_id: z.number().describe('Invoice ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { invoice: Invoice } } }>(
|
||||||
|
`/invoices/invoices/${args.invoice_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.invoice;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_invoice',
|
||||||
|
description: 'Create a new invoice',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientid: z.number().describe('Client ID'),
|
||||||
|
create_date: z.string().optional().describe('Invoice date (YYYY-MM-DD, defaults to today)'),
|
||||||
|
due_date: z.string().optional().describe('Due date (YYYY-MM-DD)'),
|
||||||
|
lines: z.array(z.object({
|
||||||
|
name: z.string().describe('Line item name'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
qty: z.number().default(1),
|
||||||
|
unit_cost: z.string().describe('Unit cost as string (e.g., "100.00")'),
|
||||||
|
})).describe('Invoice line items'),
|
||||||
|
currency_code: z.string().default('USD'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms: z.string().optional(),
|
||||||
|
status: z.enum(['draft', 'sent']).default('draft'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const lines = args.lines.map((line: any) => ({
|
||||||
|
...line,
|
||||||
|
unit_cost: { amount: line.unit_cost, code: args.currency_code },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const invoiceData = {
|
||||||
|
invoice: {
|
||||||
|
clientid: args.clientid,
|
||||||
|
create_date: args.create_date || new Date().toISOString().split('T')[0],
|
||||||
|
due_date: args.due_date,
|
||||||
|
currency_code: args.currency_code,
|
||||||
|
lines,
|
||||||
|
notes: args.notes,
|
||||||
|
terms: args.terms,
|
||||||
|
status: args.status === 'sent' ? 2 : 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { invoice: Invoice } } }>(
|
||||||
|
'/invoices/invoices',
|
||||||
|
invoiceData
|
||||||
|
);
|
||||||
|
return response.response.result.invoice;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_invoice',
|
||||||
|
description: 'Update an existing invoice',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoice_id: z.number().describe('Invoice ID'),
|
||||||
|
clientid: z.number().optional(),
|
||||||
|
create_date: z.string().optional(),
|
||||||
|
due_date: z.string().optional(),
|
||||||
|
lines: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
qty: z.number(),
|
||||||
|
unit_cost: z.string(),
|
||||||
|
})).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const updateData: any = { invoice: {} };
|
||||||
|
|
||||||
|
if (args.clientid) updateData.invoice.clientid = args.clientid;
|
||||||
|
if (args.create_date) updateData.invoice.create_date = args.create_date;
|
||||||
|
if (args.due_date) updateData.invoice.due_date = args.due_date;
|
||||||
|
if (args.notes) updateData.invoice.notes = args.notes;
|
||||||
|
if (args.terms) updateData.invoice.terms = args.terms;
|
||||||
|
if (args.lines) {
|
||||||
|
updateData.invoice.lines = args.lines.map((line: any) => ({
|
||||||
|
...line,
|
||||||
|
unit_cost: { amount: line.unit_cost, code: 'USD' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.put<{ response: { result: { invoice: Invoice } } }>(
|
||||||
|
`/invoices/invoices/${args.invoice_id}`,
|
||||||
|
updateData
|
||||||
|
);
|
||||||
|
return response.response.result.invoice;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_invoice',
|
||||||
|
description: 'Delete an invoice (moves to archived)',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoice_id: z.number().describe('Invoice ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/invoices/invoices/${args.invoice_id}`,
|
||||||
|
{ invoice: { vis_state: 1 } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Invoice ${args.invoice_id} archived` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_send_invoice',
|
||||||
|
description: 'Send an invoice to the client via email',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoice_id: z.number().describe('Invoice ID'),
|
||||||
|
email_subject: z.string().optional(),
|
||||||
|
email_body: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const emailData: any = { invoice: {} };
|
||||||
|
if (args.email_subject) emailData.invoice.email_subject = args.email_subject;
|
||||||
|
if (args.email_body) emailData.invoice.email_body = args.email_body;
|
||||||
|
|
||||||
|
await client.put(
|
||||||
|
`/invoices/invoices/${args.invoice_id}`,
|
||||||
|
{ invoice: { action_email: true, ...emailData.invoice } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Invoice ${args.invoice_id} sent` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_mark_invoice_paid',
|
||||||
|
description: 'Mark an invoice as paid',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoice_id: z.number().describe('Invoice ID'),
|
||||||
|
payment_type: z.string().default('Cash').describe('Payment method'),
|
||||||
|
payment_date: z.string().optional().describe('Payment date (YYYY-MM-DD, defaults to today)'),
|
||||||
|
amount: z.string().optional().describe('Payment amount (defaults to outstanding amount)'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
// First get the invoice to know the outstanding amount
|
||||||
|
const invoiceResp = await client.get<{ response: { result: { invoice: Invoice } } }>(
|
||||||
|
`/invoices/invoices/${args.invoice_id}`
|
||||||
|
);
|
||||||
|
const invoice = invoiceResp.response.result.invoice;
|
||||||
|
|
||||||
|
const paymentData = {
|
||||||
|
payment: {
|
||||||
|
invoiceid: args.invoice_id,
|
||||||
|
amount: {
|
||||||
|
amount: args.amount || invoice.outstanding.amount,
|
||||||
|
code: invoice.currency_code,
|
||||||
|
},
|
||||||
|
date: args.payment_date || new Date().toISOString().split('T')[0],
|
||||||
|
type: args.payment_type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { payment: Payment } } }>(
|
||||||
|
'/payments/payments',
|
||||||
|
paymentData
|
||||||
|
);
|
||||||
|
return response.response.result.payment;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_mark_invoice_unpaid',
|
||||||
|
description: 'Mark an invoice as unpaid (reopen it)',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoice_id: z.number().describe('Invoice ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/invoices/invoices/${args.invoice_id}`,
|
||||||
|
{ invoice: { v3_status: 'unpaid' } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Invoice ${args.invoice_id} marked unpaid` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_invoice_payment',
|
||||||
|
description: 'Get payment details for an invoice',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoice_id: z.number().describe('Invoice ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { payments: Payment[] } } }>(
|
||||||
|
'/payments/payments',
|
||||||
|
{ invoiceid: args.invoice_id }
|
||||||
|
);
|
||||||
|
return response.response.result.payments || [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_payment',
|
||||||
|
description: 'Create a payment record for an invoice',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoice_id: z.number().describe('Invoice ID'),
|
||||||
|
amount: z.string().describe('Payment amount'),
|
||||||
|
date: z.string().optional().describe('Payment date (YYYY-MM-DD)'),
|
||||||
|
type: z.string().default('Cash').describe('Payment method'),
|
||||||
|
note: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const paymentData = {
|
||||||
|
payment: {
|
||||||
|
invoiceid: args.invoice_id,
|
||||||
|
amount: { amount: args.amount, code: 'USD' },
|
||||||
|
date: args.date || new Date().toISOString().split('T')[0],
|
||||||
|
type: args.type,
|
||||||
|
note: args.note,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { payment: Payment } } }>(
|
||||||
|
'/payments/payments',
|
||||||
|
paymentData
|
||||||
|
);
|
||||||
|
return response.response.result.payment;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
110
servers/freshbooks/src/tools/items-tools.ts
Normal file
110
servers/freshbooks/src/tools/items-tools.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Item } from '../types/index.js';
|
||||||
|
|
||||||
|
export const itemsTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_items',
|
||||||
|
description: 'List all items (products/services)',
|
||||||
|
inputSchema: z.object({
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.getPaginated<{ items: Item[] }>(
|
||||||
|
'/items/items',
|
||||||
|
args.page,
|
||||||
|
args.per_page
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
items: response.response.result.items || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_item',
|
||||||
|
description: 'Get a single item by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
item_id: z.number().describe('Item ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { item: Item } } }>(
|
||||||
|
`/items/items/${args.item_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.item;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_item',
|
||||||
|
description: 'Create a new item (product or service)',
|
||||||
|
inputSchema: z.object({
|
||||||
|
name: z.string().describe('Item name'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
qty: z.number().optional().describe('Quantity on hand'),
|
||||||
|
inventory: z.number().optional(),
|
||||||
|
unit_cost: z.string().optional().describe('Unit cost'),
|
||||||
|
tax1: z.number().optional().describe('Tax 1 ID'),
|
||||||
|
tax2: z.number().optional().describe('Tax 2 ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const itemData: any = { item: { ...args } };
|
||||||
|
if (itemData.item.unit_cost) {
|
||||||
|
itemData.item.unit_cost = { amount: itemData.item.unit_cost, code: 'USD' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { item: Item } } }>(
|
||||||
|
'/items/items',
|
||||||
|
itemData
|
||||||
|
);
|
||||||
|
return response.response.result.item;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_item',
|
||||||
|
description: 'Update an existing item',
|
||||||
|
inputSchema: z.object({
|
||||||
|
item_id: z.number().describe('Item ID'),
|
||||||
|
name: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
qty: z.number().optional(),
|
||||||
|
inventory: z.number().optional(),
|
||||||
|
unit_cost: z.string().optional(),
|
||||||
|
tax1: z.number().optional(),
|
||||||
|
tax2: z.number().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { item_id, ...updateFields } = args;
|
||||||
|
if (updateFields.unit_cost) {
|
||||||
|
updateFields.unit_cost = { amount: updateFields.unit_cost, code: 'USD' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemData = { item: updateFields };
|
||||||
|
const response = await client.put<{ response: { result: { item: Item } } }>(
|
||||||
|
`/items/items/${item_id}`,
|
||||||
|
itemData
|
||||||
|
);
|
||||||
|
return response.response.result.item;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_item',
|
||||||
|
description: 'Delete an item',
|
||||||
|
inputSchema: z.object({
|
||||||
|
item_id: z.number().describe('Item ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/items/items/${args.item_id}`,
|
||||||
|
{ item: { vis_state: 1 } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Item ${args.item_id} deleted` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
121
servers/freshbooks/src/tools/payments-tools.ts
Normal file
121
servers/freshbooks/src/tools/payments-tools.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Payment } from '../types/index.js';
|
||||||
|
|
||||||
|
export const paymentsTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_payments',
|
||||||
|
description: 'List all payments with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoiceid: z.number().optional().describe('Filter by invoice ID'),
|
||||||
|
clientid: z.number().optional().describe('Filter by client ID'),
|
||||||
|
date_min: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
||||||
|
date_max: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (args.invoiceid) params.invoiceid = args.invoiceid;
|
||||||
|
if (args.clientid) params.clientid = args.clientid;
|
||||||
|
if (args.date_min) params.date_min = args.date_min;
|
||||||
|
if (args.date_max) params.date_max = args.date_max;
|
||||||
|
|
||||||
|
const response = await client.getPaginated<{ payments: Payment[] }>(
|
||||||
|
'/payments/payments',
|
||||||
|
args.page,
|
||||||
|
args.per_page,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
payments: response.response.result.payments || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_payment',
|
||||||
|
description: 'Get a single payment by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
payment_id: z.number().describe('Payment ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { payment: Payment } } }>(
|
||||||
|
`/payments/payments/${args.payment_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.payment;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_payment',
|
||||||
|
description: 'Create a new payment',
|
||||||
|
inputSchema: z.object({
|
||||||
|
invoiceid: z.number().describe('Invoice ID'),
|
||||||
|
amount: z.string().describe('Payment amount'),
|
||||||
|
date: z.string().optional().describe('Payment date (YYYY-MM-DD)'),
|
||||||
|
type: z.string().default('Cash').describe('Payment method'),
|
||||||
|
note: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const paymentData = {
|
||||||
|
payment: {
|
||||||
|
invoiceid: args.invoiceid,
|
||||||
|
amount: { amount: args.amount, code: 'USD' },
|
||||||
|
date: args.date || new Date().toISOString().split('T')[0],
|
||||||
|
type: args.type,
|
||||||
|
note: args.note,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { payment: Payment } } }>(
|
||||||
|
'/payments/payments',
|
||||||
|
paymentData
|
||||||
|
);
|
||||||
|
return response.response.result.payment;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_payment',
|
||||||
|
description: 'Update an existing payment',
|
||||||
|
inputSchema: z.object({
|
||||||
|
payment_id: z.number().describe('Payment ID'),
|
||||||
|
amount: z.string().optional(),
|
||||||
|
date: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { payment_id, ...updateFields } = args;
|
||||||
|
if (updateFields.amount) {
|
||||||
|
updateFields.amount = { amount: updateFields.amount, code: 'USD' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentData = { payment: updateFields };
|
||||||
|
const response = await client.put<{ response: { result: { payment: Payment } } }>(
|
||||||
|
`/payments/payments/${payment_id}`,
|
||||||
|
paymentData
|
||||||
|
);
|
||||||
|
return response.response.result.payment;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_payment',
|
||||||
|
description: 'Delete a payment',
|
||||||
|
inputSchema: z.object({
|
||||||
|
payment_id: z.number().describe('Payment ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/payments/payments/${args.payment_id}`,
|
||||||
|
{ payment: { vis_state: 1 } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Payment ${args.payment_id} deleted` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
124
servers/freshbooks/src/tools/projects-tools.ts
Normal file
124
servers/freshbooks/src/tools/projects-tools.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Project, ProjectService } from '../types/index.js';
|
||||||
|
|
||||||
|
export const projectsTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_projects',
|
||||||
|
description: 'List all projects with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
client_id: z.number().optional().describe('Filter by client ID'),
|
||||||
|
active: z.boolean().optional().describe('Filter by active status'),
|
||||||
|
complete: z.boolean().optional().describe('Filter by completion status'),
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (args.client_id !== undefined) params.client_id = args.client_id;
|
||||||
|
if (args.active !== undefined) params.active = args.active;
|
||||||
|
if (args.complete !== undefined) params.complete = args.complete;
|
||||||
|
|
||||||
|
const response = await client.getPaginated<{ projects: Project[] }>(
|
||||||
|
'/projects/business/123/projects',
|
||||||
|
args.page,
|
||||||
|
args.per_page,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
projects: response.response.result.projects || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_project',
|
||||||
|
description: 'Get a single project by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
project_id: z.number().describe('Project ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { project: Project } } }>(
|
||||||
|
`/projects/business/123/projects/${args.project_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.project;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_project',
|
||||||
|
description: 'Create a new project',
|
||||||
|
inputSchema: z.object({
|
||||||
|
title: z.string().describe('Project title'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
client_id: z.number().optional().describe('Associated client ID'),
|
||||||
|
due_date: z.string().optional().describe('Due date (YYYY-MM-DD)'),
|
||||||
|
project_type: z.enum(['fixed_price', 'hourly_rate']).default('hourly_rate'),
|
||||||
|
fixed_price: z.string().optional().describe('Fixed price amount'),
|
||||||
|
billing_method: z.enum(['project_rate', 'service_rate', 'team_member_rate']).optional(),
|
||||||
|
rate: z.string().optional().describe('Hourly rate'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const projectData = { project: { ...args } };
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { project: Project } } }>(
|
||||||
|
'/projects/business/123/projects',
|
||||||
|
projectData
|
||||||
|
);
|
||||||
|
return response.response.result.project;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_project',
|
||||||
|
description: 'Update an existing project',
|
||||||
|
inputSchema: z.object({
|
||||||
|
project_id: z.number().describe('Project ID'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
client_id: z.number().optional(),
|
||||||
|
due_date: z.string().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
complete: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { project_id, ...updateFields } = args;
|
||||||
|
const projectData = { project: updateFields };
|
||||||
|
|
||||||
|
const response = await client.put<{ response: { result: { project: Project } } }>(
|
||||||
|
`/projects/business/123/projects/${project_id}`,
|
||||||
|
projectData
|
||||||
|
);
|
||||||
|
return response.response.result.project;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_project',
|
||||||
|
description: 'Delete a project',
|
||||||
|
inputSchema: z.object({
|
||||||
|
project_id: z.number().describe('Project ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.delete(
|
||||||
|
`/projects/business/123/projects/${args.project_id}`
|
||||||
|
);
|
||||||
|
return { success: true, message: `Project ${args.project_id} deleted` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_project_services',
|
||||||
|
description: 'List all available services for time tracking',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
handler: async (_args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { services: ProjectService[] } } }>(
|
||||||
|
'/projects/business/123/services'
|
||||||
|
);
|
||||||
|
return response.response.result.services || [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
140
servers/freshbooks/src/tools/recurring-tools.ts
Normal file
140
servers/freshbooks/src/tools/recurring-tools.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { RecurringProfile } from '../types/index.js';
|
||||||
|
|
||||||
|
export const recurringTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_recurring_profiles',
|
||||||
|
description: 'List all recurring invoice profiles',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientid: z.number().optional().describe('Filter by client ID'),
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (args.clientid) params.clientid = args.clientid;
|
||||||
|
|
||||||
|
const response = await client.getPaginated<{ recurring: RecurringProfile[] }>(
|
||||||
|
'/invoices/recurring',
|
||||||
|
args.page,
|
||||||
|
args.per_page,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recurring: response.response.result.recurring || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_recurring_profile',
|
||||||
|
description: 'Get a single recurring profile by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
recurring_id: z.number().describe('Recurring profile ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { recurring: RecurringProfile } } }>(
|
||||||
|
`/invoices/recurring/${args.recurring_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.recurring;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_recurring_profile',
|
||||||
|
description: 'Create a new recurring invoice profile',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientid: z.number().describe('Client ID'),
|
||||||
|
frequency: z.enum(['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']).describe('Billing frequency'),
|
||||||
|
numberRecurring: z.number().optional().describe('Number of times to recur (0 = indefinite)'),
|
||||||
|
lines: z.array(z.object({
|
||||||
|
name: z.string().describe('Line item name'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
qty: z.number().default(1),
|
||||||
|
unit_cost: z.string().describe('Unit cost'),
|
||||||
|
})).describe('Invoice line items'),
|
||||||
|
currency_code: z.string().default('USD'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const lines = args.lines.map((line: any) => ({
|
||||||
|
...line,
|
||||||
|
unit_cost: { amount: line.unit_cost, code: args.currency_code },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const recurringData = {
|
||||||
|
recurring: {
|
||||||
|
clientid: args.clientid,
|
||||||
|
frequency: args.frequency,
|
||||||
|
numberRecurring: args.numberRecurring || 0,
|
||||||
|
create_date: new Date().toISOString().split('T')[0],
|
||||||
|
currency_code: args.currency_code,
|
||||||
|
lines,
|
||||||
|
notes: args.notes,
|
||||||
|
terms: args.terms,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { recurring: RecurringProfile } } }>(
|
||||||
|
'/invoices/recurring',
|
||||||
|
recurringData
|
||||||
|
);
|
||||||
|
return response.response.result.recurring;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_recurring_profile',
|
||||||
|
description: 'Update an existing recurring profile',
|
||||||
|
inputSchema: z.object({
|
||||||
|
recurring_id: z.number().describe('Recurring profile ID'),
|
||||||
|
frequency: z.enum(['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']).optional(),
|
||||||
|
numberRecurring: z.number().optional(),
|
||||||
|
lines: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
qty: z.number(),
|
||||||
|
unit_cost: z.string(),
|
||||||
|
})).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { recurring_id, ...updateFields } = args;
|
||||||
|
|
||||||
|
if (updateFields.lines) {
|
||||||
|
updateFields.lines = updateFields.lines.map((line: any) => ({
|
||||||
|
...line,
|
||||||
|
unit_cost: { amount: line.unit_cost, code: 'USD' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurringData = { recurring: updateFields };
|
||||||
|
const response = await client.put<{ response: { result: { recurring: RecurringProfile } } }>(
|
||||||
|
`/invoices/recurring/${recurring_id}`,
|
||||||
|
recurringData
|
||||||
|
);
|
||||||
|
return response.response.result.recurring;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_recurring_profile',
|
||||||
|
description: 'Delete a recurring profile',
|
||||||
|
inputSchema: z.object({
|
||||||
|
recurring_id: z.number().describe('Recurring profile ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/invoices/recurring/${args.recurring_id}`,
|
||||||
|
{ recurring: { vis_state: 1 } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Recurring profile ${args.recurring_id} deleted` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
112
servers/freshbooks/src/tools/reports-tools.ts
Normal file
112
servers/freshbooks/src/tools/reports-tools.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { ProfitLossReport, TaxSummary, AccountsAgingReport } from '../types/index.js';
|
||||||
|
|
||||||
|
export const reportsTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_profit_loss_report',
|
||||||
|
description: 'Generate profit and loss report for a date range',
|
||||||
|
inputSchema: z.object({
|
||||||
|
start_date: z.string().describe('Start date (YYYY-MM-DD)'),
|
||||||
|
end_date: z.string().describe('End date (YYYY-MM-DD)'),
|
||||||
|
currency_code: z.string().default('USD'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: ProfitLossReport } }>(
|
||||||
|
'/reports/accounting/profitloss',
|
||||||
|
{
|
||||||
|
start_date: args.start_date,
|
||||||
|
end_date: args.end_date,
|
||||||
|
currency_code: args.currency_code,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.response.result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_tax_summary_report',
|
||||||
|
description: 'Generate tax summary report for a date range',
|
||||||
|
inputSchema: z.object({
|
||||||
|
start_date: z.string().describe('Start date (YYYY-MM-DD)'),
|
||||||
|
end_date: z.string().describe('End date (YYYY-MM-DD)'),
|
||||||
|
currency_code: z.string().default('USD'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { taxsummaries: TaxSummary[] } } }>(
|
||||||
|
'/reports/accounting/taxsummary',
|
||||||
|
{
|
||||||
|
start_date: args.start_date,
|
||||||
|
end_date: args.end_date,
|
||||||
|
currency_code: args.currency_code,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.response.result.taxsummaries || [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_accounts_aging_report',
|
||||||
|
description: 'Generate accounts aging report (accounts receivable)',
|
||||||
|
inputSchema: z.object({
|
||||||
|
date: z.string().optional().describe('Report date (YYYY-MM-DD, defaults to today)'),
|
||||||
|
currency_code: z.string().default('USD'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { clients: AccountsAgingReport[] } } }>(
|
||||||
|
'/reports/accounting/aging',
|
||||||
|
{
|
||||||
|
date: args.date || new Date().toISOString().split('T')[0],
|
||||||
|
currency_code: args.currency_code,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.response.result.clients || [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_expense_report',
|
||||||
|
description: 'Generate expense report for a date range',
|
||||||
|
inputSchema: z.object({
|
||||||
|
start_date: z.string().describe('Start date (YYYY-MM-DD)'),
|
||||||
|
end_date: z.string().describe('End date (YYYY-MM-DD)'),
|
||||||
|
clientid: z.number().optional().describe('Filter by client ID'),
|
||||||
|
categoryid: z.number().optional().describe('Filter by category ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: any = {
|
||||||
|
start_date: args.start_date,
|
||||||
|
end_date: args.end_date,
|
||||||
|
};
|
||||||
|
if (args.clientid) params.clientid = args.clientid;
|
||||||
|
if (args.categoryid) params.categoryid = args.categoryid;
|
||||||
|
|
||||||
|
const response = await client.get<{ response: { result: any } }>(
|
||||||
|
'/reports/accounting/expenses',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return response.response.result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_revenue_by_client_report',
|
||||||
|
description: 'Generate revenue by client report for a date range',
|
||||||
|
inputSchema: z.object({
|
||||||
|
start_date: z.string().describe('Start date (YYYY-MM-DD)'),
|
||||||
|
end_date: z.string().describe('End date (YYYY-MM-DD)'),
|
||||||
|
currency_code: z.string().default('USD'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: any } }>(
|
||||||
|
'/reports/accounting/revenue_by_client',
|
||||||
|
{
|
||||||
|
start_date: args.start_date,
|
||||||
|
end_date: args.end_date,
|
||||||
|
currency_code: args.currency_code,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.response.result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
96
servers/freshbooks/src/tools/taxes-tools.ts
Normal file
96
servers/freshbooks/src/tools/taxes-tools.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { Tax } from '../types/index.js';
|
||||||
|
|
||||||
|
export const taxesTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_taxes',
|
||||||
|
description: 'List all taxes',
|
||||||
|
inputSchema: z.object({
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.getPaginated<{ taxes: Tax[] }>(
|
||||||
|
'/taxes/taxes',
|
||||||
|
args.page,
|
||||||
|
args.per_page
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
taxes: response.response.result.taxes || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_tax',
|
||||||
|
description: 'Get a single tax by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
tax_id: z.number().describe('Tax ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { tax: Tax } } }>(
|
||||||
|
`/taxes/taxes/${args.tax_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.tax;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_tax',
|
||||||
|
description: 'Create a new tax',
|
||||||
|
inputSchema: z.object({
|
||||||
|
name: z.string().describe('Tax name (e.g., "GST", "VAT")'),
|
||||||
|
number: z.string().optional().describe('Tax number/registration'),
|
||||||
|
amount: z.string().describe('Tax percentage (e.g., "13" for 13%)'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const taxData = { tax: { ...args } };
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { tax: Tax } } }>(
|
||||||
|
'/taxes/taxes',
|
||||||
|
taxData
|
||||||
|
);
|
||||||
|
return response.response.result.tax;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_tax',
|
||||||
|
description: 'Update an existing tax',
|
||||||
|
inputSchema: z.object({
|
||||||
|
tax_id: z.number().describe('Tax ID'),
|
||||||
|
name: z.string().optional(),
|
||||||
|
number: z.string().optional(),
|
||||||
|
amount: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { tax_id, ...updateFields } = args;
|
||||||
|
const taxData = { tax: updateFields };
|
||||||
|
|
||||||
|
const response = await client.put<{ response: { result: { tax: Tax } } }>(
|
||||||
|
`/taxes/taxes/${tax_id}`,
|
||||||
|
taxData
|
||||||
|
);
|
||||||
|
return response.response.result.tax;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_tax',
|
||||||
|
description: 'Delete a tax',
|
||||||
|
inputSchema: z.object({
|
||||||
|
tax_id: z.number().describe('Tax ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.put(
|
||||||
|
`/taxes/taxes/${args.tax_id}`,
|
||||||
|
{ tax: { vis_state: 1 } }
|
||||||
|
);
|
||||||
|
return { success: true, message: `Tax ${args.tax_id} deleted` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
125
servers/freshbooks/src/tools/time-entries-tools.ts
Normal file
125
servers/freshbooks/src/tools/time-entries-tools.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { FreshBooksClient } from '../clients/freshbooks.js';
|
||||||
|
import type { TimeEntry } from '../types/index.js';
|
||||||
|
|
||||||
|
export const timeEntriesTools = [
|
||||||
|
{
|
||||||
|
name: 'freshbooks_list_time_entries',
|
||||||
|
description: 'List all time entries with optional filtering',
|
||||||
|
inputSchema: z.object({
|
||||||
|
clientid: z.number().optional().describe('Filter by client ID'),
|
||||||
|
projectid: z.number().optional().describe('Filter by project ID'),
|
||||||
|
date_min: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
||||||
|
date_max: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
||||||
|
billed_status: z.enum(['billed', 'unbilled']).optional(),
|
||||||
|
page: z.number().default(1),
|
||||||
|
per_page: z.number().default(30),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (args.clientid) params.client_id = args.clientid;
|
||||||
|
if (args.projectid) params.project_id = args.projectid;
|
||||||
|
if (args.date_min) params.started_from = args.date_min;
|
||||||
|
if (args.date_max) params.started_to = args.date_max;
|
||||||
|
if (args.billed_status) params.billed_status = args.billed_status;
|
||||||
|
|
||||||
|
const response = await client.getPaginated<{ time_entries: TimeEntry[] }>(
|
||||||
|
'/timetracking/business/123/time_entries',
|
||||||
|
args.page,
|
||||||
|
args.per_page,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
time_entries: response.response.result.time_entries || [],
|
||||||
|
page: response.response.page,
|
||||||
|
pages: response.response.pages,
|
||||||
|
total: response.response.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_get_time_entry',
|
||||||
|
description: 'Get a single time entry by ID',
|
||||||
|
inputSchema: z.object({
|
||||||
|
time_entry_id: z.number().describe('Time entry ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const response = await client.get<{ response: { result: { time_entry: TimeEntry } } }>(
|
||||||
|
`/timetracking/business/123/time_entries/${args.time_entry_id}`
|
||||||
|
);
|
||||||
|
return response.response.result.time_entry;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_create_time_entry',
|
||||||
|
description: 'Create a new time entry',
|
||||||
|
inputSchema: z.object({
|
||||||
|
duration: z.number().describe('Duration in seconds'),
|
||||||
|
note: z.string().optional().describe('Note/description'),
|
||||||
|
started_at: z.string().describe('Start time (ISO 8601 format)'),
|
||||||
|
clientid: z.number().optional().describe('Client ID'),
|
||||||
|
projectid: z.number().optional().describe('Project ID'),
|
||||||
|
service_id: z.number().optional().describe('Service ID'),
|
||||||
|
is_logged: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const timeEntryData = {
|
||||||
|
time_entry: {
|
||||||
|
is_logged: args.is_logged,
|
||||||
|
duration: args.duration,
|
||||||
|
note: args.note,
|
||||||
|
started_at: args.started_at,
|
||||||
|
client_id: args.clientid,
|
||||||
|
project_id: args.projectid,
|
||||||
|
service_id: args.service_id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ response: { result: { time_entry: TimeEntry } } }>(
|
||||||
|
'/timetracking/business/123/time_entries',
|
||||||
|
timeEntryData
|
||||||
|
);
|
||||||
|
return response.response.result.time_entry;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_update_time_entry',
|
||||||
|
description: 'Update an existing time entry',
|
||||||
|
inputSchema: z.object({
|
||||||
|
time_entry_id: z.number().describe('Time entry ID'),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
started_at: z.string().optional(),
|
||||||
|
clientid: z.number().optional(),
|
||||||
|
projectid: z.number().optional(),
|
||||||
|
service_id: z.number().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
const { time_entry_id, ...updateFields } = args;
|
||||||
|
const timeEntryData = { time_entry: updateFields };
|
||||||
|
|
||||||
|
const response = await client.put<{ response: { result: { time_entry: TimeEntry } } }>(
|
||||||
|
`/timetracking/business/123/time_entries/${time_entry_id}`,
|
||||||
|
timeEntryData
|
||||||
|
);
|
||||||
|
return response.response.result.time_entry;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'freshbooks_delete_time_entry',
|
||||||
|
description: 'Delete a time entry',
|
||||||
|
inputSchema: z.object({
|
||||||
|
time_entry_id: z.number().describe('Time entry ID'),
|
||||||
|
}),
|
||||||
|
handler: async (args: any, client: FreshBooksClient) => {
|
||||||
|
await client.delete(
|
||||||
|
`/timetracking/business/123/time_entries/${args.time_entry_id}`
|
||||||
|
);
|
||||||
|
return { success: true, message: `Time entry ${args.time_entry_id} deleted` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
349
servers/freshbooks/src/types/index.ts
Normal file
349
servers/freshbooks/src/types/index.ts
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
// FreshBooks API Types
|
||||||
|
|
||||||
|
export interface FreshBooksConfig {
|
||||||
|
accountId: string;
|
||||||
|
bearerToken: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
response: {
|
||||||
|
result: T;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreshBooksError {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
errors?: Array<{ field: string; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Types
|
||||||
|
export interface Client {
|
||||||
|
id: number;
|
||||||
|
accounting_systemid: string;
|
||||||
|
organization: string;
|
||||||
|
fname: string;
|
||||||
|
lname: string;
|
||||||
|
email: string;
|
||||||
|
company_industry?: string;
|
||||||
|
company_size?: string;
|
||||||
|
bill_street?: string;
|
||||||
|
bill_city?: string;
|
||||||
|
bill_state?: string;
|
||||||
|
bill_country?: string;
|
||||||
|
bill_postal_code?: string;
|
||||||
|
currency_code: string;
|
||||||
|
updated: string;
|
||||||
|
created_at: string;
|
||||||
|
language?: string;
|
||||||
|
note?: string;
|
||||||
|
vat_name?: string;
|
||||||
|
vat_number?: string;
|
||||||
|
allow_late_fees?: boolean;
|
||||||
|
allow_late_notifications?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientContact {
|
||||||
|
id: number;
|
||||||
|
clientid: number;
|
||||||
|
fname: string;
|
||||||
|
lname: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice Types
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
accountid: string;
|
||||||
|
accounting_systemid: string;
|
||||||
|
clientid: number;
|
||||||
|
create_date: string;
|
||||||
|
invoice_number: string;
|
||||||
|
currency_code: string;
|
||||||
|
amount: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
outstanding: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
paid: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
due_date: string;
|
||||||
|
status: string;
|
||||||
|
payment_status: string;
|
||||||
|
v3_status: string;
|
||||||
|
lines: InvoiceLine[];
|
||||||
|
terms?: string;
|
||||||
|
notes?: string;
|
||||||
|
discount_total?: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
updated: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceLine {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
qty: number;
|
||||||
|
unit_cost: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
amount: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
taxName1?: string;
|
||||||
|
taxAmount1?: string;
|
||||||
|
taxName2?: string;
|
||||||
|
taxAmount2?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: number;
|
||||||
|
invoiceid: number;
|
||||||
|
amount: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
date: string;
|
||||||
|
type: string;
|
||||||
|
note?: string;
|
||||||
|
updated: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expense Types
|
||||||
|
export interface Expense {
|
||||||
|
id: number;
|
||||||
|
category_id: number;
|
||||||
|
clientid?: number;
|
||||||
|
projectid?: number;
|
||||||
|
vendor: string;
|
||||||
|
amount: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
date: string;
|
||||||
|
notes?: string;
|
||||||
|
taxName1?: string;
|
||||||
|
taxAmount1?: number;
|
||||||
|
taxPercent1?: number;
|
||||||
|
updated: string;
|
||||||
|
created_at: string;
|
||||||
|
staffid?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseCategory {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
is_cogs?: boolean;
|
||||||
|
is_editable?: boolean;
|
||||||
|
parentid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate Types
|
||||||
|
export interface Estimate {
|
||||||
|
id: number;
|
||||||
|
accountid: string;
|
||||||
|
clientid: number;
|
||||||
|
create_date: string;
|
||||||
|
estimate_number: string;
|
||||||
|
currency_code: string;
|
||||||
|
amount: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
lines: EstimateLine[];
|
||||||
|
terms?: string;
|
||||||
|
notes?: string;
|
||||||
|
discount_total?: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
updated: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateLine {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
qty: number;
|
||||||
|
unit_cost: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
amount: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time Entry Types
|
||||||
|
export interface TimeEntry {
|
||||||
|
id: number;
|
||||||
|
is_logged?: boolean;
|
||||||
|
duration: number;
|
||||||
|
note?: string;
|
||||||
|
started_at: string;
|
||||||
|
clientid?: number;
|
||||||
|
projectid?: number;
|
||||||
|
service_id?: number;
|
||||||
|
billed_status?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project Types
|
||||||
|
export interface Project {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
client_id?: number;
|
||||||
|
due_date?: string;
|
||||||
|
project_type?: string;
|
||||||
|
fixed_price?: string;
|
||||||
|
billing_method?: string;
|
||||||
|
rate?: string;
|
||||||
|
active?: boolean;
|
||||||
|
complete?: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectService {
|
||||||
|
id: number;
|
||||||
|
business_id: number;
|
||||||
|
name: string;
|
||||||
|
billable: boolean;
|
||||||
|
rate?: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item Types
|
||||||
|
export interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
qty?: number;
|
||||||
|
inventory?: number;
|
||||||
|
unit_cost?: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
tax1?: number;
|
||||||
|
tax2?: number;
|
||||||
|
updated: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tax Types
|
||||||
|
export interface Tax {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
number?: string;
|
||||||
|
amount: string;
|
||||||
|
updated: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurring Profile Types
|
||||||
|
export interface RecurringProfile {
|
||||||
|
id: number;
|
||||||
|
clientid: number;
|
||||||
|
frequency: string;
|
||||||
|
numberRecurring: number;
|
||||||
|
create_date: string;
|
||||||
|
currency_code: string;
|
||||||
|
lines: InvoiceLine[];
|
||||||
|
status?: string;
|
||||||
|
updated: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account Types
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
account_name: string;
|
||||||
|
email: string;
|
||||||
|
business_phone?: string;
|
||||||
|
address?: {
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
province: string;
|
||||||
|
country: string;
|
||||||
|
postal_code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaffMember {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report Types
|
||||||
|
export interface ProfitLossReport {
|
||||||
|
total_income: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
total_expenses: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
net_profit: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxSummary {
|
||||||
|
tax_name: string;
|
||||||
|
tax_collected: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
tax_paid: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountsAgingReport {
|
||||||
|
client_userid: number;
|
||||||
|
organization: string;
|
||||||
|
outstanding_balance: {
|
||||||
|
amount: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
current: { amount: string; code: string };
|
||||||
|
'1-30': { amount: string; code: string };
|
||||||
|
'31-60': { amount: string; code: string };
|
||||||
|
'61-90': { amount: string; code: string };
|
||||||
|
'91+': { amount: string; code: string };
|
||||||
|
}
|
||||||
34
servers/freshbooks/src/ui/react-app/aging-report/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/aging-report/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Clients Dashboard - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
||||||
|
h1 { font-size: 2rem; }
|
||||||
|
.btn { background: #3b82f6; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: 1rem; }
|
||||||
|
.btn:hover { background: #2563eb; }
|
||||||
|
.search-bar { margin-bottom: 1.5rem; }
|
||||||
|
.search-bar input { width: 100%; background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 0.75rem; border-radius: 4px; font-size: 1rem; }
|
||||||
|
.client-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; }
|
||||||
|
.client-card { background: #1e293b; padding: 1.5rem; border-radius: 8px; border-left: 4px solid #3b82f6; }
|
||||||
|
.client-card h3 { margin-bottom: 0.5rem; }
|
||||||
|
.client-card .email { color: #64748b; font-size: 0.875rem; margin-bottom: 1rem; }
|
||||||
|
.client-meta { display: flex; justify-content: space-between; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #334155; }
|
||||||
|
.client-meta div { text-align: center; }
|
||||||
|
.client-meta .label { color: #64748b; font-size: 0.75rem; margin-bottom: 0.25rem; }
|
||||||
|
.client-meta .value { font-weight: bold; font-size: 1.125rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
|
||||||
|
function ClientDashboard() {
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sampleClients = [
|
||||||
|
{ id: 1, name: 'Acme Corp', email: 'contact@acme.com', invoices: 12, outstanding: 5600, lastInvoice: '2024-01-15' },
|
||||||
|
{ id: 2, name: 'Tech Solutions', email: 'hello@tech.com', invoices: 8, outstanding: 3200, lastInvoice: '2024-01-20' },
|
||||||
|
{ id: 3, name: 'Design Co', email: 'info@design.co', invoices: 15, outstanding: 0, lastInvoice: '2024-01-10' },
|
||||||
|
{ id: 4, name: 'Marketing Inc', email: 'team@marketing.com', invoices: 6, outstanding: 1800, lastInvoice: '2024-01-25' },
|
||||||
|
];
|
||||||
|
setClients(sampleClients);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredClients = clients.filter(client =>
|
||||||
|
client.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
client.email.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="header">
|
||||||
|
<div>
|
||||||
|
<h1>Clients</h1>
|
||||||
|
<p style={{ color: '#94a3b8' }}>{clients.length} active clients</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn">+ Add Client</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-bar">
|
||||||
|
<input type="text" placeholder="Search clients..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="client-grid">
|
||||||
|
{filteredClients.map(client => (
|
||||||
|
<div key={client.id} className="client-card">
|
||||||
|
<h3>{client.name}</h3>
|
||||||
|
<div className="email">{client.email}</div>
|
||||||
|
<div className="client-meta">
|
||||||
|
<div>
|
||||||
|
<div className="label">Invoices</div>
|
||||||
|
<div className="value">{client.invoices}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="label">Outstanding</div>
|
||||||
|
<div className="value" style={{ color: client.outstanding > 0 ? '#fbbf24' : '#6ee7b7' }}>
|
||||||
|
${client.outstanding.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="label">Last Invoice</div>
|
||||||
|
<div className="value" style={{ fontSize: '0.875rem' }}>{client.lastInvoice}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<ClientDashboard />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
servers/freshbooks/src/ui/react-app/client-detail/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/client-detail/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
servers/freshbooks/src/ui/react-app/client-grid/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/client-grid/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
servers/freshbooks/src/ui/react-app/estimate-grid/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/estimate-grid/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
134
servers/freshbooks/src/ui/react-app/expense-tracker/index.html
Normal file
134
servers/freshbooks/src/ui/react-app/expense-tracker/index.html
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Expense Tracker - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 1000px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.header { margin-bottom: 2rem; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2rem; }
|
||||||
|
.stat-card { background: #1e293b; padding: 1.5rem; border-radius: 8px; text-align: center; }
|
||||||
|
.stat-card .value { font-size: 2rem; font-weight: bold; margin-bottom: 0.25rem; }
|
||||||
|
.stat-card .label { color: #94a3b8; font-size: 0.875rem; }
|
||||||
|
.form-card { background: #1e293b; padding: 1.5rem; border-radius: 8px; margin-bottom: 2rem; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.form-group.full { grid-column: 1 / -1; }
|
||||||
|
label { font-size: 0.875rem; color: #94a3b8; }
|
||||||
|
input, select, textarea { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; padding: 0.75rem; border-radius: 4px; }
|
||||||
|
.btn { background: #3b82f6; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; }
|
||||||
|
.btn:hover { background: #2563eb; }
|
||||||
|
.expense-list { background: #1e293b; border-radius: 8px; overflow: hidden; }
|
||||||
|
.expense-item { padding: 1rem; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.expense-item:last-child { border-bottom: none; }
|
||||||
|
.expense-info .category { color: #64748b; font-size: 0.875rem; }
|
||||||
|
.expense-amount { font-weight: bold; font-size: 1.125rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
function ExpenseTracker() {
|
||||||
|
const [expenses, setExpenses] = useState([
|
||||||
|
{ id: 1, vendor: 'Office Supplies Co', category: 'Office', amount: 245, date: '2024-01-15' },
|
||||||
|
{ id: 2, vendor: 'Tech Store', category: 'Equipment', amount: 1200, date: '2024-01-18' },
|
||||||
|
{ id: 3, vendor: 'Coffee Shop', category: 'Meals', amount: 45, date: '2024-01-20' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
vendor: '', category: 'Office', amount: '', date: new Date().toISOString().split('T')[0], notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalExpenses = expenses.reduce((sum, exp) => sum + exp.amount, 0);
|
||||||
|
const thisMonth = expenses.filter(e => e.date.startsWith('2024-01')).reduce((sum, exp) => sum + exp.amount, 0);
|
||||||
|
|
||||||
|
const addExpense = () => {
|
||||||
|
if (form.vendor && form.amount) {
|
||||||
|
setExpenses([...expenses, { ...form, id: Date.now(), amount: parseFloat(form.amount) }]);
|
||||||
|
setForm({ vendor: '', category: 'Office', amount: '', date: new Date().toISOString().split('T')[0], notes: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="header">
|
||||||
|
<h1>Expense Tracker</h1>
|
||||||
|
<p style={{ color: '#94a3b8' }}>Track and categorize business expenses</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">${totalExpenses.toLocaleString()}</div>
|
||||||
|
<div className="label">Total Expenses</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">${thisMonth.toLocaleString()}</div>
|
||||||
|
<div className="label">This Month</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">{expenses.length}</div>
|
||||||
|
<div className="label">Transactions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-card">
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Add Expense</h3>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Vendor</label>
|
||||||
|
<input type="text" value={form.vendor} onChange={(e) => setForm({ ...form, vendor: e.target.value })} placeholder="Vendor name" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||||
|
<option value="Office">Office Supplies</option>
|
||||||
|
<option value="Equipment">Equipment</option>
|
||||||
|
<option value="Meals">Meals & Entertainment</option>
|
||||||
|
<option value="Travel">Travel</option>
|
||||||
|
<option value="Software">Software</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Amount</label>
|
||||||
|
<input type="number" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Date</label>
|
||||||
|
<input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group full">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea rows="2" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Optional notes" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn" style={{ marginTop: '1rem' }} onClick={addExpense}>Add Expense</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="expense-list">
|
||||||
|
{expenses.map(expense => (
|
||||||
|
<div key={expense.id} className="expense-item">
|
||||||
|
<div className="expense-info">
|
||||||
|
<div>{expense.vendor}</div>
|
||||||
|
<div className="category">{expense.category} • {expense.date}</div>
|
||||||
|
</div>
|
||||||
|
<div className="expense-amount">${expense.amount.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<ExpenseTracker />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
139
servers/freshbooks/src/ui/react-app/invoice-builder/index.html
Normal file
139
servers/freshbooks/src/ui/react-app/invoice-builder/index.html
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Invoice Builder - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 1000px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.header { margin-bottom: 2rem; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
.form-grid { display: grid; gap: 1.5rem; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
label { font-size: 0.875rem; color: #94a3b8; font-weight: 500; }
|
||||||
|
input, select, textarea { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 0.75rem; border-radius: 4px; font-size: 1rem; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline: none; border-color: #3b82f6; }
|
||||||
|
.line-items { margin-top: 2rem; }
|
||||||
|
.line-item { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 40px; gap: 1rem; margin-bottom: 1rem; align-items: end; }
|
||||||
|
.btn { background: #3b82f6; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: 1rem; font-weight: 500; }
|
||||||
|
.btn:hover { background: #2563eb; }
|
||||||
|
.btn-secondary { background: #334155; }
|
||||||
|
.btn-secondary:hover { background: #475569; }
|
||||||
|
.btn-danger { background: #ef4444; padding: 0.5rem; }
|
||||||
|
.btn-danger:hover { background: #dc2626; }
|
||||||
|
.total-section { background: #1e293b; padding: 1.5rem; border-radius: 8px; margin-top: 2rem; }
|
||||||
|
.total-row { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||||
|
.total-row.final { font-size: 1.25rem; font-weight: bold; padding-top: 1rem; border-top: 2px solid #334155; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
function InvoiceBuilder() {
|
||||||
|
const [invoice, setInvoice] = useState({
|
||||||
|
client: '',
|
||||||
|
invoiceNumber: 'INV-' + Date.now(),
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
dueDate: '',
|
||||||
|
lines: [{ description: '', qty: 1, rate: 0 }],
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const addLine = () => {
|
||||||
|
setInvoice({ ...invoice, lines: [...invoice.lines, { description: '', qty: 1, rate: 0 }] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLine = (index) => {
|
||||||
|
setInvoice({ ...invoice, lines: invoice.lines.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLine = (index, field, value) => {
|
||||||
|
const newLines = [...invoice.lines];
|
||||||
|
newLines[index] = { ...newLines[index], [field]: value };
|
||||||
|
setInvoice({ ...invoice, lines: newLines });
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = invoice.lines.reduce((sum, line) => sum + (line.qty * line.rate), 0);
|
||||||
|
const tax = subtotal * 0.13; // 13% tax
|
||||||
|
const total = subtotal + tax;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="header">
|
||||||
|
<h1>Create Invoice</h1>
|
||||||
|
<p style={{ color: '#94a3b8' }}>Build and send professional invoices</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Client</label>
|
||||||
|
<select value={invoice.client} onChange={(e) => setInvoice({ ...invoice, client: e.target.value })}>
|
||||||
|
<option value="">Select client...</option>
|
||||||
|
<option value="acme">Acme Corp</option>
|
||||||
|
<option value="tech">Tech Solutions</option>
|
||||||
|
<option value="design">Design Co</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Invoice #</label>
|
||||||
|
<input type="text" value={invoice.invoiceNumber} onChange={(e) => setInvoice({ ...invoice, invoiceNumber: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Date</label>
|
||||||
|
<input type="date" value={invoice.date} onChange={(e) => setInvoice({ ...invoice, date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Due Date</label>
|
||||||
|
<input type="date" value={invoice.dueDate} onChange={(e) => setInvoice({ ...invoice, dueDate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="line-items">
|
||||||
|
<label>Line Items</label>
|
||||||
|
{invoice.lines.map((line, index) => (
|
||||||
|
<div key={index} className="line-item">
|
||||||
|
<input type="text" placeholder="Description" value={line.description} onChange={(e) => updateLine(index, 'description', e.target.value)} />
|
||||||
|
<input type="number" placeholder="Qty" value={line.qty} onChange={(e) => updateLine(index, 'qty', parseFloat(e.target.value))} />
|
||||||
|
<input type="number" placeholder="Rate" value={line.rate} onChange={(e) => updateLine(index, 'rate', parseFloat(e.target.value))} />
|
||||||
|
<div style={{ color: '#94a3b8', paddingTop: '0.75rem' }}>${(line.qty * line.rate).toFixed(2)}</div>
|
||||||
|
{invoice.lines.length > 1 && (
|
||||||
|
<button className="btn btn-danger" onClick={() => removeLine(index)}>×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="btn btn-secondary" onClick={addLine}>+ Add Line</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea rows="3" value={invoice.notes} onChange={(e) => setInvoice({ ...invoice, notes: e.target.value })} placeholder="Payment terms, thank you note, etc." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="total-section">
|
||||||
|
<div className="total-row"><span>Subtotal:</span><span>${subtotal.toFixed(2)}</span></div>
|
||||||
|
<div className="total-row"><span>Tax (13%):</span><span>${tax.toFixed(2)}</span></div>
|
||||||
|
<div className="total-row final"><span>Total:</span><span>${total.toFixed(2)}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '2rem', display: 'flex', gap: '1rem' }}>
|
||||||
|
<button className="btn">Save Draft</button>
|
||||||
|
<button className="btn" style={{ background: '#10b981' }}>Send Invoice</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<InvoiceBuilder />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
154
servers/freshbooks/src/ui/react-app/invoice-dashboard/index.html
Normal file
154
servers/freshbooks/src/ui/react-app/invoice-dashboard/index.html
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Invoice Dashboard - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.header { margin-bottom: 2rem; }
|
||||||
|
.header h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
||||||
|
.stat-card { background: #1e293b; padding: 1.5rem; border-radius: 8px; border-left: 4px solid #3b82f6; }
|
||||||
|
.stat-card h3 { color: #94a3b8; font-size: 0.875rem; text-transform: uppercase; margin-bottom: 0.5rem; }
|
||||||
|
.stat-card .value { font-size: 2rem; font-weight: bold; color: #e2e8f0; }
|
||||||
|
.stat-card .label { color: #64748b; font-size: 0.875rem; margin-top: 0.25rem; }
|
||||||
|
.filters { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
|
||||||
|
.filter-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.filter-group label { font-size: 0.875rem; color: #94a3b8; }
|
||||||
|
.filter-group select, .filter-group input { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 0.5rem; border-radius: 4px; }
|
||||||
|
.table-container { background: #1e293b; border-radius: 8px; overflow: hidden; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { background: #0f172a; text-align: left; padding: 1rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; font-size: 0.75rem; }
|
||||||
|
td { padding: 1rem; border-top: 1px solid #334155; }
|
||||||
|
.status { padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.status-paid { background: #065f46; color: #6ee7b7; }
|
||||||
|
.status-partial { background: #78350f; color: #fbbf24; }
|
||||||
|
.status-overdue { background: #7f1d1d; color: #fca5a5; }
|
||||||
|
.status-draft { background: #1e3a8a; color: #93c5fd; }
|
||||||
|
.amount { font-weight: 600; }
|
||||||
|
.btn { background: #3b82f6; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.875rem; }
|
||||||
|
.btn:hover { background: #2563eb; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
|
||||||
|
function InvoiceDashboard() {
|
||||||
|
const [invoices, setInvoices] = useState([]);
|
||||||
|
const [stats, setStats] = useState({ total: 0, paid: 0, overdue: 0, draft: 0 });
|
||||||
|
const [filter, setFilter] = useState({ status: 'all', client: '' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Simulate data fetch
|
||||||
|
const sampleInvoices = [
|
||||||
|
{ id: 1, number: 'INV-001', client: 'Acme Corp', amount: 2500, status: 'paid', date: '2024-01-15', dueDate: '2024-02-15' },
|
||||||
|
{ id: 2, number: 'INV-002', client: 'Tech Solutions', amount: 4200, status: 'overdue', date: '2024-01-10', dueDate: '2024-02-10' },
|
||||||
|
{ id: 3, number: 'INV-003', client: 'Design Co', amount: 1800, status: 'partial', date: '2024-01-20', dueDate: '2024-02-20' },
|
||||||
|
{ id: 4, number: 'INV-004', client: 'Marketing Inc', amount: 3600, status: 'draft', date: '2024-01-25', dueDate: '2024-02-25' },
|
||||||
|
];
|
||||||
|
setInvoices(sampleInvoices);
|
||||||
|
setStats({
|
||||||
|
total: sampleInvoices.reduce((sum, inv) => sum + inv.amount, 0),
|
||||||
|
paid: sampleInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + inv.amount, 0),
|
||||||
|
overdue: sampleInvoices.filter(i => i.status === 'overdue').reduce((sum, inv) => sum + inv.amount, 0),
|
||||||
|
draft: sampleInvoices.filter(i => i.status === 'draft').length,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredInvoices = invoices.filter(inv => {
|
||||||
|
if (filter.status !== 'all' && inv.status !== filter.status) return false;
|
||||||
|
if (filter.client && !inv.client.toLowerCase().includes(filter.client.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="header">
|
||||||
|
<h1>Invoice Dashboard</h1>
|
||||||
|
<p style={{ color: '#94a3b8' }}>Manage and track all your invoices</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>Total Revenue</h3>
|
||||||
|
<div className="value">${stats.total.toLocaleString()}</div>
|
||||||
|
<div className="label">All invoices</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card" style={{ borderLeftColor: '#10b981' }}>
|
||||||
|
<h3>Paid</h3>
|
||||||
|
<div className="value">${stats.paid.toLocaleString()}</div>
|
||||||
|
<div className="label">Received</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card" style={{ borderLeftColor: '#ef4444' }}>
|
||||||
|
<h3>Overdue</h3>
|
||||||
|
<div className="value">${stats.overdue.toLocaleString()}</div>
|
||||||
|
<div className="label">Past due</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card" style={{ borderLeftColor: '#f59e0b' }}>
|
||||||
|
<h3>Drafts</h3>
|
||||||
|
<div className="value">{stats.draft}</div>
|
||||||
|
<div className="label">Pending</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filters">
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Status</label>
|
||||||
|
<select value={filter.status} onChange={(e) => setFilter({ ...filter, status: e.target.value })}>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
<option value="partial">Partial</option>
|
||||||
|
<option value="overdue">Overdue</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Client</label>
|
||||||
|
<input type="text" placeholder="Search client..." value={filter.client} onChange={(e) => setFilter({ ...filter, client: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Invoice #</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Due Date</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredInvoices.map(invoice => (
|
||||||
|
<tr key={invoice.id}>
|
||||||
|
<td>{invoice.number}</td>
|
||||||
|
<td>{invoice.client}</td>
|
||||||
|
<td>{invoice.date}</td>
|
||||||
|
<td>{invoice.dueDate}</td>
|
||||||
|
<td className="amount">${invoice.amount.toLocaleString()}</td>
|
||||||
|
<td><span className={`status status-${invoice.status}`}>{invoice.status}</span></td>
|
||||||
|
<td><button className="btn">View</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<InvoiceDashboard />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
116
servers/freshbooks/src/ui/react-app/invoice-detail/index.html
Normal file
116
servers/freshbooks/src/ui/react-app/invoice-detail/index.html
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Invoice Detail - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.invoice-header { background: #1e293b; padding: 2rem; border-radius: 8px; margin-bottom: 2rem; }
|
||||||
|
.invoice-header h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.status-badge { padding: 0.5rem 1rem; border-radius: 12px; display: inline-block; background: #10b981; color: white; }
|
||||||
|
.invoice-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1.5rem; }
|
||||||
|
.invoice-body { background: #1e293b; padding: 2rem; border-radius: 8px; }
|
||||||
|
table { width: 100%; margin-bottom: 2rem; }
|
||||||
|
th { text-align: left; padding: 0.75rem; background: #0f172a; color: #94a3b8; }
|
||||||
|
td { padding: 0.75rem; border-top: 1px solid #334155; }
|
||||||
|
.total-section { text-align: right; }
|
||||||
|
.total-row { display: flex; justify-content: flex-end; gap: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
.total-row.final { font-size: 1.5rem; font-weight: bold; padding-top: 1rem; border-top: 2px solid #334155; }
|
||||||
|
.actions { margin-top: 2rem; display: flex; gap: 1rem; }
|
||||||
|
.btn { padding: 0.75rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
|
||||||
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-secondary { background: #334155; color: white; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function InvoiceDetail() {
|
||||||
|
const invoice = {
|
||||||
|
number: 'INV-001',
|
||||||
|
client: 'Acme Corp',
|
||||||
|
date: '2024-01-15',
|
||||||
|
dueDate: '2024-02-15',
|
||||||
|
status: 'Paid',
|
||||||
|
lines: [
|
||||||
|
{ description: 'Website Design', qty: 1, rate: 2000, amount: 2000 },
|
||||||
|
{ description: 'Logo Design', qty: 1, rate: 500, amount: 500 },
|
||||||
|
],
|
||||||
|
subtotal: 2500,
|
||||||
|
tax: 325,
|
||||||
|
total: 2825,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="invoice-header">
|
||||||
|
<h1>{invoice.number}</h1>
|
||||||
|
<span className="status-badge">{invoice.status}</span>
|
||||||
|
<div className="invoice-meta">
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '0.875rem' }}>Client</div>
|
||||||
|
<div style={{ fontSize: '1.125rem', fontWeight: '500' }}>{invoice.client}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '0.875rem' }}>Amount</div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>${invoice.total}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '0.875rem' }}>Invoice Date</div>
|
||||||
|
<div>{invoice.date}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '0.875rem' }}>Due Date</div>
|
||||||
|
<div>{invoice.dueDate}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invoice-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Qty</th>
|
||||||
|
<th>Rate</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoice.lines.map((line, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{line.description}</td>
|
||||||
|
<td>{line.qty}</td>
|
||||||
|
<td>${line.rate}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>${line.amount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="total-section">
|
||||||
|
<div className="total-row"><span>Subtotal:</span><span>${invoice.subtotal}</span></div>
|
||||||
|
<div className="total-row"><span>Tax (13%):</span><span>${invoice.tax}</span></div>
|
||||||
|
<div className="total-row final"><span>Total:</span><span>${invoice.total}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn btn-primary">Send Invoice</button>
|
||||||
|
<button className="btn btn-secondary">Download PDF</button>
|
||||||
|
<button className="btn btn-secondary">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<InvoiceDetail />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
servers/freshbooks/src/ui/react-app/invoice-grid/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/invoice-grid/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Payment History - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 2rem 0; }
|
||||||
|
.stat-card { background: #1e293b; padding: 1.5rem; border-radius: 8px; text-align: center; }
|
||||||
|
.stat-card .value { font-size: 2rem; font-weight: bold; margin-bottom: 0.25rem; }
|
||||||
|
.stat-card .label { color: #94a3b8; font-size: 0.875rem; }
|
||||||
|
.payment-list { background: #1e293b; border-radius: 8px; overflow: hidden; }
|
||||||
|
.payment-item { padding: 1.5rem; border-bottom: 1px solid #334155; display: grid; grid-template-columns: 1fr 2fr 1fr 1fr 1fr; gap: 1rem; align-items: center; }
|
||||||
|
.payment-item:last-child { border-bottom: none; }
|
||||||
|
.payment-date { color: #94a3b8; }
|
||||||
|
.payment-invoice { font-weight: 500; }
|
||||||
|
.payment-client { color: #64748b; font-size: 0.875rem; }
|
||||||
|
.payment-method { padding: 0.25rem 0.75rem; background: #0f172a; border-radius: 4px; font-size: 0.875rem; text-align: center; }
|
||||||
|
.payment-amount { font-size: 1.25rem; font-weight: bold; text-align: right; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
function PaymentHistory() {
|
||||||
|
const [payments] = useState([
|
||||||
|
{ id: 1, date: '2024-01-25', invoice: 'INV-001', client: 'Acme Corp', method: 'Credit Card', amount: 2500 },
|
||||||
|
{ id: 2, date: '2024-01-23', invoice: 'INV-005', client: 'Tech Solutions', method: 'Bank Transfer', amount: 4200 },
|
||||||
|
{ id: 3, date: '2024-01-20', invoice: 'INV-003', client: 'Design Co', method: 'PayPal', amount: 1800 },
|
||||||
|
{ id: 4, date: '2024-01-18', invoice: 'INV-007', client: 'Marketing Inc', method: 'Check', amount: 3600 },
|
||||||
|
{ id: 5, date: '2024-01-15', invoice: 'INV-002', client: 'Acme Corp', method: 'Credit Card', amount: 5000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalReceived = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
const thisMonth = payments.filter(p => p.date.startsWith('2024-01')).reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>Payment History</h1>
|
||||||
|
<p style={{ color: '#94a3b8' }}>Track all received payments</p>
|
||||||
|
|
||||||
|
<div className="stats">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">${totalReceived.toLocaleString()}</div>
|
||||||
|
<div className="label">Total Received</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">${thisMonth.toLocaleString()}</div>
|
||||||
|
<div className="label">This Month</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">{payments.length}</div>
|
||||||
|
<div className="label">Payments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-list">
|
||||||
|
{payments.map(payment => (
|
||||||
|
<div key={payment.id} className="payment-item">
|
||||||
|
<div className="payment-date">{payment.date}</div>
|
||||||
|
<div>
|
||||||
|
<div className="payment-invoice">{payment.invoice}</div>
|
||||||
|
<div className="payment-client">{payment.client}</div>
|
||||||
|
</div>
|
||||||
|
<div className="payment-method">{payment.method}</div>
|
||||||
|
<div className="payment-amount">${payment.amount.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<PaymentHistory />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
servers/freshbooks/src/ui/react-app/profit-loss/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/profit-loss/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Projects - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
||||||
|
h1 { font-size: 2rem; }
|
||||||
|
.btn { background: #3b82f6; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; }
|
||||||
|
.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; }
|
||||||
|
.project-card { background: #1e293b; padding: 1.5rem; border-radius: 8px; border-top: 4px solid #3b82f6; }
|
||||||
|
.project-card.hourly { border-top-color: #10b981; }
|
||||||
|
.project-card.fixed { border-top-color: #f59e0b; }
|
||||||
|
.project-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem; }
|
||||||
|
.project-title { font-size: 1.25rem; font-weight: 600; }
|
||||||
|
.project-status { padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.status-active { background: #065f46; color: #6ee7b7; }
|
||||||
|
.status-complete { background: #1e40af; color: #93c5fd; }
|
||||||
|
.client-name { color: #94a3b8; font-size: 0.875rem; margin-bottom: 1rem; }
|
||||||
|
.progress-bar { background: #0f172a; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 1rem; }
|
||||||
|
.progress-fill { background: linear-gradient(90deg, #3b82f6, #10b981); height: 100%; }
|
||||||
|
.project-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
.stat { text-align: center; }
|
||||||
|
.stat .value { font-size: 1.5rem; font-weight: bold; }
|
||||||
|
.stat .label { color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
function ProjectDashboard() {
|
||||||
|
const [projects] = useState([
|
||||||
|
{ id: 1, title: 'Website Redesign', client: 'Acme Corp', type: 'hourly', status: 'active', progress: 65, hours: 45, budget: 8000 },
|
||||||
|
{ id: 2, title: 'Mobile App', client: 'Tech Solutions', type: 'fixed', status: 'active', progress: 30, hours: 22, budget: 15000 },
|
||||||
|
{ id: 3, title: 'Brand Identity', client: 'Design Co', type: 'hourly', status: 'complete', progress: 100, hours: 60, budget: 12000 },
|
||||||
|
{ id: 4, title: 'E-commerce Store', client: 'Marketing Inc', type: 'fixed', status: 'active', progress: 50, hours: 35, budget: 20000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="header">
|
||||||
|
<div>
|
||||||
|
<h1>Projects</h1>
|
||||||
|
<p style={{ color: '#94a3b8' }}>{projects.filter(p => p.status === 'active').length} active projects</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn">+ New Project</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="project-grid">
|
||||||
|
{projects.map(project => (
|
||||||
|
<div key={project.id} className={`project-card ${project.type}`}>
|
||||||
|
<div className="project-header">
|
||||||
|
<div className="project-title">{project.title}</div>
|
||||||
|
<span className={`project-status status-${project.status}`}>{project.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="client-name">{project.client}</div>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div className="progress-fill" style={{ width: `${project.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '1rem', color: '#94a3b8', fontSize: '0.875rem' }}>
|
||||||
|
{project.progress}% complete
|
||||||
|
</div>
|
||||||
|
<div className="project-stats">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="value">{project.hours}h</div>
|
||||||
|
<div className="label">Hours Tracked</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<div className="value">${(project.budget / 1000).toFixed(0)}k</div>
|
||||||
|
<div className="label">{project.type === 'fixed' ? 'Fixed Price' : 'Budget'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<ProjectDashboard />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reports - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
.report-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; margin-top: 2rem; }
|
||||||
|
.report-card { background: #1e293b; padding: 2rem; border-radius: 8px; cursor: pointer; transition: transform 0.2s; }
|
||||||
|
.report-card:hover { transform: translateY(-4px); background: #334155; }
|
||||||
|
.report-icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
||||||
|
.report-title { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
.report-description { color: #94a3b8; font-size: 0.875rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function ReportsDashboard() {
|
||||||
|
const reports = [
|
||||||
|
{ icon: '📊', title: 'Profit & Loss', description: 'View income and expenses over time' },
|
||||||
|
{ icon: '💰', title: 'Revenue by Client', description: 'See which clients generate the most revenue' },
|
||||||
|
{ icon: '📈', title: 'Tax Summary', description: 'Summary of taxes collected and paid' },
|
||||||
|
{ icon: '⏰', title: 'Accounts Aging', description: 'Outstanding invoices by age' },
|
||||||
|
{ icon: '💸', title: 'Expense Report', description: 'Breakdown of all business expenses' },
|
||||||
|
{ icon: '🎯', title: 'Time Summary', description: 'Billable vs non-billable hours' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>Reports</h1>
|
||||||
|
<p style={{ color: '#94a3b8' }}>Financial insights and business analytics</p>
|
||||||
|
|
||||||
|
<div className="report-grid">
|
||||||
|
{reports.map((report, index) => (
|
||||||
|
<div key={index} className="report-card">
|
||||||
|
<div className="report-icon">{report.icon}</div>
|
||||||
|
<div className="report-title">{report.title}</div>
|
||||||
|
<div className="report-description">{report.description}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<ReportsDashboard />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
servers/freshbooks/src/ui/react-app/revenue-chart/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/revenue-chart/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
servers/freshbooks/src/ui/react-app/tax-summary/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/tax-summary/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
servers/freshbooks/src/ui/react-app/time-entries/index.html
Normal file
34
servers/freshbooks/src/ui/react-app/time-entries/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TITLE - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: #1e293b; padding: 2rem; border-radius: 8px; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>TITLE</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: '#94a3b8' }}>MCP App Component</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
122
servers/freshbooks/src/ui/react-app/time-tracker/index.html
Normal file
122
servers/freshbooks/src/ui/react-app/time-tracker/index.html
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Time Tracker - FreshBooks</title>
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||||
|
.container { max-width: 1000px; margin: 0 auto; padding: 2rem; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
.timer-card { background: #1e293b; padding: 3rem; border-radius: 8px; text-align: center; margin: 2rem 0; }
|
||||||
|
.timer-display { font-size: 4rem; font-weight: bold; margin-bottom: 2rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.timer-controls { display: flex; gap: 1rem; justify-content: center; }
|
||||||
|
.btn { background: #3b82f6; color: white; border: none; padding: 1rem 2rem; border-radius: 4px; cursor: pointer; font-size: 1rem; font-weight: 500; }
|
||||||
|
.btn:hover { background: #2563eb; }
|
||||||
|
.btn-start { background: #10b981; }
|
||||||
|
.btn-start:hover { background: #059669; }
|
||||||
|
.btn-stop { background: #ef4444; }
|
||||||
|
.btn-stop:hover { background: #dc2626; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 0.5rem; text-align: left; }
|
||||||
|
label { font-size: 0.875rem; color: #94a3b8; }
|
||||||
|
input, select { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; padding: 0.75rem; border-radius: 4px; }
|
||||||
|
.entries-list { margin-top: 2rem; }
|
||||||
|
.entry-item { background: #1e293b; padding: 1rem; border-radius: 8px; margin-bottom: 0.75rem; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.entry-info .project { color: #64748b; font-size: 0.875rem; margin-top: 0.25rem; }
|
||||||
|
.entry-duration { font-size: 1.25rem; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
|
||||||
|
function TimeTracker() {
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [seconds, setSeconds] = useState(0);
|
||||||
|
const [task, setTask] = useState({ description: '', project: '', client: '' });
|
||||||
|
const [entries, setEntries] = useState([
|
||||||
|
{ id: 1, description: 'Website design', project: 'Acme Redesign', client: 'Acme Corp', duration: 7200 },
|
||||||
|
{ id: 2, description: 'Bug fixes', project: 'Tech App', client: 'Tech Solutions', duration: 3600 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval;
|
||||||
|
if (isRunning) {
|
||||||
|
interval = setInterval(() => setSeconds(s => s + 1), 1000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isRunning]);
|
||||||
|
|
||||||
|
const formatTime = (secs) => {
|
||||||
|
const h = Math.floor(secs / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
const s = secs % 60;
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTimer = () => setIsRunning(true);
|
||||||
|
const stopTimer = () => {
|
||||||
|
if (seconds > 0 && task.description) {
|
||||||
|
setEntries([{ ...task, id: Date.now(), duration: seconds }, ...entries]);
|
||||||
|
}
|
||||||
|
setIsRunning(false);
|
||||||
|
setSeconds(0);
|
||||||
|
setTask({ description: '', project: '', client: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>Time Tracker</h1>
|
||||||
|
<p style={{ color: '#94a3b8', marginBottom: '2rem' }}>Track billable hours and project time</p>
|
||||||
|
|
||||||
|
<div className="timer-card">
|
||||||
|
<div className="timer-display">{formatTime(seconds)}</div>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Task Description</label>
|
||||||
|
<input type="text" value={task.description} onChange={(e) => setTask({ ...task, description: e.target.value })} placeholder="What are you working on?" disabled={isRunning} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Project</label>
|
||||||
|
<select value={task.project} onChange={(e) => setTask({ ...task, project: e.target.value })} disabled={isRunning}>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
<option value="Acme Redesign">Acme Redesign</option>
|
||||||
|
<option value="Tech App">Tech App</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="timer-controls">
|
||||||
|
{!isRunning ? (
|
||||||
|
<button className="btn btn-start" onClick={startTimer}>Start Timer</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-stop" onClick={stopTimer}>Stop & Save</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="entries-list">
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Recent Entries</h3>
|
||||||
|
{entries.map(entry => (
|
||||||
|
<div key={entry.id} className="entry-item">
|
||||||
|
<div className="entry-info">
|
||||||
|
<div>{entry.description}</div>
|
||||||
|
<div className="project">{entry.project} • {entry.client}</div>
|
||||||
|
</div>
|
||||||
|
<div className="entry-duration">{formatTime(entry.duration)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<TimeTracker />, document.getElementById('root'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,14 +1,20 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "ES2022",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"declaration": true
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user