acuity: Complete MCP server with 59 tools and 12 React apps

This commit is contained in:
Jake Shore 2026-02-12 17:50:57 -05:00
parent 63a1ca0df6
commit 6578e8ff04
75 changed files with 3742 additions and 763 deletions

View File

@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@ -0,0 +1,121 @@
import React, { useState } from 'react';
import './styles.css';
type Step = 'service' | 'calendar' | 'datetime' | 'info' | 'confirm';
export default function BookingFlow() {
const [step, setStep] = useState<Step>('service');
const [booking, setBooking] = useState({ service: '', calendar: '', datetime: '', firstName: '', lastName: '', email: '', phone: '' });
const services = ['Initial Consultation', 'Follow-up Session', 'Assessment', 'Workshop'];
const calendars = ['Main Calendar', 'Secondary Calendar'];
const slots = ['9:00 AM', '10:00 AM', '11:00 AM', '2:00 PM', '3:00 PM', '4:00 PM'];
const handleNext = () => {
const steps: Step[] = ['service', 'calendar', 'datetime', 'info', 'confirm'];
const currentIndex = steps.indexOf(step);
if (currentIndex < steps.length - 1) setStep(steps[currentIndex + 1]);
};
const handleBack = () => {
const steps: Step[] = ['service', 'calendar', 'datetime', 'info', 'confirm'];
const currentIndex = steps.indexOf(step);
if (currentIndex > 0) setStep(steps[currentIndex - 1]);
};
const handleSubmit = () => {
console.log('Booking submitted:', booking);
alert('Appointment booked successfully!');
};
return (
<div className="booking-flow">
<header>
<h1>📅 Book an Appointment</h1>
</header>
<div className="progress-bar">
<div className={`step ${step === 'service' ? 'active' : ''}`}>Service</div>
<div className={`step ${step === 'calendar' ? 'active' : ''}`}>Calendar</div>
<div className={`step ${step === 'datetime' ? 'active' : ''}`}>Date & Time</div>
<div className={`step ${step === 'info' ? 'active' : ''}`}>Your Info</div>
<div className={`step ${step === 'confirm' ? 'active' : ''}`}>Confirm</div>
</div>
<div className="booking-card">
{step === 'service' && (
<div className="step-content">
<h2>Select a Service</h2>
<div className="options-grid">
{services.map(service => (
<div key={service} className={`option ${booking.service === service ? 'selected' : ''}`} onClick={() => setBooking({...booking, service})}>
<span></span>
<strong>{service}</strong>
</div>
))}
</div>
</div>
)}
{step === 'calendar' && (
<div className="step-content">
<h2>Select a Calendar</h2>
<div className="options-grid">
{calendars.map(calendar => (
<div key={calendar} className={`option ${booking.calendar === calendar ? 'selected' : ''}`} onClick={() => setBooking({...booking, calendar})}>
<span></span>
<strong>{calendar}</strong>
</div>
))}
</div>
</div>
)}
{step === 'datetime' && (
<div className="step-content">
<h2>Select Date & Time</h2>
<input type="date" className="date-input" onChange={(e) => setBooking({...booking, datetime: e.target.value})} />
<div className="options-grid">
{slots.map(slot => (
<div key={slot} className="option time-slot" onClick={() => setBooking({...booking, datetime: booking.datetime + ' ' + slot})}>
<strong>{slot}</strong>
</div>
))}
</div>
</div>
)}
{step === 'info' && (
<div className="step-content">
<h2>Your Information</h2>
<div className="form">
<input type="text" placeholder="First Name" value={booking.firstName} onChange={(e) => setBooking({...booking, firstName: e.target.value})} />
<input type="text" placeholder="Last Name" value={booking.lastName} onChange={(e) => setBooking({...booking, lastName: e.target.value})} />
<input type="email" placeholder="Email" value={booking.email} onChange={(e) => setBooking({...booking, email: e.target.value})} />
<input type="tel" placeholder="Phone" value={booking.phone} onChange={(e) => setBooking({...booking, phone: e.target.value})} />
</div>
</div>
)}
{step === 'confirm' && (
<div className="step-content">
<h2>Confirm Booking</h2>
<div className="confirmation">
<div className="confirm-item"><strong>Service:</strong><span>{booking.service}</span></div>
<div className="confirm-item"><strong>Calendar:</strong><span>{booking.calendar}</span></div>
<div className="confirm-item"><strong>Date & Time:</strong><span>{booking.datetime}</span></div>
<div className="confirm-item"><strong>Name:</strong><span>{booking.firstName} {booking.lastName}</span></div>
<div className="confirm-item"><strong>Email:</strong><span>{booking.email}</span></div>
<div className="confirm-item"><strong>Phone:</strong><span>{booking.phone}</span></div>
</div>
</div>
)}
<div className="navigation">
{step !== 'service' && <button onClick={handleBack}> Back</button>}
{step !== 'confirm' ? <button onClick={handleNext}>Next </button> : <button onClick={handleSubmit}> Book Appointment</button>}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Booking Flow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

View File

@ -0,0 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -0,0 +1 @@
{"name":"booking-flow","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18.2.0","react-dom":"^18.2.0"},"devDependencies":{"@types/react":"^18.2.0","@types/react-dom":"^18.2.0","@vitejs/plugin-react":"^4.2.0","typescript":"^5.3.0","vite":"^5.0.0"}}

View File

@ -0,0 +1,26 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }
.booking-flow { max-width: 800px; margin: 0 auto; padding: 2rem; }
header { text-align: center; margin-bottom: 2rem; }
h1 { font-size: 2rem; font-weight: 700; color: #f1f5f9; }
.progress-bar { display: flex; justify-content: space-between; margin-bottom: 2rem; }
.progress-bar .step { flex: 1; text-align: center; padding: 0.75rem; background: #1e293b; color: #64748b; font-weight: 600; font-size: 0.875rem; border-bottom: 3px solid #1e293b; }
.progress-bar .step.active { color: #3b82f6; border-bottom-color: #3b82f6; }
.booking-card { background: #1e293b; padding: 2rem; border-radius: 0.75rem; border: 1px solid #334155; }
.step-content h2 { color: #f1f5f9; margin-bottom: 1.5rem; }
.options-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
.option { background: #0f172a; padding: 1.5rem; border-radius: 0.5rem; border: 2px solid #334155; cursor: pointer; text-align: center; transition: all 0.2s; }
.option:hover { border-color: #3b82f6; }
.option.selected { border-color: #3b82f6; background: #3b82f620; }
.option span { display: none; }
.option.selected span { display: inline; color: #3b82f6; margin-right: 0.5rem; }
.date-input { width: 100%; padding: 1rem; background: #0f172a; border: 1px solid #334155; border-radius: 0.5rem; color: #e2e8f0; margin-bottom: 1rem; }
.form { display: flex; flex-direction: column; gap: 1rem; }
.form input { padding: 1rem; background: #0f172a; border: 1px solid #334155; border-radius: 0.5rem; color: #e2e8f0; }
.confirmation { display: flex; flex-direction: column; gap: 1rem; }
.confirm-item { display: flex; justify-content: space-between; padding: 1rem; background: #0f172a; border-radius: 0.5rem; }
.confirm-item strong { color: #94a3b8; }
.confirm-item span { color: #f1f5f9; }
.navigation { display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem; }
button { flex: 1; padding: 1rem; background: #3b82f6; color: white; border: none; border-radius: 0.5rem; cursor: pointer; font-weight: 600; }
button:hover { background: #2563eb; }

View File

@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { port: 3011 }, build: { outDir: 'dist' } });

View File

@ -1,4 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -1,4 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -1,4 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -0,0 +1,49 @@
import React, { useState, useEffect } from 'react';
import './styles.css';
interface Coupon { id: number; code: string; name: string; discount: number; type: 'percent' | 'fixed'; active: boolean; uses: number; maxUses: number; expiresAt: string; }
export default function CouponManager() {
const [coupons, setCoupons] = useState<Coupon[]>([]);
useEffect(() => {
setCoupons([
{ id: 1, code: 'WELCOME10', name: 'Welcome Discount', discount: 10, type: 'percent', active: true, uses: 23, maxUses: 100, expiresAt: '2024-12-31' },
{ id: 2, code: 'SAVE25', name: 'Fixed $25 Off', discount: 25, type: 'fixed', active: true, uses: 12, maxUses: 50, expiresAt: '2024-06-30' },
{ id: 3, code: 'EXPIRED20', name: 'Old Promo', discount: 20, type: 'percent', active: false, uses: 45, maxUses: 100, expiresAt: '2024-01-31' }
]);
}, []);
return (
<div className="coupon-manager">
<header>
<h1>🎟 Coupon Manager</h1>
<button> Create Coupon</button>
</header>
<div className="coupons-grid">
{coupons.map(coupon => (
<div key={coupon.id} className="coupon-card">
<div className="coupon-header">
<h3>{coupon.code}</h3>
<span className={`status ${coupon.active ? 'active' : 'inactive'}`}>
{coupon.active ? '● Active' : '○ Inactive'}
</span>
</div>
<p className="coupon-name">{coupon.name}</p>
<div className="coupon-discount">
{coupon.type === 'percent' ? `${coupon.discount}%` : `$${coupon.discount}`} OFF
</div>
<div className="coupon-stats">
<div className="stat"><span>Uses:</span><span>{coupon.uses} / {coupon.maxUses}</span></div>
<div className="stat"><span>Expires:</span><span>{new Date(coupon.expiresAt).toLocaleDateString()}</span></div>
</div>
<div className="coupon-actions">
<button> Edit</button>
<button className="danger">🗑 Delete</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Coupon Manager</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

View File

@ -0,0 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -0,0 +1 @@
{"name":"coupon-manager","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18.2.0","react-dom":"^18.2.0"},"devDependencies":{"@types/react":"^18.2.0","@types/react-dom":"^18.2.0","@vitejs/plugin-react":"^4.2.0","typescript":"^5.3.0","vite":"^5.0.0"}}

View File

@ -0,0 +1,21 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }
.coupon-manager { max-width: 1200px; margin: 0 auto; padding: 2rem; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
h1 { font-size: 2rem; font-weight: 700; color: #f1f5f9; }
button { padding: 0.5rem 1rem; background: #3b82f6; color: white; border: none; border-radius: 0.5rem; cursor: pointer; }
button.danger { background: #ef4444; }
.coupons-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.5rem; }
.coupon-card { background: #1e293b; padding: 1.5rem; border-radius: 0.75rem; border: 1px solid #334155; }
.coupon-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
.coupon-header h3 { color: #f1f5f9; font-family: 'Courier New', monospace; background: #0f172a; padding: 0.25rem 0.5rem; border-radius: 0.25rem; }
.status { font-size: 0.75rem; font-weight: 600; }
.status.active { color: #10b981; }
.status.inactive { color: #64748b; }
.coupon-name { color: #94a3b8; font-size: 0.875rem; margin-bottom: 1rem; }
.coupon-discount { font-size: 2rem; font-weight: 700; color: #3b82f6; margin-bottom: 1rem; text-align: center; }
.coupon-stats { margin-bottom: 1rem; }
.stat { display: flex; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid #334155; }
.stat span:first-child { color: #94a3b8; }
.stat span:last-child { color: #e2e8f0; font-weight: 600; }
.coupon-actions { display: flex; gap: 0.5rem; }

View File

@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { port: 3010 }, build: { outDir: 'dist' } });

View File

@ -0,0 +1,61 @@
import React, { useState, useEffect } from 'react';
import './styles.css';
interface FormResponse { id: number; appointmentId: number; client: string; submittedDate: string; fields: Array<{ name: string; value: string }>; }
export default function FormResponses() {
const [responses, setResponses] = useState<FormResponse[]>([]);
const [selectedResponse, setSelectedResponse] = useState<FormResponse | null>(null);
useEffect(() => {
setResponses([
{ id: 1, appointmentId: 101, client: 'John Doe', submittedDate: '2024-02-10T09:30:00', fields: [
{ name: 'How did you hear about us?', value: 'Google search' },
{ name: 'Reason for visit', value: 'Initial consultation' },
{ name: 'Any allergies?', value: 'None' }
]},
{ id: 2, appointmentId: 102, client: 'Jane Smith', submittedDate: '2024-02-11T14:00:00', fields: [
{ name: 'How did you hear about us?', value: 'Referral from friend' },
{ name: 'Reason for visit', value: 'Follow-up' },
{ name: 'Any allergies?', value: 'Penicillin' }
]}
]);
}, []);
return (
<div className="form-responses">
<header>
<h1>📋 Form Responses</h1>
</header>
<div className="responses-layout">
<div className="responses-list">
{responses.map(response => (
<div key={response.id} className="response-item" onClick={() => setSelectedResponse(response)}>
<h3>{response.client}</h3>
<p>Appointment #{response.appointmentId}</p>
<span className="date">{new Date(response.submittedDate).toLocaleDateString()}</span>
</div>
))}
</div>
<div className="response-detail">
{selectedResponse ? (
<>
<h2>{selectedResponse.client}</h2>
<p className="meta">Submitted: {new Date(selectedResponse.submittedDate).toLocaleString()}</p>
<div className="fields-list">
{selectedResponse.fields.map((field, i) => (
<div key={i} className="field-item">
<strong>{field.name}</strong>
<p>{field.value}</p>
</div>
))}
</div>
</>
) : (
<div className="empty-state">Select a response to view details</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Form Responses</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

View File

@ -0,0 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -0,0 +1 @@
{"name":"form-responses","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18.2.0","react-dom":"^18.2.0"},"devDependencies":{"@types/react":"^18.2.0","@types/react-dom":"^18.2.0","@vitejs/plugin-react":"^4.2.0","typescript":"^5.3.0","vite":"^5.0.0"}}

View File

@ -0,0 +1,19 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }
.form-responses { max-width: 1400px; margin: 0 auto; padding: 2rem; }
header { margin-bottom: 2rem; }
h1 { font-size: 2rem; font-weight: 700; color: #f1f5f9; }
.responses-layout { display: grid; grid-template-columns: 350px 1fr; gap: 1.5rem; }
.responses-list { background: #1e293b; border-radius: 0.75rem; padding: 1rem; border: 1px solid #334155; max-height: 80vh; overflow-y: auto; }
.response-item { padding: 1rem; background: #0f172a; border-radius: 0.5rem; margin-bottom: 0.75rem; cursor: pointer; border: 1px solid #334155; }
.response-item:hover { border-color: #3b82f6; }
.response-item h3 { color: #f1f5f9; margin-bottom: 0.25rem; }
.response-item p { color: #94a3b8; font-size: 0.875rem; }
.response-item .date { color: #64748b; font-size: 0.75rem; }
.response-detail { background: #1e293b; border-radius: 0.75rem; padding: 2rem; border: 1px solid #334155; }
.response-detail h2 { color: #f1f5f9; margin-bottom: 0.5rem; }
.meta { color: #94a3b8; margin-bottom: 2rem; }
.fields-list { display: flex; flex-direction: column; gap: 1.5rem; }
.field-item strong { display: block; color: #f1f5f9; margin-bottom: 0.5rem; }
.field-item p { color: #cbd5e1; }
.empty-state { display: flex; align-items: center; justify-content: center; height: 400px; color: #64748b; }

View File

@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { port: 3008 }, build: { outDir: 'dist' } });

View File

@ -0,0 +1,57 @@
import React, { useState, useEffect } from 'react';
import './styles.css';
interface Label { id: number; name: string; color: string; count: number; }
export default function LabelManager() {
const [labels, setLabels] = useState<Label[]>([]);
const [newLabel, setNewLabel] = useState({ name: '', color: '#3b82f6' });
useEffect(() => {
setLabels([
{ id: 1, name: 'VIP', color: '#fbbf24', count: 12 },
{ id: 2, name: 'New Client', color: '#3b82f6', count: 28 },
{ id: 3, name: 'Urgent', color: '#ef4444', count: 5 },
{ id: 4, name: 'Follow-up Required', color: '#f59e0b', count: 15 }
]);
}, []);
const handleCreate = () => {
if (!newLabel.name) return;
setLabels([...labels, { ...newLabel, id: Date.now(), count: 0 }]);
setNewLabel({ name: '', color: '#3b82f6' });
};
return (
<div className="label-manager">
<header>
<h1>🏷 Label Manager</h1>
</header>
<div className="create-section">
<h2>Create New Label</h2>
<div className="create-form">
<input type="text" placeholder="Label name" value={newLabel.name} onChange={(e) => setNewLabel({...newLabel, name: e.target.value})} />
<input type="color" value={newLabel.color} onChange={(e) => setNewLabel({...newLabel, color: e.target.value})} />
<button onClick={handleCreate}> Create Label</button>
</div>
</div>
<div className="labels-grid">
{labels.map(label => (
<div key={label.id} className="label-card">
<div className="label-preview" style={{ backgroundColor: label.color + '20', borderColor: label.color }}>
<span style={{ color: label.color }}></span>
<span style={{ color: label.color }}>{label.name}</span>
</div>
<div className="label-info">
<span className="usage">{label.count} appointments</span>
<div className="label-actions">
<button> Edit</button>
<button className="danger">🗑 Delete</button>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Label Manager</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

View File

@ -0,0 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -0,0 +1 @@
{"name":"label-manager","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18.2.0","react-dom":"^18.2.0"},"devDependencies":{"@types/react":"^18.2.0","@types/react-dom":"^18.2.0","@vitejs/plugin-react":"^4.2.0","typescript":"^5.3.0","vite":"^5.0.0"}}

View File

@ -0,0 +1,18 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }
.label-manager { max-width: 1200px; margin: 0 auto; padding: 2rem; }
header { margin-bottom: 2rem; }
h1 { font-size: 2rem; font-weight: 700; color: #f1f5f9; }
.create-section { background: #1e293b; padding: 1.5rem; border-radius: 0.75rem; border: 1px solid #334155; margin-bottom: 2rem; }
.create-section h2 { color: #f1f5f9; margin-bottom: 1rem; }
.create-form { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.create-form input[type="text"] { flex: 1; min-width: 200px; padding: 0.75rem; background: #0f172a; border: 1px solid #334155; border-radius: 0.5rem; color: #e2e8f0; }
.create-form input[type="color"] { width: 60px; height: 42px; border: 1px solid #334155; border-radius: 0.5rem; cursor: pointer; }
button { padding: 0.5rem 1rem; background: #3b82f6; color: white; border: none; border-radius: 0.5rem; cursor: pointer; }
button.danger { background: #ef4444; }
.labels-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; }
.label-card { background: #1e293b; padding: 1.5rem; border-radius: 0.75rem; border: 1px solid #334155; }
.label-preview { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; border-radius: 0.5rem; border: 2px solid; margin-bottom: 1rem; font-weight: 600; }
.label-info { display: flex; justify-content: space-between; align-items: center; }
.usage { color: #94a3b8; font-size: 0.875rem; }
.label-actions { display: flex; gap: 0.5rem; }

View File

@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { port: 3009 }, build: { outDir: 'dist' } });

View File

@ -0,0 +1,57 @@
import React, { useState, useEffect } from 'react';
import './styles.css';
interface Product { id: number; name: string; description: string; price: number; category: string; active: boolean; }
export default function ProductCatalog() {
const [products, setProducts] = useState<Product[]>([]);
const [filter, setFilter] = useState('all');
useEffect(() => {
setProducts([
{ id: 1, name: 'Initial Consultation', description: '60-minute first visit', price: 150, category: 'Consultation', active: true },
{ id: 2, name: 'Follow-up Session', description: '30-minute check-in', price: 75, category: 'Session', active: true },
{ id: 3, name: 'Package (5 sessions)', description: 'Save 10% with package', price: 337.50, category: 'Package', active: true },
{ id: 4, name: 'Workshop', description: 'Group workshop session', price: 50, category: 'Workshop', active: false }
]);
}, []);
const filtered = filter === 'all' ? products : products.filter(p => p.category === filter);
return (
<div className="product-catalog">
<header>
<h1>🛍 Product Catalog</h1>
<button> Add Product</button>
</header>
<div className="filter-bar">
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button>
<button className={filter === 'Consultation' ? 'active' : ''} onClick={() => setFilter('Consultation')}>Consultation</button>
<button className={filter === 'Session' ? 'active' : ''} onClick={() => setFilter('Session')}>Session</button>
<button className={filter === 'Package' ? 'active' : ''} onClick={() => setFilter('Package')}>Package</button>
<button className={filter === 'Workshop' ? 'active' : ''} onClick={() => setFilter('Workshop')}>Workshop</button>
</div>
<div className="products-grid">
{filtered.map(product => (
<div key={product.id} className="product-card">
<div className="product-header">
<h3>{product.name}</h3>
<span className={`status ${product.active ? 'active' : 'inactive'}`}>
{product.active ? '● Active' : '○ Inactive'}
</span>
</div>
<p className="description">{product.description}</p>
<div className="product-meta">
<span className="category">{product.category}</span>
<span className="price">${product.price.toFixed(2)}</span>
</div>
<div className="product-actions">
<button> Edit</button>
<button className="danger">🗑 Delete</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Product Catalog</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

View File

@ -0,0 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -0,0 +1 @@
{"name":"product-catalog","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18.2.0","react-dom":"^18.2.0"},"devDependencies":{"@types/react":"^18.2.0","@types/react-dom":"^18.2.0","@vitejs/plugin-react":"^4.2.0","typescript":"^5.3.0","vite":"^5.0.0"}}

View File

@ -0,0 +1,22 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }
.product-catalog { max-width: 1200px; margin: 0 auto; padding: 2rem; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
h1 { font-size: 2rem; font-weight: 700; color: #f1f5f9; }
button { padding: 0.5rem 1rem; background: #3b82f6; color: white; border: none; border-radius: 0.5rem; cursor: pointer; }
button.danger { background: #ef4444; }
.filter-bar { display: flex; gap: 0.5rem; margin-bottom: 2rem; flex-wrap: wrap; }
.filter-bar button { background: #1e293b; color: #94a3b8; }
.filter-bar button.active { background: #3b82f6; color: white; }
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.5rem; }
.product-card { background: #1e293b; padding: 1.5rem; border-radius: 0.75rem; border: 1px solid #334155; }
.product-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.product-header h3 { color: #f1f5f9; }
.status { font-size: 0.75rem; font-weight: 600; }
.status.active { color: #10b981; }
.status.inactive { color: #64748b; }
.description { color: #94a3b8; font-size: 0.875rem; margin-bottom: 1rem; }
.product-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.category { background: #334155; color: #e2e8f0; padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.75rem; }
.price { font-size: 1.5rem; font-weight: 700; color: #3b82f6; }
.product-actions { display: flex; gap: 0.5rem; }

View File

@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { port: 3007 }, build: { outDir: 'dist' } });

View File

@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import './styles.css';
interface ScheduleEntry { id: number; time: string; client: string; type: string; calendar: string; status: 'confirmed' | 'pending'; }
export default function ScheduleOverview() {
const [schedules, setSchedules] = useState<ScheduleEntry[]>([]);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [view, setView] = useState<'day' | 'week'>('day');
useEffect(() => {
setSchedules([
{ id: 1, time: '09:00', client: 'John Doe', type: 'Consultation', calendar: 'Main', status: 'confirmed' },
{ id: 2, time: '10:30', client: 'Jane Smith', type: 'Follow-up', calendar: 'Main', status: 'confirmed' },
{ id: 3, time: '14:00', client: 'Bob Johnson', type: 'Assessment', calendar: 'Secondary', status: 'pending' },
{ id: 4, time: '15:30', client: 'Alice Williams', type: 'Check-in', calendar: 'Main', status: 'confirmed' }
]);
}, [selectedDate]);
return (
<div className="schedule-overview">
<header>
<h1>📆 Schedule Overview</h1>
<div className="view-toggle">
<button className={view === 'day' ? 'active' : ''} onClick={() => setView('day')}>Day</button>
<button className={view === 'week' ? 'active' : ''} onClick={() => setView('week')}>Week</button>
</div>
</header>
<div className="date-controls">
<button> Previous</button>
<input type="date" value={selectedDate} onChange={(e) => setSelectedDate(e.target.value)} />
<button>Next </button>
</div>
<div className="schedule-timeline">
{Array.from({ length: 12 }, (_, i) => {
const hour = 8 + i;
const timeSlot = `${hour}:00`;
const appointment = schedules.find(s => s.time === timeSlot || s.time === `${hour}:30`);
return (
<div key={i} className="time-block">
<div className="time-label">{timeSlot}</div>
<div className="time-content">
{appointment ? (
<div className={`appointment-block ${appointment.status}`}>
<strong>{appointment.client}</strong>
<span>{appointment.type}</span>
<span className="calendar-badge">{appointment.calendar}</span>
</div>
) : (
<div className="empty-slot">Available</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Schedule Overview</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

View File

@ -0,0 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);

View File

@ -0,0 +1 @@
{"name":"schedule-overview","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18.2.0","react-dom":"^18.2.0"},"devDependencies":{"@types/react":"^18.2.0","@types/react-dom":"^18.2.0","@vitejs/plugin-react":"^4.2.0","typescript":"^5.3.0","vite":"^5.0.0"}}

View File

@ -0,0 +1,22 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }
.schedule-overview { max-width: 1000px; margin: 0 auto; padding: 2rem; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
h1 { font-size: 2rem; font-weight: 700; color: #f1f5f9; }
.view-toggle { display: flex; gap: 0.5rem; }
.view-toggle button { padding: 0.5rem 1rem; background: #1e293b; color: #94a3b8; border: none; border-radius: 0.5rem; cursor: pointer; }
.view-toggle button.active { background: #3b82f6; color: white; }
.date-controls { display: flex; gap: 1rem; margin-bottom: 2rem; justify-content: center; }
.date-controls button { padding: 0.75rem 1.5rem; background: #1e293b; color: #e2e8f0; border: none; border-radius: 0.5rem; cursor: pointer; }
.date-controls input { padding: 0.75rem; background: #1e293b; border: 1px solid #334155; border-radius: 0.5rem; color: #e2e8f0; }
.schedule-timeline { background: #1e293b; border-radius: 0.75rem; border: 1px solid #334155; padding: 1rem; }
.time-block { display: flex; border-bottom: 1px solid #334155; padding: 1rem; }
.time-block:last-child { border-bottom: none; }
.time-label { width: 80px; color: #94a3b8; font-weight: 600; }
.time-content { flex: 1; }
.appointment-block { background: #0f172a; padding: 1rem; border-radius: 0.5rem; border-left: 4px solid #3b82f6; }
.appointment-block.pending { border-left-color: #f59e0b; }
.appointment-block strong { display: block; color: #f1f5f9; margin-bottom: 0.25rem; }
.appointment-block span { display: block; color: #94a3b8; font-size: 0.875rem; }
.calendar-badge { display: inline-block; background: #334155; color: #e2e8f0; padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; margin-top: 0.5rem; }
.empty-slot { color: #64748b; font-style: italic; }

View File

@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { port: 3012 }, build: { outDir: 'dist' } });

8
servers/acuity/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.DS_Store
.env
.env.local
*.tsbuildinfo
src/ui/react-app/src/apps/*/dist

199
servers/acuity/README.md Normal file
View File

@ -0,0 +1,199 @@
# Acuity Scheduling MCP Server
A comprehensive Model Context Protocol (MCP) server for Acuity Scheduling, providing full API integration with 59 tools and 12 interactive React applications.
## Features
### 🛠️ 59 MCP Tools
Complete CRUD operations across all Acuity Scheduling domains:
- **Appointments** (6 tools): List, get, create, update, cancel, reschedule
- **Calendars** (2 tools): List, get
- **Appointment Types** (5 tools): List, get, create, update, delete
- **Clients** (5 tools): List, get, create, update, delete
- **Availability** (2 tools): Get available dates and times
- **Blocks** (5 tools): List, get, create, update, delete (block out time)
- **Products** (5 tools): List, get, create, update, delete
- **Certificates** (4 tools): List, get, create, delete (gift certificates)
- **Coupons** (5 tools): List, get, create, update, delete
- **Forms** (5 tools): List, get, create, update, delete (intake forms)
- **Labels** (5 tools): List, get, create, update, delete
- **Packages** (5 tools): List, get, create, update, delete
- **Subscriptions** (5 tools): List, get, create, update, delete
### 🎨 12 React Applications
Modern, dark-themed UI applications:
1. **Appointment Calendar** - View and manage daily/weekly appointments
2. **Appointment Detail** - Detailed view of individual appointments
3. **Client Directory** - Browse and search all clients
4. **Client Detail** - Complete client profile with appointment history
5. **Availability Manager** - Manage available time slots and blocks
6. **Product Catalog** - View and manage products/services
7. **Certificate Viewer** - Manage gift certificates
8. **Coupon Manager** - Create and track discount coupons
9. **Form Builder** - Design and manage intake forms
10. **Analytics Dashboard** - Business insights and metrics
11. **Booking Flow** - End-to-end appointment booking experience
12. **Schedule Overview** - Weekly schedule summary and revenue tracking
## Installation
```bash
npm install
```
## Configuration
Set the following environment variables:
```bash
export ACUITY_USER_ID="your_acuity_user_id"
export ACUITY_API_KEY="your_acuity_api_key"
```
You can find your User ID and API Key in your Acuity Scheduling account under:
**Business Settings → Integrations → API**
## Usage
### As MCP Server
Build and start the server:
```bash
npm run build
npm start
```
### With Claude Desktop
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"acuity": {
"command": "node",
"args": ["/path/to/acuity-scheduling-mcp-server/dist/main.js"],
"env": {
"ACUITY_USER_ID": "your_user_id",
"ACUITY_API_KEY": "your_api_key"
}
}
}
}
```
### Development
Watch mode for TypeScript:
```bash
npm run dev
```
Type checking without building:
```bash
npm run typecheck
```
### Running React Apps
Each React app can be run independently using Vite:
```bash
cd src/ui/react-app/src/apps/appointment-calendar
npm install
npm run dev
```
## Architecture
### API Client
The `AcuityClient` class (`src/clients/acuity.ts`) handles:
- Basic Authentication (userId + API key)
- Request/response handling
- Error handling
- Pagination support
### Tools
Each domain has its own tool file in `src/tools/`:
- Standardized input schemas
- Proper error handling
- Type-safe operations
### Type System
Complete TypeScript definitions in `src/types/index.ts` covering:
- Request/response types
- API entities
- Client configuration
## API Reference
### Example Tool Calls
**List appointments:**
```typescript
{
"name": "acuity_list_appointments",
"arguments": {
"minDate": "2024-01-01",
"maxDate": "2024-01-31",
"max": 100
}
}
```
**Create appointment:**
```typescript
{
"name": "acuity_create_appointment",
"arguments": {
"appointmentTypeID": 123,
"datetime": "2024-01-15T14:00:00",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phone": "555-1234"
}
}
```
**Get availability:**
```typescript
{
"name": "acuity_get_availability_dates",
"arguments": {
"appointmentTypeID": 123,
"month": "2024-01",
"timezone": "America/New_York"
}
}
```
## Tech Stack
- **Server**: TypeScript, Node.js, MCP SDK
- **UI**: React 18, TypeScript, Vite, Tailwind CSS
- **API**: Acuity Scheduling REST API v1
## License
MIT
## Support
For issues or questions:
- Acuity API Docs: https://developers.acuityscheduling.com/
- MCP Docs: https://modelcontextprotocol.io/
---
Built with ❤️ for the Model Context Protocol

View File

@ -0,0 +1,42 @@
{
"name": "acuity-scheduling-mcp-server",
"version": "1.0.0",
"description": "MCP server for Acuity Scheduling integration with 59 tools and 12 React apps",
"type": "module",
"main": "./dist/main.js",
"bin": {
"acuity-mcp": "./dist/main.js"
},
"scripts": {
"build": "tsc && chmod +x dist/main.js",
"dev": "tsc --watch",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit",
"prepare": "npm run build"
},
"keywords": [
"mcp",
"model-context-protocol",
"acuity",
"scheduling",
"appointments"
],
"author": "MCP Engine",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4"
},
"devDependencies": {
"@types/node": "^20.17.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,107 @@
import { useState } from 'react';
export default function AnalyticsDashboard() {
const [dateRange, setDateRange] = useState('7days');
const stats = {
totalAppointments: 142,
revenue: '$12,450',
newClients: 28,
cancelationRate: '8%',
};
return (
<div className="min-h-screen bg-gray-900 text-gray-100 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-blue-400">Analytics Dashboard</h1>
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100"
>
<option value="7days">Last 7 Days</option>
<option value="30days">Last 30 Days</option>
<option value="90days">Last 90 Days</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-gray-800 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-2">Total Appointments</p>
<p className="text-3xl font-bold text-blue-400">{stats.totalAppointments}</p>
<p className="text-sm text-green-400 mt-2"> 12% vs last period</p>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-2">Revenue</p>
<p className="text-3xl font-bold text-green-400">{stats.revenue}</p>
<p className="text-sm text-green-400 mt-2"> 8% vs last period</p>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-2">New Clients</p>
<p className="text-3xl font-bold text-purple-400">{stats.newClients}</p>
<p className="text-sm text-green-400 mt-2"> 15% vs last period</p>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-2">Cancelation Rate</p>
<p className="text-3xl font-bold text-yellow-400">{stats.cancelationRate}</p>
<p className="text-sm text-red-400 mt-2"> 2% vs last period</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Appointment Types</h2>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span>Consultation</span>
<div className="flex items-center gap-3">
<div className="w-48 bg-gray-700 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full" style={{ width: '75%' }}></div>
</div>
<span className="text-sm text-gray-400">75%</span>
</div>
</div>
<div className="flex justify-between items-center">
<span>Follow-up</span>
<div className="flex items-center gap-3">
<div className="w-48 bg-gray-700 rounded-full h-2">
<div className="bg-green-500 h-2 rounded-full" style={{ width: '45%' }}></div>
</div>
<span className="text-sm text-gray-400">45%</span>
</div>
</div>
<div className="flex justify-between items-center">
<span>Extended Session</span>
<div className="flex items-center gap-3">
<div className="w-48 bg-gray-700 rounded-full h-2">
<div className="bg-purple-500 h-2 rounded-full" style={{ width: '30%' }}></div>
</div>
<span className="text-sm text-gray-400">30%</span>
</div>
</div>
</div>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Top Calendars</h2>
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-700 rounded">
<span>Main Calendar</span>
<span className="font-semibold text-blue-400">85 appointments</span>
</div>
<div className="flex justify-between items-center p-3 bg-gray-700 rounded">
<span>Secondary Calendar</span>
<span className="font-semibold text-blue-400">42 appointments</span>
</div>
<div className="flex justify-between items-center p-3 bg-gray-700 rounded">
<span>Weekend Calendar</span>
<span className="font-semibold text-blue-400">15 appointments</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,146 @@
import { useState } from 'react';
type Step = 'service' | 'datetime' | 'info' | 'confirm';
export default function BookingFlow() {
const [currentStep, setCurrentStep] = useState<Step>('service');
const [formData, setFormData] = useState({
service: '',
date: '',
time: '',
firstName: '',
lastName: '',
email: '',
phone: '',
});
const steps = [
{ id: 'service', label: 'Select Service' },
{ id: 'datetime', label: 'Choose Date & Time' },
{ id: 'info', label: 'Your Information' },
{ id: 'confirm', label: 'Confirm' },
];
return (
<div className="min-h-screen bg-gray-900 text-gray-100 p-6">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8 text-center text-blue-400">Book an Appointment</h1>
<div className="flex justify-between mb-12">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center flex-1">
<div className={`flex flex-col items-center flex-1 ${index < steps.length - 1 ? 'border-r-2' : ''} ${
currentStep === step.id ? 'border-blue-500' : 'border-gray-700'
}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 ${
currentStep === step.id ? 'bg-blue-600' : 'bg-gray-700'
}`}>
{index + 1}
</div>
<span className="text-sm text-center">{step.label}</span>
</div>
</div>
))}
</div>
<div className="bg-gray-800 rounded-lg p-8">
{currentStep === 'service' && (
<div>
<h2 className="text-2xl font-semibold mb-6">Select a Service</h2>
<div className="space-y-4">
{['Consultation - $50', 'Extended Session - $100', 'Follow-up - $75'].map((service) => (
<button
key={service}
onClick={() => {
setFormData({ ...formData, service });
setCurrentStep('datetime');
}}
className="w-full bg-gray-700 hover:bg-gray-600 p-4 rounded-lg text-left transition"
>
{service}
</button>
))}
</div>
</div>
)}
{currentStep === 'datetime' && (
<div>
<h2 className="text-2xl font-semibold mb-6">Choose Date & Time</h2>
<div className="grid gap-4">
<input
type="date"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-gray-100"
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
<select className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-gray-100">
<option>9:00 AM</option>
<option>10:00 AM</option>
<option>11:00 AM</option>
<option>2:00 PM</option>
<option>3:00 PM</option>
</select>
<button
onClick={() => setCurrentStep('info')}
className="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium"
>
Continue
</button>
</div>
</div>
)}
{currentStep === 'info' && (
<div>
<h2 className="text-2xl font-semibold mb-6">Your Information</h2>
<div className="grid gap-4">
<input
type="text"
placeholder="First Name"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-gray-100"
/>
<input
type="text"
placeholder="Last Name"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-gray-100"
/>
<input
type="email"
placeholder="Email"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-gray-100"
/>
<input
type="tel"
placeholder="Phone"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-gray-100"
/>
<button
onClick={() => setCurrentStep('confirm')}
className="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium"
>
Continue
</button>
</div>
</div>
)}
{currentStep === 'confirm' && (
<div>
<h2 className="text-2xl font-semibold mb-6">Confirm Your Appointment</h2>
<div className="bg-gray-700 rounded-lg p-6 mb-6">
<p className="mb-2"><span className="text-gray-400">Service:</span> Consultation</p>
<p className="mb-2"><span className="text-gray-400">Date:</span> January 15, 2024</p>
<p className="mb-2"><span className="text-gray-400">Time:</span> 2:00 PM</p>
<p className="mb-2"><span className="text-gray-400">Name:</span> John Doe</p>
<p className="mb-2"><span className="text-gray-400">Email:</span> john@example.com</p>
</div>
<button className="w-full bg-green-600 hover:bg-green-700 px-6 py-3 rounded-lg font-medium text-lg">
Confirm Appointment
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { useState, useEffect } from 'react';
interface Certificate {
certificate: string;
name: string;
value: string;
validUses: number;
usesRemaining: number;
expirationDate: string | null;
}
export default function CertificateViewer() {
const [certificates, setCertificates] = useState<Certificate[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const mockCertificates: Certificate[] = [
{ certificate: 'GIFT100', name: 'John Doe', value: '$100.00', validUses: 1, usesRemaining: 1, expirationDate: '2024-12-31' },
{ certificate: 'VIP50', name: 'Jane Smith', value: '$50.00', validUses: 2, usesRemaining: 1, expirationDate: null },
];
setCertificates(mockCertificates);
setLoading(false);
}, []);
return (
<div className="min-h-screen bg-gray-900 text-gray-100 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-blue-400">Gift Certificates</h1>
<button className="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded-lg font-medium">
+ New Certificate
</button>
</div>
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-400 mx-auto"></div>
</div>
) : (
<div className="grid gap-4">
{certificates.map((cert) => (
<div key={cert.certificate} className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-start">
<div>
<h3 className="text-xl font-semibold mb-2">Code: {cert.certificate}</h3>
<p className="text-gray-400 mb-1">Holder: {cert.name}</p>
<p className="text-lg font-semibold text-green-400 mb-2">Value: {cert.value}</p>
<div className="flex gap-4 text-sm text-gray-400">
<span>Valid Uses: {cert.validUses}</span>
<span>Remaining: {cert.usesRemaining}</span>
{cert.expirationDate && <span>Expires: {cert.expirationDate}</span>}
</div>
</div>
<div className="space-x-2">
<button className="text-blue-400 hover:text-blue-300">Edit</button>
<button className="text-red-400 hover:text-red-300">Delete</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
interface Coupon {
id: number;
code: string;
discount: string;
expirationDate: string | null;
usageCount: number;
}
export default function CouponManager() {
const [coupons, setCoupons] = useState<Coupon[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const mockCoupons: Coupon[] = [
{ id: 1, code: 'SAVE10', discount: '10%', expirationDate: '2024-12-31', usageCount: 15 },
{ id: 2, code: 'FIRST20', discount: '20%', expirationDate: null, usageCount: 8 },
{ id: 3, code: 'FLAT5', discount: '$5.00', expirationDate: '2024-06-30', usageCount: 42 },
];
setCoupons(mockCoupons);
setLoading(false);
}, []);
return (
<div className="min-h-screen bg-gray-900 text-gray-100 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-blue-400">Coupon Manager</h1>
<button className="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded-lg font-medium">
+ New Coupon
</button>
</div>
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-400 mx-auto"></div>
</div>
) : (
<div className="bg-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase">Code</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase">Discount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase">Expires</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase">Usage</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{coupons.map((coupon) => (
<tr key={coupon.id} className="hover:bg-gray-750">
<td className="px-6 py-4 font-mono font-semibold">{coupon.code}</td>
<td className="px-6 py-4 text-green-400 font-semibold">{coupon.discount}</td>
<td className="px-6 py-4">{coupon.expirationDate || 'No expiration'}</td>
<td className="px-6 py-4">{coupon.usageCount} uses</td>
<td className="px-6 py-4 space-x-3">
<button className="text-blue-400 hover:text-blue-300">Edit</button>
<button className="text-red-400 hover:text-red-300">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
import { useState, useEffect } from 'react';
interface FormField {
id: string;
name: string;
type: 'text' | 'email' | 'select' | 'textarea';
required: boolean;
}
interface Form {
id: number;
name: string;
description: string;
fields: FormField[];
}
export default function FormBuilder() {
const [forms, setForms] = useState<Form[]>([]);
const [selectedForm, setSelectedForm] = useState<Form | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const mockForms: Form[] = [
{
id: 1,
name: 'New Client Intake',
description: 'Initial consultation form',
fields: [
{ id: '1', name: 'Full Name', type: 'text', required: true },
{ id: '2', name: 'Email', type: 'email', required: true },
{ id: '3', name: 'Reason for Visit', type: 'textarea', required: false },
],
},
];
setForms(mockForms);
setLoading(false);
}, []);
return (
<div className="min-h-screen bg-gray-900 text-gray-100 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-blue-400">Form Builder</h1>
<button className="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded-lg font-medium">
+ New Form
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<h2 className="text-xl font-semibold mb-4">Forms</h2>
<div className="space-y-3">
{forms.map((form) => (
<div
key={form.id}
onClick={() => setSelectedForm(form)}
className={`bg-gray-800 rounded-lg p-4 cursor-pointer hover:bg-gray-750 transition ${
selectedForm?.id === form.id ? 'ring-2 ring-blue-500' : ''
}`}
>
<h3 className="font-semibold">{form.name}</h3>
<p className="text-sm text-gray-400">{form.fields.length} fields</p>
</div>
))}
</div>
</div>
<div className="lg:col-span-2">
{selectedForm ? (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">{selectedForm.name}</h2>
<p className="text-gray-400 mb-6">{selectedForm.description}</p>
<div className="space-y-4">
{selectedForm.fields.map((field) => (
<div key={field.id} className="bg-gray-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-medium">{field.name}</h4>
<p className="text-sm text-gray-400">Type: {field.type}</p>
</div>
{field.required && (
<span className="bg-red-900 text-red-200 px-2 py-1 rounded text-xs">Required</span>
)}
</div>
</div>
))}
</div>
<button className="mt-6 bg-green-600 hover:bg-green-700 px-6 py-2 rounded-lg font-medium">
+ Add Field
</button>
</div>
) : (
<div className="bg-gray-800 rounded-lg p-12 text-center">
<p className="text-gray-400">Select a form to edit</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
import { useState } from 'react';
interface DaySchedule {
date: string;
appointments: number;
revenue: string;
}
export default function ScheduleOverview() {
const [currentWeek, setCurrentWeek] = useState('2024-W03');
const weekSchedule: DaySchedule[] = [
{ date: 'Mon, Jan 15', appointments: 8, revenue: '$650' },
{ date: 'Tue, Jan 16', appointments: 12, revenue: '$980' },
{ date: 'Wed, Jan 17', appointments: 6, revenue: '$450' },
{ date: 'Thu, Jan 18', appointments: 10, revenue: '$825' },
{ date: 'Fri, Jan 19', appointments: 7, revenue: '$575' },
{ date: 'Sat, Jan 20', appointments: 4, revenue: '$320' },
{ date: 'Sun, Jan 21', appointments: 0, revenue: '$0' },
];
return (
<div className="min-h-screen bg-gray-900 text-gray-100 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-blue-400">Schedule Overview</h1>
<input
type="week"
value={currentWeek}
onChange={(e) => setCurrentWeek(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-gray-800 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-2">Total Appointments</p>
<p className="text-3xl font-bold text-blue-400">47</p>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-2">Weekly Revenue</p>
<p className="text-3xl font-bold text-green-400">$3,800</p>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-2">Busiest Day</p>
<p className="text-3xl font-bold text-purple-400">Tuesday</p>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<p className="text-sm text-gray-400 mb-2">Open Slots</p>
<p className="text-3xl font-bold text-yellow-400">23</p>
</div>
</div>
<div className="bg-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300">Day</th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300">Appointments</th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300">Revenue</th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{weekSchedule.map((day) => (
<tr key={day.date} className="hover:bg-gray-750">
<td className="px-6 py-4 font-medium">{day.date}</td>
<td className="px-6 py-4">{day.appointments}</td>
<td className="px-6 py-4 text-green-400">{day.revenue}</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-xs ${
day.appointments > 10 ? 'bg-red-900 text-red-200' :
day.appointments > 5 ? 'bg-yellow-900 text-yellow-200' :
day.appointments > 0 ? 'bg-green-900 text-green-200' :
'bg-gray-700 text-gray-400'
}`}>
{day.appointments > 10 ? 'Fully Booked' :
day.appointments > 5 ? 'Busy' :
day.appointments > 0 ? 'Available' :
'Closed'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/ui/react-app/src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
gray: {
750: '#374151',
},
},
},
},
plugins: [],
}

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true
},
"include": [
"src/types/**/*",
"src/clients/**/*",
"src/tools/**/*",
"src/server.ts",
"src/main.ts"
],
"exclude": ["node_modules", "dist", "src/ui/**/*"]
}

View File

@ -4,11 +4,15 @@
"description": "MCP server for Clover POS platform with comprehensive tools and React apps",
"type": "module",
"main": "dist/main.js",
"bin": {
"clover-mcp": "./dist/main.js"
},
"scripts": {
"build": "tsc && node scripts/copy-assets.js",
"dev": "tsc --watch",
"start": "node dist/main.js",
"prepare": "npm run build"
"prepare": "npm run build",
"prepublishOnly": "npm run build"
},
"keywords": [
"mcp",

View File

@ -1,20 +1,32 @@
{
"name": "mcp-server-fieldedge",
"name": "@mcpengine/fieldedge",
"version": "1.0.0",
"description": "FieldEdge MCP Server - Complete field service management integration",
"type": "module",
"main": "dist/index.js",
"main": "dist/main.js",
"bin": {
"fieldedge-mcp": "./dist/main.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
"prepare": "npm run build",
"start": "node dist/main.js"
},
"keywords": [
"mcp",
"fieldedge",
"field-service",
"hvac",
"dispatch",
"service-management"
],
"author": "MCPEngine",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0",
"zod": "^3.22.4"
"@modelcontextprotocol/sdk": "^1.0.4"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
"@types/node": "^22.10.5",
"typescript": "^5.7.2"
}
}

View File

@ -0,0 +1,215 @@
/**
* FieldEdge API Client
* Handles authentication, pagination, and error handling
*/
import { FieldEdgeConfig, PaginatedResponse, ApiError } from './types.js';
export class FieldEdgeClient {
private apiKey: string;
private baseUrl: string;
constructor(config: FieldEdgeConfig) {
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl || 'https://api.fieldedge.com/v2';
}
/**
* Make a GET request with pagination support
*/
async get<T>(
endpoint: string,
params?: Record<string, unknown>
): Promise<T> {
const url = this.buildUrl(endpoint, params);
return this.request<T>('GET', url);
}
/**
* Make a paginated GET request
*/
async getPaginated<T>(
endpoint: string,
params?: Record<string, unknown>
): Promise<PaginatedResponse<T>> {
const page = (params?.page as number) || 1;
const pageSize = (params?.pageSize as number) || 50;
const response = await this.get<{
data: T[];
total: number;
page: number;
pageSize: number;
}>(endpoint, { ...params, page, pageSize });
return {
data: response.data,
total: response.total,
page: response.page,
pageSize: response.pageSize,
hasMore: page * pageSize < response.total,
};
}
/**
* Make a POST request
*/
async post<T>(endpoint: string, data?: unknown): Promise<T> {
const url = this.buildUrl(endpoint);
return this.request<T>('POST', url, data);
}
/**
* Make a PUT request
*/
async put<T>(endpoint: string, data?: unknown): Promise<T> {
const url = this.buildUrl(endpoint);
return this.request<T>('PUT', url, data);
}
/**
* Make a PATCH request
*/
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
const url = this.buildUrl(endpoint);
return this.request<T>('PATCH', url, data);
}
/**
* Make a DELETE request
*/
async delete<T>(endpoint: string): Promise<T> {
const url = this.buildUrl(endpoint);
return this.request<T>('DELETE', url);
}
/**
* Core request method with error handling
*/
private async request<T>(
method: string,
url: string,
body?: unknown
): Promise<T> {
try {
const headers: HeadersInit = {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
const options: RequestInit = {
method,
headers,
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
await this.handleErrorResponse(response);
}
// Handle 204 No Content
if (response.status === 204) {
return {} as T;
}
const data = await response.json();
return data as T;
} catch (error) {
if (error instanceof Error) {
throw this.createApiError(error.message, 0, error);
}
throw this.createApiError('Unknown error occurred', 0, error);
}
}
/**
* Handle error responses from the API
*/
private async handleErrorResponse(response: Response): Promise<never> {
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
let errorDetails: unknown;
try {
const errorData = await response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
errorDetails = errorData;
} catch {
// Response body is not JSON
}
throw this.createApiError(errorMessage, response.status, errorDetails);
}
/**
* Create a standardized API error
*/
private createApiError(
message: string,
statusCode: number,
details?: unknown
): Error {
const error = new Error(message) as Error & ApiError;
error.statusCode = statusCode;
error.details = details;
return error;
}
/**
* Build URL with query parameters
*/
private buildUrl(
endpoint: string,
params?: Record<string, unknown>
): string {
const url = new URL(
endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`
);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
/**
* Fetch all pages of a paginated endpoint
*/
async getAllPages<T>(
endpoint: string,
params?: Record<string, unknown>
): Promise<T[]> {
const allData: T[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await this.getPaginated<T>(endpoint, {
...params,
page,
});
allData.push(...response.data);
hasMore = response.hasMore;
page++;
// Safety limit to prevent infinite loops
if (page > 1000) {
console.warn('Reached maximum page limit (1000)');
break;
}
}
return allData;
}
}

View File

@ -0,0 +1,439 @@
/**
* FieldEdge MCP Server - Type Definitions
*/
// API Configuration
export interface FieldEdgeConfig {
apiKey: string;
baseUrl?: string;
}
// Common Types
export interface PaginationParams {
page?: number;
pageSize?: number;
offset?: number;
limit?: number;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export interface ApiError {
message: string;
code?: string;
statusCode?: number;
details?: unknown;
}
// Job Types
export interface Job {
id: string;
jobNumber: string;
customerId: string;
customerName?: string;
locationId?: string;
jobType: string;
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'on_hold';
priority: 'low' | 'normal' | 'high' | 'emergency';
assignedTechId?: string;
assignedTechName?: string;
scheduledStart?: string;
scheduledEnd?: string;
actualStart?: string;
actualEnd?: string;
description?: string;
notes?: string;
totalAmount?: number;
address?: Address;
createdAt: string;
updatedAt: string;
}
export interface JobLineItem {
id: string;
jobId: string;
type: 'labor' | 'material' | 'equipment' | 'other';
description: string;
quantity: number;
unitPrice: number;
totalPrice: number;
taxable: boolean;
partNumber?: string;
technicianId?: string;
}
export interface JobEquipment {
id: string;
jobId: string;
equipmentId: string;
equipmentType: string;
serialNumber?: string;
model?: string;
manufacturer?: string;
serviceType: string;
}
// Customer Types
export interface Customer {
id: string;
customerNumber: string;
type: 'residential' | 'commercial';
firstName?: string;
lastName?: string;
companyName?: string;
email?: string;
phone?: string;
mobilePhone?: string;
status: 'active' | 'inactive';
balance: number;
creditLimit?: number;
taxExempt: boolean;
primaryAddress?: Address;
billingAddress?: Address;
tags?: string[];
createdAt: string;
updatedAt: string;
}
export interface CustomerLocation {
id: string;
customerId: string;
name: string;
address: Address;
isPrimary: boolean;
contactName?: string;
contactPhone?: string;
accessNotes?: string;
gateCode?: string;
}
export interface Address {
street1: string;
street2?: string;
city: string;
state: string;
zip: string;
country?: string;
latitude?: number;
longitude?: number;
}
// Invoice Types
export interface Invoice {
id: string;
invoiceNumber: string;
customerId: string;
customerName?: string;
jobId?: string;
status: 'draft' | 'sent' | 'paid' | 'partial' | 'overdue' | 'void';
invoiceDate: string;
dueDate?: string;
subtotal: number;
taxAmount: number;
totalAmount: number;
amountPaid: number;
amountDue: number;
lineItems: InvoiceLineItem[];
notes?: string;
terms?: string;
createdAt: string;
updatedAt: string;
}
export interface InvoiceLineItem {
id: string;
type: 'labor' | 'material' | 'equipment' | 'other';
description: string;
quantity: number;
unitPrice: number;
totalPrice: number;
taxable: boolean;
}
export interface Payment {
id: string;
invoiceId: string;
amount: number;
paymentMethod: 'cash' | 'check' | 'credit_card' | 'ach' | 'other';
paymentDate: string;
referenceNumber?: string;
notes?: string;
createdAt: string;
}
// Estimate Types
export interface Estimate {
id: string;
estimateNumber: string;
customerId: string;
customerName?: string;
locationId?: string;
status: 'draft' | 'sent' | 'approved' | 'declined' | 'expired';
estimateDate: string;
expirationDate?: string;
subtotal: number;
taxAmount: number;
totalAmount: number;
lineItems: EstimateLineItem[];
notes?: string;
terms?: string;
createdBy?: string;
createdAt: string;
updatedAt: string;
}
export interface EstimateLineItem {
id: string;
type: 'labor' | 'material' | 'equipment' | 'other';
description: string;
quantity: number;
unitPrice: number;
totalPrice: number;
taxable: boolean;
}
// Technician Types
export interface Technician {
id: string;
employeeNumber: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
status: 'active' | 'inactive' | 'on_leave';
role: string;
certifications?: string[];
skills?: string[];
hourlyRate?: number;
hireDate?: string;
terminationDate?: string;
createdAt: string;
updatedAt: string;
}
export interface TechnicianPerformance {
technicianId: string;
technicianName: string;
period: string;
jobsCompleted: number;
averageJobTime: number;
revenue: number;
customerSatisfaction?: number;
callbackRate?: number;
efficiency?: number;
}
export interface TimeEntry {
id: string;
technicianId: string;
jobId?: string;
date: string;
clockIn: string;
clockOut?: string;
hours: number;
type: 'regular' | 'overtime' | 'double_time' | 'travel';
notes?: string;
}
// Dispatch Types
export interface DispatchBoard {
date: string;
zones: DispatchZone[];
unassignedJobs: Job[];
}
export interface DispatchZone {
id: string;
name: string;
technicians: DispatchTechnician[];
}
export interface DispatchTechnician {
id: string;
name: string;
status: 'available' | 'on_job' | 'traveling' | 'break' | 'offline';
currentLocation?: { lat: number; lng: number };
assignedJobs: Job[];
capacity: number;
utilizationPercent: number;
}
export interface TechnicianAvailability {
technicianId: string;
date: string;
availableSlots: TimeSlot[];
bookedSlots: TimeSlot[];
}
export interface TimeSlot {
start: string;
end: string;
duration: number;
}
// Equipment Types
export interface Equipment {
id: string;
customerId: string;
locationId?: string;
type: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
installDate?: string;
warrantyExpiration?: string;
status: 'active' | 'inactive' | 'retired';
lastServiceDate?: string;
nextServiceDate?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface ServiceHistory {
id: string;
equipmentId: string;
jobId: string;
technicianId: string;
technicianName?: string;
serviceDate: string;
serviceType: string;
description: string;
partsUsed?: string[];
laborHours?: number;
cost?: number;
notes?: string;
}
// Inventory Types
export interface InventoryPart {
id: string;
partNumber: string;
description: string;
category?: string;
manufacturer?: string;
quantityOnHand: number;
quantityAvailable: number;
quantityOnOrder: number;
reorderPoint?: number;
reorderQuantity?: number;
unitCost: number;
unitPrice: number;
location?: string;
updatedAt: string;
}
export interface PurchaseOrder {
id: string;
poNumber: string;
vendorId: string;
vendorName?: string;
status: 'draft' | 'submitted' | 'approved' | 'received' | 'cancelled';
orderDate: string;
expectedDate?: string;
receivedDate?: string;
subtotal: number;
taxAmount: number;
totalAmount: number;
lineItems: PurchaseOrderLineItem[];
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface PurchaseOrderLineItem {
id: string;
partId: string;
partNumber: string;
description: string;
quantityOrdered: number;
quantityReceived: number;
unitCost: number;
totalCost: number;
}
// Service Agreement Types
export interface ServiceAgreement {
id: string;
agreementNumber: string;
customerId: string;
customerName?: string;
locationId?: string;
type: string;
status: 'active' | 'cancelled' | 'expired' | 'suspended';
startDate: string;
endDate?: string;
renewalDate?: string;
billingFrequency: 'monthly' | 'quarterly' | 'annually';
amount: number;
equipmentCovered?: string[];
servicesCovered?: string[];
visitsPerYear?: number;
visitsRemaining?: number;
autoRenew: boolean;
notes?: string;
createdAt: string;
updatedAt: string;
}
// Reporting Types
export interface RevenueReport {
period: string;
startDate: string;
endDate: string;
totalRevenue: number;
laborRevenue: number;
partsRevenue: number;
equipmentRevenue: number;
agreementRevenue: number;
jobCount: number;
averageTicket: number;
byJobType?: Record<string, number>;
byTechnician?: Record<string, number>;
}
export interface JobProfitabilityReport {
jobId: string;
jobNumber: string;
revenue: number;
laborCost: number;
partsCost: number;
overheadCost: number;
totalCost: number;
profit: number;
profitMargin: number;
}
export interface AgingReport {
asOfDate: string;
totalOutstanding: number;
current: AgingBucket;
days30: AgingBucket;
days60: AgingBucket;
days90: AgingBucket;
days90Plus: AgingBucket;
byCustomer: CustomerAging[];
}
export interface AgingBucket {
amount: number;
invoiceCount: number;
percentage: number;
}
export interface CustomerAging {
customerId: string;
customerName: string;
totalDue: number;
current: number;
days30: number;
days60: number;
days90: number;
days90Plus: number;
}

View File

@ -1,14 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]

View File

@ -1,66 +1,150 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
export default function App() {
const [teamMembers] = useState([
{ id: '1', name: 'Alice Johnson', role: 'Technician', email: 'alice@example.com', isActive: true, activeJobs: 3, hoursThisWeek: 38 },
{ id: '2', name: 'Bob Smith', role: 'Technician', email: 'bob@example.com', isActive: true, activeJobs: 2, hoursThisWeek: 35 },
{ id: '3', name: 'Charlie Davis', role: 'Manager', email: 'charlie@example.com', isActive: true, activeJobs: 1, hoursThisWeek: 40 },
]);
interface TeamMember {
id: string;
name: string;
role: string;
email: string;
phone: string;
activeJobs: number;
completedJobs: number;
hoursThisWeek: number;
status: 'AVAILABLE' | 'ON_JOB' | 'OFF_DUTY';
}
const totalHours = teamMembers.reduce((sum, member) => sum + member.hoursThisWeek, 0);
const totalActiveJobs = teamMembers.reduce((sum, member) => sum + member.activeJobs, 0);
export default function TeamOverview() {
const [team, setTeam] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setTimeout(() => {
setTeam([
{
id: '1',
name: 'Mike Johnson',
role: 'Lead Technician',
email: 'mike@company.com',
phone: '555-0101',
activeJobs: 3,
completedJobs: 127,
hoursThisWeek: 38,
status: 'ON_JOB',
},
{
id: '2',
name: 'Sarah Smith',
role: 'HVAC Specialist',
email: 'sarah@company.com',
phone: '555-0102',
activeJobs: 2,
completedJobs: 94,
hoursThisWeek: 35,
status: 'AVAILABLE',
},
{
id: '3',
name: 'Tom Brown',
role: 'Plumber',
email: 'tom@company.com',
phone: '555-0103',
activeJobs: 1,
completedJobs: 156,
hoursThisWeek: 40,
status: 'ON_JOB',
},
{
id: '4',
name: 'Emily Davis',
role: 'Electrician',
email: 'emily@company.com',
phone: '555-0104',
activeJobs: 0,
completedJobs: 83,
hoursThisWeek: 32,
status: 'OFF_DUTY',
},
]);
setLoading(false);
}, 500);
}, []);
const totalActiveJobs = team.reduce((sum, m) => sum + m.activeJobs, 0);
const totalCompletedJobs = team.reduce((sum, m) => sum + m.completedJobs, 0);
const averageHours = team.reduce((sum, m) => sum + m.hoursThisWeek, 0) / team.length;
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<header className="bg-gray-800 border-b border-gray-700 p-6">
<h1 className="text-3xl font-bold">Team Overview</h1>
<p className="text-gray-400 mt-1">Manage your field team</p>
<p className="text-gray-400 mt-1">Manage your field service team</p>
</header>
<div className="p-6">
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div className="text-sm text-gray-400 mb-1">Team Members</div>
<div className="text-3xl font-bold">{teamMembers.length}</div>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div className="text-sm text-gray-400 mb-1">Active Jobs</div>
<div className="text-3xl font-bold text-blue-400">{totalActiveJobs}</div>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div className="text-sm text-gray-400 mb-1">Hours This Week</div>
<div className="text-3xl font-bold text-green-400">{totalHours}h</div>
</div>
</div>
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-900">
<tr className="text-left text-gray-400">
<th className="p-4">Name</th>
<th className="p-4">Role</th>
<th className="p-4">Email</th>
<th className="p-4">Active Jobs</th>
<th className="p-4">Hours This Week</th>
<th className="p-4">Status</th>
</tr>
</thead>
<tbody>
{teamMembers.map(member => (
<tr key={member.id} className="border-t border-gray-700">
<td className="p-4 font-medium">{member.name}</td>
<td className="p-4">{member.role}</td>
<td className="p-4 text-gray-400">{member.email}</td>
<td className="p-4">{member.activeJobs}</td>
<td className="p-4">{member.hoursThisWeek}h</td>
<td className="p-4">
<span className={`px-2 py-1 rounded text-xs ${member.isActive ? 'bg-green-500' : 'bg-gray-500'} text-white`}>
{member.isActive ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
{loading ? (
<div className="text-center py-12 text-gray-400">Loading team...</div>
) : (
<>
<div className="grid grid-cols-4 gap-6 mb-6">
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 className="text-sm text-gray-400 mb-2">Total Team Members</h3>
<div className="text-3xl font-bold">{team.length}</div>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 className="text-sm text-gray-400 mb-2">Active Jobs</h3>
<div className="text-3xl font-bold text-blue-400">{totalActiveJobs}</div>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 className="text-sm text-gray-400 mb-2">Completed Jobs (Total)</h3>
<div className="text-3xl font-bold text-green-400">{totalCompletedJobs}</div>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 className="text-sm text-gray-400 mb-2">Avg Hours This Week</h3>
<div className="text-3xl font-bold text-purple-400">{averageHours.toFixed(1)}</div>
</div>
</div>
<div className="grid gap-4">
{team.map(member => (
<div key={member.id} className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold">{member.name}</h3>
<span className={`px-2 py-1 rounded text-xs font-semibold ${
member.status === 'AVAILABLE' ? 'bg-green-900 text-green-200' :
member.status === 'ON_JOB' ? 'bg-blue-900 text-blue-200' :
'bg-gray-700 text-gray-300'
}`}>
{member.status.replace('_', ' ')}
</span>
</div>
<p className="text-gray-400 mb-3">{member.role}</p>
<div className="flex gap-4 text-sm text-gray-400">
<span>{member.email}</span>
<span></span>
<span>{member.phone}</span>
</div>
</div>
<div className="grid grid-cols-3 gap-6 ml-6">
<div className="text-center">
<div className="text-2xl font-bold text-blue-400">{member.activeJobs}</div>
<div className="text-xs text-gray-400 mt-1">Active Jobs</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-400">{member.completedJobs}</div>
<div className="text-xs text-gray-400 mt-1">Completed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-400">{member.hoursThisWeek}</div>
<div className="text-xs text-gray-400 mt-1">Hours This Week</div>
</div>
</div>
</div>
</div>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
</div>
);

View File

@ -1,54 +1,172 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
export default function App() {
const [timeEntries] = useState([
{ id: '1', user: 'Alice', job: 'J-1001', date: '2024-02-15', hours: 8, description: 'HVAC Installation' },
{ id: '2', user: 'Bob', job: 'J-1002', date: '2024-02-15', hours: 6, description: 'Maintenance' },
{ id: '3', user: 'Alice', job: 'J-1003', date: '2024-02-16', hours: 5, description: 'Site Survey' },
{ id: '4', user: 'Charlie', job: 'J-1002', date: '2024-02-16', hours: 7, description: 'Repair Work' },
]);
interface TimeEntry {
id: string;
userId: string;
userName: string;
jobTitle: string;
date: string;
startTime: string;
endTime: string;
hours: number;
notes?: string;
approved: boolean;
}
const totalHours = timeEntries.reduce((sum, entry) => sum + entry.hours, 0);
export default function TimesheetGrid() {
const [entries, setEntries] = useState<TimeEntry[]>([]);
const [filter, setFilter] = useState<'ALL' | 'PENDING' | 'APPROVED'>('ALL');
const [loading, setLoading] = useState(true);
useEffect(() => {
setTimeout(() => {
setEntries([
{
id: '1',
userId: '1',
userName: 'Mike Johnson',
jobTitle: 'HVAC Installation',
date: '2024-02-15',
startTime: '09:00',
endTime: '12:00',
hours: 3,
notes: 'Installed new unit',
approved: true,
},
{
id: '2',
userId: '2',
userName: 'Sarah Smith',
jobTitle: 'Plumbing Repair',
date: '2024-02-15',
startTime: '10:00',
endTime: '14:00',
hours: 4,
notes: 'Fixed leaking pipes',
approved: false,
},
{
id: '3',
userId: '1',
userName: 'Mike Johnson',
jobTitle: 'Electrical Inspection',
date: '2024-02-14',
startTime: '13:00',
endTime: '16:00',
hours: 3,
approved: true,
},
{
id: '4',
userId: '3',
userName: 'Tom Brown',
jobTitle: 'Service Call',
date: '2024-02-14',
startTime: '08:00',
endTime: '11:30',
hours: 3.5,
notes: 'Emergency repair',
approved: false,
},
]);
setLoading(false);
}, 500);
}, []);
const filteredEntries = filter === 'ALL'
? entries
: entries.filter(e => filter === 'APPROVED' ? e.approved : !e.approved);
const totalHours = filteredEntries.reduce((sum, e) => sum + e.hours, 0);
const toggleApproval = (id: string) => {
setEntries(entries.map(e => e.id === id ? { ...e, approved: !e.approved } : e));
};
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<header className="bg-gray-800 border-b border-gray-700 p-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Timesheet Grid</h1>
<p className="text-gray-400 mt-1">Track team hours and productivity</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-blue-400">{totalHours}h</div>
<div className="text-sm text-gray-400">Total Hours</div>
</div>
</div>
<h1 className="text-3xl font-bold">Timesheet Grid</h1>
<p className="text-gray-400 mt-1">Track and approve team time entries</p>
</header>
<div className="p-6">
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-900">
<tr className="text-left text-gray-400">
<th className="p-4">Date</th>
<th className="p-4">User</th>
<th className="p-4">Job</th>
<th className="p-4">Description</th>
<th className="p-4 text-right">Hours</th>
</tr>
</thead>
<tbody>
{timeEntries.map(entry => (
<tr key={entry.id} className="border-t border-gray-700">
<td className="p-4">{new Date(entry.date).toLocaleDateString()}</td>
<td className="p-4">{entry.user}</td>
<td className="p-4"><span className="px-2 py-1 bg-gray-900 rounded text-xs font-mono">{entry.job}</span></td>
<td className="p-4">{entry.description}</td>
<td className="p-4 text-right font-bold">{entry.hours}h</td>
</tr>
))}
</tbody>
</table>
<div className="mb-6 flex justify-between items-center">
<div className="flex gap-2">
{['ALL', 'PENDING', 'APPROVED'].map(status => (
<button
key={status}
onClick={() => setFilter(status as typeof filter)}
className={`px-4 py-2 rounded ${
filter === status
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{status}
</button>
))}
</div>
<div className="text-xl font-bold">
Total Hours: {totalHours.toFixed(1)}
</div>
</div>
{loading ? (
<div className="text-center py-12 text-gray-400">Loading timesheets...</div>
) : (
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-gray-750 border-b border-gray-700">
<th className="text-left p-4 font-semibold">Employee</th>
<th className="text-left p-4 font-semibold">Job</th>
<th className="text-left p-4 font-semibold">Date</th>
<th className="text-left p-4 font-semibold">Time</th>
<th className="text-left p-4 font-semibold">Hours</th>
<th className="text-left p-4 font-semibold">Notes</th>
<th className="text-left p-4 font-semibold">Status</th>
<th className="text-left p-4 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{filteredEntries.map(entry => (
<tr key={entry.id} className="border-b border-gray-700 hover:bg-gray-750">
<td className="p-4">{entry.userName}</td>
<td className="p-4">{entry.jobTitle}</td>
<td className="p-4">{new Date(entry.date).toLocaleDateString()}</td>
<td className="p-4 text-sm text-gray-400">
{entry.startTime} - {entry.endTime}
</td>
<td className="p-4 font-semibold">{entry.hours}h</td>
<td className="p-4 text-sm text-gray-400">{entry.notes || '-'}</td>
<td className="p-4">
<span className={`px-2 py-1 rounded text-xs font-semibold ${
entry.approved
? 'bg-green-900 text-green-200'
: 'bg-yellow-900 text-yellow-200'
}`}>
{entry.approved ? 'APPROVED' : 'PENDING'}
</span>
</td>
<td className="p-4">
<button
onClick={() => toggleApproval(entry.id)}
className={`px-3 py-1 rounded text-sm ${
entry.approved
? 'bg-red-600 hover:bg-red-700'
: 'bg-green-600 hover:bg-green-700'
}`}
>
{entry.approved ? 'Unapprove' : 'Approve'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);

View File

@ -1,50 +1,184 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
export default function App() {
const [visits] = useState([
{ id: '1', title: 'HVAC Installation', job: 'J-1001', startAt: '2024-02-15T09:00:00Z', status: 'COMPLETED', assignedUsers: ['Alice', 'Bob'] },
{ id: '2', title: 'Maintenance', job: 'J-1002', startAt: '2024-02-16T13:00:00Z', status: 'IN_PROGRESS', assignedUsers: ['Charlie'] },
{ id: '3', title: 'Site Survey', job: 'J-1003', startAt: '2024-02-17T10:00:00Z', status: 'SCHEDULED', assignedUsers: ['Alice'] },
]);
interface Visit {
id: string;
title: string;
jobTitle: string;
jobId: string;
clientName: string;
address: string;
startAt: string;
endAt: string;
status: 'SCHEDULED' | 'EN_ROUTE' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
assignedTo: string[];
notes?: string;
}
const statusColors: Record<string, string> = {
COMPLETED: 'bg-green-500',
IN_PROGRESS: 'bg-blue-500',
SCHEDULED: 'bg-gray-500',
CANCELLED: 'bg-red-500',
export default function VisitTracker() {
const [visits, setVisits] = useState<Visit[]>([]);
const [filter, setFilter] = useState<string>('ALL');
const [loading, setLoading] = useState(true);
useEffect(() => {
setTimeout(() => {
setVisits([
{
id: '1',
title: 'Initial Consultation',
jobTitle: 'HVAC Installation',
jobId: 'J-1234',
clientName: 'John Doe',
address: '123 Main St, New York, NY',
startAt: '2024-02-15T09:00:00Z',
endAt: '2024-02-15T10:00:00Z',
status: 'COMPLETED',
assignedTo: ['Mike Johnson'],
notes: 'Discussed system requirements',
},
{
id: '2',
title: 'System Installation',
jobTitle: 'HVAC Installation',
jobId: 'J-1234',
clientName: 'John Doe',
address: '123 Main St, New York, NY',
startAt: '2024-02-20T08:00:00Z',
endAt: '2024-02-20T16:00:00Z',
status: 'SCHEDULED',
assignedTo: ['Mike Johnson', 'Sarah Smith'],
},
{
id: '3',
title: 'Emergency Repair',
jobTitle: 'Plumbing Emergency',
jobId: 'J-1235',
clientName: 'Jane Wilson',
address: '456 Oak Ave, Brooklyn, NY',
startAt: '2024-02-15T13:00:00Z',
endAt: '2024-02-15T15:00:00Z',
status: 'IN_PROGRESS',
assignedTo: ['Tom Brown'],
notes: 'Leaking pipe in basement',
},
]);
setLoading(false);
}, 500);
}, []);
const filteredVisits = filter === 'ALL'
? visits
: visits.filter(v => v.status === filter);
const updateStatus = (id: string, status: Visit['status']) => {
setVisits(visits.map(v => v.id === id ? { ...v, status } : v));
};
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<header className="bg-gray-800 border-b border-gray-700 p-6">
<h1 className="text-3xl font-bold">Visit Tracker</h1>
<p className="text-gray-400 mt-1">Track all field visits</p>
<p className="text-gray-400 mt-1">Track all scheduled and active visits</p>
</header>
<div className="p-6">
<div className="grid gap-4">
{visits.map(visit => (
<div key={visit.id} className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<span className={`px-2 py-1 rounded text-xs ${statusColors[visit.status]} text-white`}>
{visit.status}
</span>
<span className="text-sm text-gray-400">{visit.job}</span>
</div>
<h3 className="text-xl font-semibold">{visit.title}</h3>
<p className="text-gray-400 text-sm mt-1">
Assigned: {visit.assignedUsers.join(', ')}
</p>
</div>
<div className="text-right text-sm">
<div>{new Date(visit.startAt).toLocaleDateString()}</div>
<div className="text-gray-400">{new Date(visit.startAt).toLocaleTimeString()}</div>
</div>
</div>
</div>
<div className="mb-6 flex gap-2">
{['ALL', 'SCHEDULED', 'EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'].map(status => (
<button
key={status}
onClick={() => setFilter(status)}
className={`px-4 py-2 rounded ${
filter === status
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{status.replace('_', ' ')}
</button>
))}
</div>
{loading ? (
<div className="text-center py-12 text-gray-400">Loading visits...</div>
) : (
<div className="grid gap-4">
{filteredVisits.map(visit => (
<div key={visit.id} className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold">{visit.title}</h3>
<span className={`px-2 py-1 rounded text-xs font-semibold ${
visit.status === 'SCHEDULED' ? 'bg-blue-900 text-blue-200' :
visit.status === 'EN_ROUTE' ? 'bg-purple-900 text-purple-200' :
visit.status === 'IN_PROGRESS' ? 'bg-yellow-900 text-yellow-200' :
visit.status === 'COMPLETED' ? 'bg-green-900 text-green-200' :
'bg-gray-700 text-gray-300'
}`}>
{visit.status.replace('_', ' ')}
</span>
</div>
<p className="text-gray-400 mb-1">
<span className="font-mono text-sm">{visit.jobId}</span> - {visit.jobTitle}
</p>
<p className="text-sm text-gray-400 mb-2">{visit.clientName}</p>
<p className="text-sm text-gray-500">{visit.address}</p>
{visit.notes && (
<p className="mt-2 text-sm text-gray-400 italic">{visit.notes}</p>
)}
</div>
<div className="text-right ml-4">
<div className="text-sm text-gray-400 mb-1">
{new Date(visit.startAt).toLocaleDateString()}
</div>
<div className="text-sm font-semibold">
{new Date(visit.startAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
{' - '}
{new Date(visit.endAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
<div className="mt-2 text-xs text-gray-400">
{visit.assignedTo.join(', ')}
</div>
</div>
</div>
<div className="flex gap-2 mt-4">
{visit.status === 'SCHEDULED' && (
<button
onClick={() => updateStatus(visit.id, 'EN_ROUTE')}
className="bg-purple-600 hover:bg-purple-700 px-3 py-1 rounded text-sm"
>
Mark En Route
</button>
)}
{visit.status === 'EN_ROUTE' && (
<button
onClick={() => updateStatus(visit.id, 'IN_PROGRESS')}
className="bg-yellow-600 hover:bg-yellow-700 px-3 py-1 rounded text-sm"
>
Start Visit
</button>
)}
{visit.status === 'IN_PROGRESS' && (
<button
onClick={() => updateStatus(visit.id, 'COMPLETED')}
className="bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm"
>
Complete Visit
</button>
)}
{visit.status !== 'COMPLETED' && visit.status !== 'CANCELLED' && (
<button
onClick={() => updateStatus(visit.id, 'CANCELLED')}
className="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm"
>
Cancel
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);

View File

@ -1,20 +1,39 @@
{
"name": "mcp-server-lightspeed",
"name": "@mcpengine/lightspeed-mcp-server",
"version": "1.0.0",
"description": "MCP server for Lightspeed Retail (X-Series/R-Series) - complete POS and inventory management",
"main": "dist/main.js",
"type": "module",
"main": "dist/index.js",
"bin": {
"lightspeed-mcp": "./dist/main.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
"prepare": "npm run build",
"dev": "tsc --watch",
"start": "node dist/main.js"
},
"keywords": [
"mcp",
"lightspeed",
"retail",
"pos",
"inventory",
"sales",
"ecommerce"
],
"author": "MCPEngine",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0",
"zod": "^3.22.4"
"@modelcontextprotocol/sdk": "^1.0.4",
"axios": "^1.7.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -1,329 +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";
// ============================================
// LIGHTSPEED RETAIL (R-SERIES) MCP SERVER
// API Docs: https://developers.lightspeedhq.com/retail/
// ============================================
const MCP_NAME = "lightspeed";
const MCP_VERSION = "1.0.0";
const API_BASE_URL = "https://api.lightspeedapp.com/API/V3/Account";
// ============================================
// API CLIENT - OAuth2 Authentication
// ============================================
class LightspeedClient {
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}/${accountId}`;
}
async request(endpoint: string, options: RequestInit = {}) {
const url = `${this.baseUrl}${endpoint}.json`;
const response = await fetch(url, {
...options,
headers: {
"Authorization": `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
"Accept": "application/json",
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Lightspeed API error: ${response.status} ${response.statusText} - ${errorText}`);
}
return response.json();
}
async get(endpoint: string, params?: Record<string, string>) {
const queryString = params ? '?' + new URLSearchParams(params).toString() : '';
return this.request(`${endpoint}${queryString}`, { 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),
});
}
}
// ============================================
// TOOL DEFINITIONS
// ============================================
const tools = [
{
name: "list_sales",
description: "List sales/transactions from Lightspeed Retail. Returns completed sales with line items, payments, and customer info.",
inputSchema: {
type: "object" as const,
properties: {
limit: { type: "number", description: "Max sales to return (default 100, max 100)" },
offset: { type: "number", description: "Pagination offset" },
completed: { type: "boolean", description: "Filter by completed status" },
timeStamp: { type: "string", description: "Filter by timestamp (e.g., '>=,2024-01-01' or '<=,2024-12-31')" },
employeeID: { type: "string", description: "Filter by employee ID" },
shopID: { type: "string", description: "Filter by shop/location ID" },
load_relations: { type: "string", description: "Comma-separated relations to load (e.g., 'SaleLines,SalePayments,Customer')" },
},
},
},
{
name: "get_sale",
description: "Get a specific sale by ID with full details including line items, payments, and customer",
inputSchema: {
type: "object" as const,
properties: {
sale_id: { type: "string", description: "Sale ID" },
load_relations: { type: "string", description: "Comma-separated relations (e.g., 'SaleLines,SalePayments,Customer,SaleLines.Item')" },
},
required: ["sale_id"],
},
},
{
name: "list_items",
description: "List inventory items from Lightspeed Retail catalog",
inputSchema: {
type: "object" as const,
properties: {
limit: { type: "number", description: "Max items to return (default 100, max 100)" },
offset: { type: "number", description: "Pagination offset" },
categoryID: { type: "string", description: "Filter by category ID" },
manufacturerID: { type: "string", description: "Filter by manufacturer ID" },
description: { type: "string", description: "Search by description (supports ~ for contains)" },
upc: { type: "string", description: "Filter by UPC barcode" },
customSku: { type: "string", description: "Filter by custom SKU" },
archived: { type: "boolean", description: "Include archived items" },
load_relations: { type: "string", description: "Comma-separated relations (e.g., 'ItemShops,Category,Manufacturer')" },
},
},
},
{
name: "get_item",
description: "Get a specific inventory item by ID with full details",
inputSchema: {
type: "object" as const,
properties: {
item_id: { type: "string", description: "Item ID" },
load_relations: { type: "string", description: "Comma-separated relations (e.g., 'ItemShops,Category,Manufacturer,Prices')" },
},
required: ["item_id"],
},
},
{
name: "update_inventory",
description: "Update inventory quantity for an item at a specific shop location",
inputSchema: {
type: "object" as const,
properties: {
item_shop_id: { type: "string", description: "ItemShop ID (the item-location relationship ID)" },
qoh: { type: "number", description: "New quantity on hand" },
reorderPoint: { type: "number", description: "Reorder point threshold" },
reorderLevel: { type: "number", description: "Reorder quantity level" },
},
required: ["item_shop_id", "qoh"],
},
},
{
name: "list_customers",
description: "List customers from Lightspeed Retail",
inputSchema: {
type: "object" as const,
properties: {
limit: { type: "number", description: "Max customers to return (default 100, max 100)" },
offset: { type: "number", description: "Pagination offset" },
firstName: { type: "string", description: "Filter by first name (supports ~ for contains)" },
lastName: { type: "string", description: "Filter by last name (supports ~ for contains)" },
email: { type: "string", description: "Filter by email address" },
phone: { type: "string", description: "Filter by phone number" },
customerTypeID: { type: "string", description: "Filter by customer type ID" },
load_relations: { type: "string", description: "Comma-separated relations (e.g., 'Contact,CustomerType')" },
},
},
},
{
name: "list_categories",
description: "List product categories from Lightspeed Retail catalog",
inputSchema: {
type: "object" as const,
properties: {
limit: { type: "number", description: "Max categories to return (default 100, max 100)" },
offset: { type: "number", description: "Pagination offset" },
parentID: { type: "string", description: "Filter by parent category ID (0 for root categories)" },
name: { type: "string", description: "Filter by category name (supports ~ for contains)" },
load_relations: { type: "string", description: "Comma-separated relations (e.g., 'Items')" },
},
},
},
{
name: "get_register",
description: "Get register/POS terminal information and status",
inputSchema: {
type: "object" as const,
properties: {
register_id: { type: "string", description: "Register ID (optional - lists all if not provided)" },
shopID: { type: "string", description: "Filter by shop/location ID" },
load_relations: { type: "string", description: "Comma-separated relations (e.g., 'Shop,RegisterCounts')" },
},
},
},
];
// ============================================
// TOOL HANDLERS
// ============================================
async function handleTool(client: LightspeedClient, name: string, args: any) {
switch (name) {
case "list_sales": {
const params: Record<string, string> = {};
if (args.limit) params.limit = String(args.limit);
if (args.offset) params.offset = String(args.offset);
if (args.completed !== undefined) params.completed = args.completed ? 'true' : 'false';
if (args.timeStamp) params.timeStamp = args.timeStamp;
if (args.employeeID) params.employeeID = args.employeeID;
if (args.shopID) params.shopID = args.shopID;
if (args.load_relations) params.load_relations = `["${args.load_relations.split(',').join('","')}"]`;
return await client.get("/Sale", params);
}
case "get_sale": {
const params: Record<string, string> = {};
if (args.load_relations) params.load_relations = `["${args.load_relations.split(',').join('","')}"]`;
return await client.get(`/Sale/${args.sale_id}`, params);
}
case "list_items": {
const params: Record<string, string> = {};
if (args.limit) params.limit = String(args.limit);
if (args.offset) params.offset = String(args.offset);
if (args.categoryID) params.categoryID = args.categoryID;
if (args.manufacturerID) params.manufacturerID = args.manufacturerID;
if (args.description) params.description = args.description;
if (args.upc) params.upc = args.upc;
if (args.customSku) params.customSku = args.customSku;
if (args.archived !== undefined) params.archived = args.archived ? 'true' : 'false';
if (args.load_relations) params.load_relations = `["${args.load_relations.split(',').join('","')}"]`;
return await client.get("/Item", params);
}
case "get_item": {
const params: Record<string, string> = {};
if (args.load_relations) params.load_relations = `["${args.load_relations.split(',').join('","')}"]`;
return await client.get(`/Item/${args.item_id}`, params);
}
case "update_inventory": {
const data: any = { qoh: args.qoh };
if (args.reorderPoint !== undefined) data.reorderPoint = args.reorderPoint;
if (args.reorderLevel !== undefined) data.reorderLevel = args.reorderLevel;
return await client.put(`/ItemShop/${args.item_shop_id}`, data);
}
case "list_customers": {
const params: Record<string, string> = {};
if (args.limit) params.limit = String(args.limit);
if (args.offset) params.offset = String(args.offset);
if (args.firstName) params.firstName = args.firstName;
if (args.lastName) params.lastName = args.lastName;
if (args.email) params['Contact.email'] = args.email;
if (args.phone) params['Contact.phone'] = args.phone;
if (args.customerTypeID) params.customerTypeID = args.customerTypeID;
if (args.load_relations) params.load_relations = `["${args.load_relations.split(',').join('","')}"]`;
return await client.get("/Customer", params);
}
case "list_categories": {
const params: Record<string, string> = {};
if (args.limit) params.limit = String(args.limit);
if (args.offset) params.offset = String(args.offset);
if (args.parentID) params.parentID = args.parentID;
if (args.name) params.name = args.name;
if (args.load_relations) params.load_relations = `["${args.load_relations.split(',').join('","')}"]`;
return await client.get("/Category", params);
}
case "get_register": {
const params: Record<string, string> = {};
if (args.shopID) params.shopID = args.shopID;
if (args.load_relations) params.load_relations = `["${args.load_relations.split(',').join('","')}"]`;
if (args.register_id) {
return await client.get(`/Register/${args.register_id}`, params);
}
return await client.get("/Register", params);
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
// ============================================
// SERVER SETUP
// ============================================
async function main() {
const accessToken = process.env.LIGHTSPEED_ACCESS_TOKEN;
const accountId = process.env.LIGHTSPEED_ACCOUNT_ID;
if (!accessToken) {
console.error("Error: LIGHTSPEED_ACCESS_TOKEN environment variable required");
process.exit(1);
}
if (!accountId) {
console.error("Error: LIGHTSPEED_ACCOUNT_ID environment variable required");
process.exit(1);
}
const client = new LightspeedClient(accessToken, accountId);
const server = new Server(
{ name: `${MCP_NAME}-mcp`, version: MCP_VERSION },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools,
}));
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,
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`${MCP_NAME} MCP server running on stdio`);
}
main().catch(console.error);

View File

@ -1,14 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]

View File

@ -1,10 +1,4 @@
# ServiceTitan API Credentials (Required)
SERVICETITAN_CLIENT_ID=your_client_id_here
SERVICETITAN_CLIENT_SECRET=your_client_secret_here
SERVICETITAN_TENANT_ID=your_tenant_id_here
SERVICETITAN_APP_KEY=your_app_key_here
# Optional Configuration
SERVICETITAN_BASE_URL=https://api.servicetitan.io
PORT=3000
MODE=stdio # or "http" for web apps

View File

@ -1,171 +1,221 @@
# ServiceTitan MCP Server
Complete Model Context Protocol (MCP) server for ServiceTitan field service management platform.
Complete Model Context Protocol (MCP) server for ServiceTitan field service management platform with **108 tools** and **15 React apps**.
## Features
### 🔧 **55 MCP Tools** across 10 categories:
### 🔧 Tools (108 total)
#### Jobs Management (9 tools)
#### Jobs Management (8 tools)
- `servicetitan_list_jobs` - List jobs with filters
- `servicetitan_get_job` - Get job details
- `servicetitan_create_job` - Create new job
- `servicetitan_update_job` - Update job
- `servicetitan_cancel_job` - Cancel job
- `servicetitan_update_job` - Update job details
- `servicetitan_complete_job` - Mark job complete
- `servicetitan_cancel_job` - Cancel a job
- `servicetitan_list_job_appointments` - List job appointments
- `servicetitan_create_job_appointment` - Create appointment
- `servicetitan_reschedule_appointment` - Reschedule appointment
- `servicetitan_get_job_history` - Get job history
#### Customer Management (9 tools)
#### Customers Management (9 tools)
- `servicetitan_list_customers` - List customers
- `servicetitan_get_customer` - Get customer details
- `servicetitan_create_customer` - Create customer
- `servicetitan_create_customer` - Create new customer
- `servicetitan_update_customer` - Update customer
- `servicetitan_search_customers` - Search customers
- `servicetitan_list_customer_contacts` - List contacts
- `servicetitan_deactivate_customer` - Deactivate customer
- `servicetitan_get_customer_balance` - Get account balance
- `servicetitan_list_customer_contacts` - List customer contacts
- `servicetitan_create_customer_contact` - Create contact
- `servicetitan_list_customer_locations` - List locations
- `servicetitan_create_customer_location` - Create location
- `servicetitan_list_customer_locations` - List customer locations
#### Invoice Management (8 tools)
#### Estimates Management (8 tools)
- `servicetitan_list_estimates` - List estimates
- `servicetitan_get_estimate` - Get estimate details
- `servicetitan_create_estimate` - Create new estimate
- `servicetitan_update_estimate` - Update estimate
- `servicetitan_mark_estimate_sold` - Mark estimate as sold
- `servicetitan_dismiss_estimate` - Dismiss estimate
- `servicetitan_get_estimate_items` - Get estimate line items
- `servicetitan_add_estimate_item` - Add line item
#### Invoices & Payments (10 tools)
- `servicetitan_list_invoices` - List invoices
- `servicetitan_get_invoice` - Get invoice details
- `servicetitan_create_invoice` - Create invoice
- `servicetitan_create_invoice` - Create new invoice
- `servicetitan_update_invoice` - Update invoice
- `servicetitan_list_invoice_items` - List invoice items
- `servicetitan_post_invoice` - Post invoice
- `servicetitan_void_invoice` - Void invoice
- `servicetitan_get_invoice_items` - Get invoice items
- `servicetitan_add_invoice_item` - Add invoice item
- `servicetitan_list_invoice_payments` - List payments
- `servicetitan_add_invoice_payment` - Add payment
- `servicetitan_create_payment` - Record payment
#### Estimates (6 tools)
- `servicetitan_list_estimates` - List estimates
- `servicetitan_get_estimate` - Get estimate
- `servicetitan_create_estimate` - Create estimate
- `servicetitan_update_estimate` - Update estimate
- `servicetitan_convert_estimate_to_job` - Convert to job
- `servicetitan_list_estimate_items` - List items
#### Dispatching (8 tools)
- `servicetitan_list_appointments` - List appointments
- `servicetitan_get_appointment` - Get appointment details
- `servicetitan_create_appointment` - Schedule appointment
- `servicetitan_update_appointment` - Update appointment
- `servicetitan_assign_technician` - Assign technician
- `servicetitan_cancel_appointment` - Cancel appointment
- `servicetitan_list_dispatch_zones` - List dispatch zones
- `servicetitan_get_dispatch_board` - Get dispatch board view
#### Technician Management (6 tools)
#### Technicians (8 tools)
- `servicetitan_list_technicians` - List technicians
- `servicetitan_get_technician` - Get technician details
- `servicetitan_create_technician` - Create technician
- `servicetitan_update_technician` - Update technician
- `servicetitan_get_technician_performance` - Get performance
- `servicetitan_list_technician_shifts` - List shifts
- `servicetitan_deactivate_technician` - Deactivate technician
- `servicetitan_get_technician_schedule` - Get schedule
- `servicetitan_get_technician_shifts` - Get shifts
- `servicetitan_create_technician_shift` - Create shift
#### Dispatch (4 tools)
- `servicetitan_list_dispatch_zones` - List zones
- `servicetitan_get_dispatch_board` - Get dispatch board
- `servicetitan_assign_technician` - Assign technician
- `servicetitan_get_dispatch_capacity` - Get capacity
#### Equipment (5 tools)
#### Equipment Management (6 tools)
- `servicetitan_list_equipment` - List equipment
- `servicetitan_get_equipment` - Get equipment
- `servicetitan_create_equipment` - Create equipment
- `servicetitan_get_equipment` - Get equipment details
- `servicetitan_create_equipment` - Register new equipment
- `servicetitan_update_equipment` - Update equipment
- `servicetitan_list_location_equipment` - List by location
- `servicetitan_deactivate_equipment` - Deactivate equipment
- `servicetitan_get_equipment_history` - Get service history
#### Memberships (6 tools)
#### Memberships (7 tools)
- `servicetitan_list_memberships` - List memberships
- `servicetitan_get_membership` - Get membership
- `servicetitan_get_membership` - Get membership details
- `servicetitan_create_membership` - Create membership
- `servicetitan_update_membership` - Update membership
- `servicetitan_cancel_membership` - Cancel membership
- `servicetitan_list_membership_types` - List types
- `servicetitan_renew_membership` - Renew membership
- `servicetitan_list_membership_types` - List membership types
- `servicetitan_get_membership_type` - Get membership type
#### Reporting (4 tools)
- `servicetitan_revenue_report` - Revenue analytics
- `servicetitan_technician_performance_report` - Performance metrics
- `servicetitan_job_costing_report` - Job costing
- `servicetitan_call_tracking_report` - Call tracking
#### Inventory Management (7 tools)
- `servicetitan_list_inventory_items` - List inventory items
- `servicetitan_get_inventory_item` - Get item details
- `servicetitan_create_inventory_item` - Create item/SKU
- `servicetitan_update_inventory_item` - Update item
- `servicetitan_deactivate_inventory_item` - Deactivate item
- `servicetitan_get_inventory_levels` - Get stock levels
- `servicetitan_adjust_inventory` - Adjust stock
#### Marketing (4 tools)
#### Locations (6 tools)
- `servicetitan_list_locations` - List locations
- `servicetitan_get_location` - Get location details
- `servicetitan_create_location` - Create service location
- `servicetitan_update_location` - Update location
- `servicetitan_deactivate_location` - Deactivate location
- `servicetitan_list_location_equipment` - List location equipment
#### Marketing & Leads (10 tools)
- `servicetitan_list_campaigns` - List campaigns
- `servicetitan_get_campaign` - Get campaign
- `servicetitan_get_campaign` - Get campaign details
- `servicetitan_create_campaign` - Create campaign
- `servicetitan_update_campaign` - Update campaign
- `servicetitan_list_leads` - List leads
- `servicetitan_get_lead_source_analytics` - Lead source ROI
- `servicetitan_get_lead` - Get lead details
- `servicetitan_create_lead` - Create lead
- `servicetitan_convert_lead` - Convert lead to customer
- `servicetitan_list_call_tracking` - List call tracking
- `servicetitan_get_lead_sources_report` - Get lead sources report
### 📊 **20 MCP Apps** (React-based UI)
#### Reporting & Analytics (9 tools)
- `servicetitan_get_revenue_report` - Revenue report
- `servicetitan_get_technician_performance` - Technician performance
- `servicetitan_get_job_costing_report` - Job costing report
- `servicetitan_get_sales_report` - Sales report
- `servicetitan_get_customer_acquisition_report` - Customer acquisition
- `servicetitan_get_ar_aging_report` - AR aging report
- `servicetitan_get_membership_revenue_report` - Membership revenue
- `servicetitan_get_job_type_analysis` - Job type analysis
- `servicetitan_get_dispatch_metrics` - Dispatch metrics
- **Job Dashboard** - Overview of all jobs
- **Job Detail** - Detailed job information
- **Job Grid** - Searchable job grid
- **Customer Detail** - Complete customer profile
- **Customer Grid** - Customer database
- **Invoice Dashboard** - Revenue overview
- **Invoice Detail** - Invoice line items
- **Estimate Builder** - Create estimates
- **Dispatch Board** - Visual scheduling
- **Technician Dashboard** - Performance overview
- **Technician Detail** - Individual tech stats
- **Equipment Tracker** - Equipment by location
- **Membership Manager** - Recurring memberships
- **Revenue Dashboard** - Revenue trends
- **Performance Metrics** - KPIs
- **Call Tracking** - Inbound call analytics
- **Lead Source Analytics** - Marketing ROI
- **Schedule Calendar** - Calendar view
- **Appointment Manager** - Appointment management
- **Marketing Dashboard** - Campaign performance
#### Tags (7 tools)
- `servicetitan_list_tag_types` - List tag types
- `servicetitan_create_tag_type` - Create tag type
- `servicetitan_add_job_tag` - Add job tag
- `servicetitan_remove_job_tag` - Remove job tag
- `servicetitan_list_job_tags` - List job tags
- `servicetitan_add_customer_tag` - Add customer tag
- `servicetitan_remove_customer_tag` - Remove customer tag
#### Payroll (5 tools)
- `servicetitan_list_payroll_records` - List payroll records
- `servicetitan_get_technician_timesheet` - Get timesheet
- `servicetitan_get_technician_commissions` - Get commissions
- `servicetitan_get_payroll_summary` - Get payroll summary
- `servicetitan_export_payroll` - Export payroll data
### 🎨 React Apps (15 total)
1. **Job Board** - View and manage all service jobs with filtering
2. **Job Detail** - Complete job information with appointments
3. **Customer Dashboard** - Manage customer accounts and activity
4. **Customer Detail** - Full customer profile with locations and contacts
5. **Dispatch Board** - Daily technician schedules and assignments
6. **Estimate Builder** - Create and track estimates and quotes
7. **Invoice Dashboard** - Manage invoices and track payments
8. **Technician Schedule** - View technician availability and shifts
9. **Equipment Tracker** - Monitor customer equipment and warranties
10. **Membership Manager** - Manage recurring service memberships
11. **Marketing Dashboard** - Track campaigns and lead generation
12. **Inventory Manager** - Track materials and stock levels
13. **Payroll Overview** - Track technician payroll and commissions
14. **Reporting Dashboard** - Financial reports and analytics
15. **Location Map** - Service locations directory
All apps feature:
- 🌑 Dark theme optimized for readability
- 📱 Responsive design
- ⚡ Real-time data via MCP tools
- 🎯 Intuitive filtering and search
## Installation
```bash
cd /Users/jakeshore/.clawdbot/workspace/mcpengine-repo/servers/servicetitan
npm install
cd src/ui/react-app && npm install && cd ../../..
```
## Configuration
Create a `.env` file in the server root:
Create a `.env` file:
```env
# Required
SERVICETITAN_CLIENT_ID=your_client_id
SERVICETITAN_CLIENT_SECRET=your_client_secret
SERVICETITAN_TENANT_ID=your_tenant_id
SERVICETITAN_APP_KEY=your_app_key
# Optional
SERVICETITAN_BASE_URL=https://api.servicetitan.io
PORT=3000
MODE=stdio # or "http"
```bash
SERVICETITAN_CLIENT_ID=your_client_id_here
SERVICETITAN_CLIENT_SECRET=your_client_secret_here
SERVICETITAN_TENANT_ID=your_tenant_id_here
SERVICETITAN_APP_KEY=your_app_key_here
```
### Getting ServiceTitan API Credentials
1. **Register Developer Account**
- Visit https://developer.servicetitan.io
- Sign up for developer access
1. Log into your ServiceTitan account
2. Go to Settings → Integrations → API Application Management
3. Create a new API application
4. Copy your Client ID, Client Secret, Tenant ID, and App Key
5. Configure OAuth2 scopes as needed
2. **Create Application**
- Create a new application in the developer portal
- Note your `client_id`, `client_secret`, and `app_key`
3. **Get Tenant ID**
- Your tenant ID is provided by ServiceTitan
- Usually visible in your ServiceTitan admin dashboard
## Usage
### Stdio Mode (MCP Protocol)
For use with Claude Desktop or other MCP clients:
## Build
```bash
npm run build
npm start
```
Add to your MCP client configuration (e.g., `claude_desktop_config.json`):
This will:
1. Compile TypeScript server code
2. Build all 15 React apps
3. Make the main entry point executable
## Usage
### As MCP Server
Add to your MCP client configuration:
```json
{
"mcpServers": {
"servicetitan": {
"command": "node",
"args": ["/path/to/servicetitan/dist/main.js"],
"args": ["/path/to/servers/servicetitan/dist/main.js"],
"env": {
"SERVICETITAN_CLIENT_ID": "your_client_id",
"SERVICETITAN_CLIENT_SECRET": "your_client_secret",
@ -177,99 +227,90 @@ Add to your MCP client configuration (e.g., `claude_desktop_config.json`):
}
```
### HTTP Mode (Web Apps)
For browser-based UI apps:
### Direct Usage
```bash
MODE=http PORT=3000 npm start
npm start
```
Visit http://localhost:3000/apps to access the React apps.
## API Architecture
### Authentication
- OAuth2 client_credentials flow
- Automatic token refresh
- 5-minute token expiry buffer
### Pagination
- Automatic pagination handling
- Configurable page size (default: 50, max: 500)
- `getPaginated()` for automatic multi-page fetching
### Error Handling
- Comprehensive error messages
- Rate limit detection
- Network error recovery
- 401/403 authentication errors
- 429 rate limit errors
- 500+ server errors
### API Endpoints
Base URL: `https://api.servicetitan.io`
- Jobs: `/jpm/v2/tenant/{tenant}/`
- Customers: `/crm/v2/tenant/{tenant}/`
- Invoices: `/accounting/v2/tenant/{tenant}/`
- Estimates: `/sales/v2/tenant/{tenant}/`
- Technicians: `/settings/v2/tenant/{tenant}/`
- Dispatch: `/dispatch/v2/tenant/{tenant}/`
- Equipment: `/equipment/v2/tenant/{tenant}/`
- Memberships: `/memberships/v2/tenant/{tenant}/`
- Reporting: `/reporting/v2/tenant/{tenant}/`
- Marketing: `/marketing/v2/tenant/{tenant}/`
## Development
```bash
# Build
npm run build
# Watch TypeScript compilation
npm run watch
# Watch mode
# Run in development mode
npm run dev
# Start server
npm start
# Build React apps only
npm run build:apps
```
## Project Structure
## Architecture
```
servicetitan/
├── src/
│ ├── clients/
│ │ └── servicetitan.ts # API client with OAuth2
│ ├── tools/
│ │ ├── jobs-tools.ts # Job management tools
│ │ ├── customers-tools.ts # Customer tools
│ │ ├── invoices-tools.ts # Invoice tools
│ │ ├── estimates-tools.ts # Estimate tools
│ │ ├── technicians-tools.ts # Technician tools
│ │ ├── dispatch-tools.ts # Dispatch tools
│ │ ├── equipment-tools.ts # Equipment tools
│ │ ├── memberships-tools.ts # Membership tools
│ │ ├── reporting-tools.ts # Reporting tools
│ │ └── marketing-tools.ts # Marketing tools
│ │ └── servicetitan.ts # API client with OAuth2
│ ├── types/
│ │ └── index.ts # TypeScript types
│ │ └── index.ts # TypeScript interfaces
│ ├── tools/ # 14 tool modules
│ │ ├── jobs-tools.ts
│ │ ├── customers-tools.ts
│ │ ├── estimates-tools.ts
│ │ ├── invoices-tools.ts
│ │ ├── dispatching-tools.ts
│ │ ├── technicians-tools.ts
│ │ ├── equipment-tools.ts
│ │ ├── memberships-tools.ts
│ │ ├── inventory-tools.ts
│ │ ├── locations-tools.ts
│ │ ├── marketing-tools.ts
│ │ ├── reporting-tools.ts
│ │ ├── tags-tools.ts
│ │ └── payroll-tools.ts
│ ├── ui/
│ │ └── react-app/ # 20 React MCP apps
│ ├── server.ts # MCP server
│ └── main.ts # Entry point
│ │ └── react-app/ # 15 React applications
│ │ └── src/
│ │ ├── apps/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── styles/
│ ├── server.ts # MCP server setup
│ └── main.ts # Entry point
├── package.json
├── tsconfig.json
└── README.md
```
## API Coverage
This MCP server covers all major ServiceTitan API v2 endpoints:
- ✅ Job Management (JPM)
- ✅ Customer Relationship Management (CRM)
- ✅ Sales & Estimates
- ✅ Accounting & Invoicing
- ✅ Dispatch & Scheduling
- ✅ Technician Management
- ✅ Equipment Tracking
- ✅ Memberships & Recurring Revenue
- ✅ Inventory & Materials
- ✅ Marketing & Lead Generation
- ✅ Reporting & Analytics
- ✅ Payroll & Commissions
- ✅ Settings & Configuration
## License
MIT
## Support
## Author
- ServiceTitan API Documentation: https://developer.servicetitan.io/docs
- ServiceTitan Developer Portal: https://developer.servicetitan.io
- MCP Protocol: https://modelcontextprotocol.io
MCP Engine - BusyBee3333
## Links
- [ServiceTitan API Documentation](https://developer.servicetitan.io/)
- [MCP Specification](https://modelcontextprotocol.io/)
- [GitHub Repository](https://github.com/BusyBee3333/mcpengine)

View File

@ -1,98 +0,0 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { ServiceTitanClient } from '../clients/servicetitan.js';
import type { DispatchZone, DispatchBoard } from '../types/index.js';
export function createDispatchTools(client: ServiceTitanClient): Tool[] {
return [
{
name: 'servicetitan_list_dispatch_zones',
description: 'List all dispatch zones',
inputSchema: {
type: 'object',
properties: {
active: { type: 'boolean', description: 'Filter by active status' },
businessUnitId: { type: 'number', description: 'Filter by business unit' },
},
},
},
{
name: 'servicetitan_get_dispatch_board',
description: 'Get the dispatch board for a specific date and zone',
inputSchema: {
type: 'object',
properties: {
date: { type: 'string', description: 'Date (YYYY-MM-DD)' },
zoneId: { type: 'number', description: 'Zone ID (optional)' },
businessUnitId: { type: 'number', description: 'Business unit ID' },
},
required: ['date', 'businessUnitId'],
},
},
{
name: 'servicetitan_assign_technician',
description: 'Assign a technician to an appointment',
inputSchema: {
type: 'object',
properties: {
appointmentId: { type: 'number', description: 'Appointment ID' },
technicianId: { type: 'number', description: 'Technician ID' },
},
required: ['appointmentId', 'technicianId'],
},
},
{
name: 'servicetitan_get_dispatch_capacity',
description: 'Get dispatch capacity for a date range',
inputSchema: {
type: 'object',
properties: {
startDate: { type: 'string', description: 'Start date (YYYY-MM-DD)' },
endDate: { type: 'string', description: 'End date (YYYY-MM-DD)' },
businessUnitId: { type: 'number', description: 'Business unit ID' },
zoneId: { type: 'number', description: 'Zone ID (optional)' },
},
required: ['startDate', 'endDate', 'businessUnitId'],
},
},
];
}
export async function handleDispatchTool(
client: ServiceTitanClient,
name: string,
args: any
): Promise<any> {
switch (name) {
case 'servicetitan_list_dispatch_zones':
return await client.get<DispatchZone[]>('/settings/v2/tenant/{tenant}/zones', {
active: args.active,
businessUnitId: args.businessUnitId,
});
case 'servicetitan_get_dispatch_board':
return await client.get<DispatchBoard>('/dispatch/v2/tenant/{tenant}/board', {
date: args.date,
zoneId: args.zoneId,
businessUnitId: args.businessUnitId,
});
case 'servicetitan_assign_technician':
return await client.patch(
`/jpm/v2/tenant/{tenant}/job-appointments/${args.appointmentId}/assign`,
{
technicianId: args.technicianId,
}
);
case 'servicetitan_get_dispatch_capacity':
return await client.get('/dispatch/v2/tenant/{tenant}/capacity', {
startDate: args.startDate,
endDate: args.endDate,
businessUnitId: args.businessUnitId,
zoneId: args.zoneId,
});
default:
throw new Error(`Unknown tool: ${name}`);
}
}

View File

@ -0,0 +1,176 @@
import React, { useState } from 'react';
interface Equipment {
id: number;
type: string;
brand: string;
model: string;
serialNumber: string;
customer: string;
location: string;
installDate: string;
lastService: string;
nextService: string;
warrantyExpires: string;
status: 'active' | 'needs-service' | 'warranty-expiring' | 'retired';
}
export default function EquipmentTracker() {
const [equipment] = useState<Equipment[]>([
{ id: 1, type: 'HVAC System', brand: 'Carrier', model: 'Infinity 20', serialNumber: 'CAR-2023-001', customer: 'John Smith', location: '123 Main St, Austin, TX', installDate: '2023-08-22', lastService: '2024-02-15', nextService: '2024-08-15', warrantyExpires: '2025-08-22', status: 'active' },
{ id: 2, type: 'Water Heater', brand: 'Rheem', model: 'Professional Classic', serialNumber: 'RHE-2022-045', customer: 'Sarah Johnson', location: '456 Oak Ave, Houston, TX', installDate: '2022-03-15', lastService: '2023-09-10', nextService: '2024-03-10', warrantyExpires: '2024-03-15', status: 'warranty-expiring' },
{ id: 3, type: 'Furnace', brand: 'Trane', model: 'XC95m', serialNumber: 'TRA-2023-112', customer: 'Mike Davis', location: '789 Pine St, Dallas, TX', installDate: '2023-11-05', lastService: '2024-01-20', nextService: '2024-05-05', warrantyExpires: '2026-11-05', status: 'active' },
{ id: 4, type: 'Heat Pump', brand: 'Lennox', model: 'XP25', serialNumber: 'LEN-2021-089', customer: 'Emily Wilson', location: '321 Elm St, Austin, TX', installDate: '2021-06-10', lastService: '2023-12-05', nextService: '2024-02-20', warrantyExpires: '2024-06-10', status: 'needs-service' },
]);
const [filterStatus, setFilterStatus] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
const filteredEquipment = equipment.filter(item => {
if (filterStatus !== 'all' && item.status !== filterStatus) return false;
if (searchTerm && !item.customer.toLowerCase().includes(searchTerm.toLowerCase()) && !item.serialNumber.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
});
const getStatusColor = (status: string) => {
switch(status) {
case 'active': return 'bg-green-500/20 text-green-400 border-green-500/50';
case 'needs-service': return 'bg-amber-500/20 text-amber-400 border-amber-500/50';
case 'warranty-expiring': return 'bg-orange-500/20 text-orange-400 border-orange-500/50';
case 'retired': return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
}
};
return (
<div className="min-h-screen bg-[#0f172a] text-gray-100 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2"> Equipment Tracker</h1>
<p className="text-gray-400">Monitor and manage customer equipment</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Total Equipment</div>
<div className="text-3xl font-bold text-white">{equipment.length}</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Active</div>
<div className="text-3xl font-bold text-green-400">
{equipment.filter(e => e.status === 'active').length}
</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Needs Service</div>
<div className="text-3xl font-bold text-amber-400">
{equipment.filter(e => e.status === 'needs-service').length}
</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Warranty Expiring</div>
<div className="text-3xl font-bold text-orange-400">
{equipment.filter(e => e.status === 'warranty-expiring').length}
</div>
</div>
</div>
{/* Filters */}
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
type="text"
placeholder="Search by customer or serial number..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-[#0f172a] border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="bg-[#0f172a] border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
>
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="needs-service">Needs Service</option>
<option value="warranty-expiring">Warranty Expiring</option>
<option value="retired">Retired</option>
</select>
</div>
</div>
{/* Equipment List */}
<div className="space-y-4">
{filteredEquipment.map((item) => (
<div
key={item.id}
className="bg-[#1e293b] rounded-lg p-6 border border-gray-700 hover:border-blue-500 transition-all cursor-pointer"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-semibold text-white mb-1">{item.type}</h3>
<p className="text-gray-400">{item.brand} {item.model}</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getStatusColor(item.status)}`}>
{item.status.replace('-', ' ')}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h4 className="text-sm font-semibold text-gray-400 mb-2">Equipment Info</h4>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Serial #:</span>
<span className="text-white">{item.serialNumber}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Installed:</span>
<span className="text-white">{item.installDate}</span>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-400 mb-2">Customer</h4>
<div className="space-y-1 text-sm">
<div className="text-white">{item.customer}</div>
<div className="text-gray-400">{item.location}</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-400 mb-2">Service Schedule</h4>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Last Service:</span>
<span className="text-white">{item.lastService}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Next Service:</span>
<span className="text-blue-400">{item.nextService}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Warranty Until:</span>
<span className="text-amber-400">{item.warrantyExpires}</span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
<div className="mt-4 text-sm text-gray-400">
Showing {filteredEquipment.length} of {equipment.length} items
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,234 @@
import React, { useState } from 'react';
interface Member {
id: number;
customer: string;
tier: 'Bronze' | 'Silver' | 'Gold' | 'Platinum';
joinDate: string;
renewalDate: string;
monthlyFee: number;
benefits: string[];
servicesUsed: number;
totalSavings: number;
status: 'active' | 'expiring-soon' | 'expired';
}
export default function MembershipManager() {
const [members] = useState<Member[]>([
{
id: 1,
customer: 'John Smith',
tier: 'Gold',
joinDate: '2022-03-15',
renewalDate: '2024-03-15',
monthlyFee: 49.99,
benefits: ['Priority Service', '15% Discount', 'Annual Inspection'],
servicesUsed: 8,
totalSavings: 850,
status: 'active',
},
{
id: 2,
customer: 'Sarah Johnson',
tier: 'Silver',
joinDate: '2023-01-10',
renewalDate: '2024-01-10',
monthlyFee: 34.99,
benefits: ['10% Discount', 'Seasonal Inspection'],
servicesUsed: 5,
totalSavings: 420,
status: 'active',
},
{
id: 3,
customer: 'Mike Davis',
tier: 'Platinum',
joinDate: '2021-06-20',
renewalDate: '2024-06-20',
monthlyFee: 79.99,
benefits: ['VIP Service', '20% Discount', 'Quarterly Inspections', 'Free Parts'],
servicesUsed: 15,
totalSavings: 2100,
status: 'active',
},
{
id: 4,
customer: 'Emily Wilson',
tier: 'Bronze',
joinDate: '2023-09-05',
renewalDate: '2024-02-28',
monthlyFee: 24.99,
benefits: ['5% Discount'],
servicesUsed: 3,
totalSavings: 150,
status: 'expiring-soon',
},
]);
const [tierFilter, setTierFilter] = useState('all');
const stats = {
totalMembers: members.length,
activeMembers: members.filter(m => m.status === 'active').length,
expiringCount: members.filter(m => m.status === 'expiring-soon').length,
monthlyRevenue: members.reduce((sum, m) => sum + (m.status === 'active' ? m.monthlyFee : 0), 0),
};
const filteredMembers = members.filter(member => {
if (tierFilter !== 'all' && member.tier !== tierFilter) return false;
return true;
});
const getTierColor = (tier: string) => {
switch(tier) {
case 'Platinum': return 'bg-purple-500/20 text-purple-400 border-purple-500/50';
case 'Gold': return 'bg-amber-500/20 text-amber-400 border-amber-500/50';
case 'Silver': return 'bg-slate-400/20 text-slate-300 border-slate-400/50';
case 'Bronze': return 'bg-orange-700/20 text-orange-400 border-orange-700/50';
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
}
};
const getStatusColor = (status: string) => {
switch(status) {
case 'active': return 'bg-green-500/20 text-green-400';
case 'expiring-soon': return 'bg-amber-500/20 text-amber-400';
case 'expired': return 'bg-red-500/20 text-red-400';
default: return 'bg-gray-500/20 text-gray-400';
}
};
return (
<div className="min-h-screen bg-[#0f172a] text-gray-100 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">💎 Membership Manager</h1>
<p className="text-gray-400">Manage customer memberships and benefits</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Total Members</div>
<div className="text-3xl font-bold text-white">{stats.totalMembers}</div>
<div className="text-green-400 text-sm mt-2"> 12%</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Active Members</div>
<div className="text-3xl font-bold text-green-400">{stats.activeMembers}</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Expiring Soon</div>
<div className="text-3xl font-bold text-amber-400">{stats.expiringCount}</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Monthly Revenue</div>
<div className="text-3xl font-bold text-green-400">${stats.monthlyRevenue.toFixed(2)}</div>
</div>
</div>
{/* Filters */}
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700 mb-6">
<select
value={tierFilter}
onChange={(e) => setTierFilter(e.target.value)}
className="bg-[#0f172a] border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
>
<option value="all">All Tiers</option>
<option value="Platinum">Platinum</option>
<option value="Gold">Gold</option>
<option value="Silver">Silver</option>
<option value="Bronze">Bronze</option>
</select>
</div>
{/* Members List */}
<div className="space-y-4">
{filteredMembers.map((member) => (
<div
key={member.id}
className="bg-[#1e293b] rounded-lg p-6 border border-gray-700 hover:border-blue-500 transition-all cursor-pointer"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-semibold text-white mb-2">{member.customer}</h3>
<div className="flex gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getTierColor(member.tier)}`}>
{member.tier}
</span>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(member.status)}`}>
{member.status.replace('-', ' ')}
</span>
</div>
</div>
<div className="text-right">
<div className="text-gray-400 text-sm">Monthly Fee</div>
<div className="text-green-400 font-bold text-xl">${member.monthlyFee}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-4">
<div>
<h4 className="text-sm font-semibold text-gray-400 mb-2">Membership Info</h4>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Join Date:</span>
<span className="text-white">{member.joinDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Renewal:</span>
<span className="text-white">{member.renewalDate}</span>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-400 mb-2">Usage & Savings</h4>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Services Used:</span>
<span className="text-blue-400 font-medium">{member.servicesUsed}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Total Savings:</span>
<span className="text-green-400 font-medium">${member.totalSavings}</span>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-400 mb-2">Benefits</h4>
<div className="flex flex-wrap gap-1">
{member.benefits.map((benefit, idx) => (
<span key={idx} className="px-2 py-1 bg-blue-500/10 text-blue-400 rounded text-xs border border-blue-500/30">
{benefit}
</span>
))}
</div>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-gray-700">
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors">
View Details
</button>
<button className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm transition-colors">
Renew
</button>
{member.tier !== 'Platinum' && (
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm transition-colors">
Upgrade
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,149 @@
import React, { useState } from 'react';
interface RevenueData {
period: string;
revenue: number;
jobs: number;
avgTicket: number;
}
export default function RevenueDashboard() {
const [timeRange, setTimeRange] = useState<'week' | 'month' | 'year'>('month');
const [monthlyData] = useState<RevenueData[]>([
{ period: 'Jan', revenue: 145000, jobs: 156, avgTicket: 929 },
{ period: 'Feb', revenue: 168000, jobs: 178, avgTicket: 944 },
{ period: 'Mar', revenue: 152000, jobs: 165, avgTicket: 921 },
{ period: 'Apr', revenue: 178000, jobs: 189, avgTicket: 942 },
{ period: 'May', revenue: 195000, jobs: 205, avgTicket: 951 },
{ period: 'Jun', revenue: 210000, jobs: 225, avgTicket: 933 },
]);
const currentMonth = monthlyData[monthlyData.length - 1];
const lastMonth = monthlyData[monthlyData.length - 2];
const revenueGrowth = ((currentMonth.revenue - lastMonth.revenue) / lastMonth.revenue * 100).toFixed(1);
const totalRevenue = monthlyData.reduce((sum, d) => sum + d.revenue, 0);
const totalJobs = monthlyData.reduce((sum, d) => sum + d.jobs, 0);
const avgTicket = totalRevenue / totalJobs;
const maxRevenue = Math.max(...monthlyData.map(d => d.revenue));
return (
<div className="min-h-screen bg-[#0f172a] text-gray-100 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-white mb-2">💵 Revenue Dashboard</h1>
<p className="text-gray-400">Track financial performance and trends</p>
</div>
<div className="flex gap-2 bg-[#1e293b] rounded-lg p-1 border border-gray-700">
{(['week', 'month', 'year'] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 rounded-md transition-colors capitalize ${
timeRange === range ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
}`}
>
{range}
</button>
))}
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Total Revenue (YTD)</div>
<div className="text-3xl font-bold text-green-400">${totalRevenue.toLocaleString()}</div>
<div className="text-green-400 text-sm mt-2"> {revenueGrowth}% vs last month</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">This Month</div>
<div className="text-3xl font-bold text-white">${currentMonth.revenue.toLocaleString()}</div>
<div className="text-green-400 text-sm mt-2"> ${(currentMonth.revenue - lastMonth.revenue).toLocaleString()}</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Total Jobs</div>
<div className="text-3xl font-bold text-blue-400">{totalJobs}</div>
<div className="text-green-400 text-sm mt-2"> 8%</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Avg Ticket</div>
<div className="text-3xl font-bold text-purple-400">${Math.round(avgTicket)}</div>
<div className="text-green-400 text-sm mt-2"> 2%</div>
</div>
</div>
{/* Revenue Chart */}
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700 mb-6">
<h3 className="text-lg font-semibold text-white mb-6">Revenue Trend</h3>
<div className="h-64 flex items-end justify-between gap-2">
{monthlyData.map((data, idx) => {
const height = (data.revenue / maxRevenue) * 100;
return (
<div key={idx} className="flex-1 flex flex-col items-center">
<div className="w-full relative group">
<div
className="w-full bg-gradient-to-t from-blue-600 to-blue-400 rounded-t transition-all hover:from-blue-500 hover:to-blue-300"
style={{ height: `${height * 2.4}px` }}
>
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-[#0f172a] px-2 py-1 rounded text-xs text-white opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
${data.revenue.toLocaleString()}
</div>
</div>
</div>
<div className="mt-2 text-sm text-gray-400">{data.period}</div>
</div>
);
})}
</div>
</div>
{/* Breakdown Table */}
<div className="bg-[#1e293b] rounded-lg border border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-[#0f172a]">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Period</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">Revenue</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">Jobs</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">Avg Ticket</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">Growth</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{monthlyData.map((data, idx) => {
const prevData = idx > 0 ? monthlyData[idx - 1] : null;
const growth = prevData ? ((data.revenue - prevData.revenue) / prevData.revenue * 100).toFixed(1) : '0.0';
return (
<tr key={idx} className="hover:bg-[#0f172a] transition-colors">
<td className="px-6 py-4 text-sm font-medium text-white">{data.period}</td>
<td className="px-6 py-4 text-sm text-right text-green-400 font-bold">
${data.revenue.toLocaleString()}
</td>
<td className="px-6 py-4 text-sm text-right text-blue-400">{data.jobs}</td>
<td className="px-6 py-4 text-sm text-right text-purple-400">${data.avgTicket}</td>
<td className="px-6 py-4 text-sm text-right">
<span className={parseFloat(growth) >= 0 ? 'text-green-400' : 'text-red-400'}>
{parseFloat(growth) >= 0 ? '↑' : '↓'} {Math.abs(parseFloat(growth))}%
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,252 @@
import React, { useState } from 'react';
interface TechDetail {
id: number;
name: string;
email: string;
phone: string;
status: string;
certifications: string[];
specialties: string[];
stats: {
jobsCompleted: number;
avgRating: number;
totalRevenue: number;
efficiency: number;
};
schedule: Array<{
date: string;
jobs: Array<{
jobNumber: string;
customer: string;
timeWindow: string;
status: string;
}>;
}>;
}
export default function TechnicianDetail() {
const [tech] = useState<TechDetail>({
id: 1,
name: 'Mike Johnson',
email: 'mike.johnson@company.com',
phone: '(555) 987-6543',
status: 'on-job',
certifications: ['HVAC Certified', 'EPA 608 Universal', 'NATE Certified'],
specialties: ['HVAC Systems', 'Heat Pumps', 'Air Quality'],
stats: {
jobsCompleted: 342,
avgRating: 4.8,
totalRevenue: 145600,
efficiency: 92,
},
schedule: [
{
date: '2024-02-15',
jobs: [
{ jobNumber: 'J-2024-001', customer: 'John Smith', timeWindow: '9:00 AM - 11:00 AM', status: 'in-progress' },
{ jobNumber: 'J-2024-004', customer: 'Emily Wilson', timeWindow: '1:00 PM - 3:00 PM', status: 'scheduled' },
{ jobNumber: 'J-2024-007', customer: 'James Martinez', timeWindow: '3:30 PM - 5:30 PM', status: 'scheduled' },
],
},
{
date: '2024-02-16',
jobs: [
{ jobNumber: 'J-2024-012', customer: 'Linda Davis', timeWindow: '8:00 AM - 10:00 AM', status: 'scheduled' },
{ jobNumber: 'J-2024-015', customer: 'Robert Taylor', timeWindow: '11:00 AM - 1:00 PM', status: 'scheduled' },
],
},
],
});
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'performance'>('overview');
const getStatusColor = (status: string) => {
switch(status) {
case 'scheduled': return 'bg-blue-500/20 text-blue-400';
case 'in-progress': return 'bg-amber-500/20 text-amber-400';
case 'completed': return 'bg-green-500/20 text-green-400';
default: return 'bg-gray-500/20 text-gray-400';
}
};
return (
<div className="min-h-screen bg-[#0f172a] text-gray-100 p-6">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">{tech.name}</h1>
<p className="text-gray-400">Technician #{tech.id}</p>
</div>
<div className="flex gap-3">
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
Edit Profile
</button>
<button className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
Assign Job
</button>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
tech.status === 'available' ? 'bg-green-500/20 text-green-400' :
tech.status === 'on-job' ? 'bg-blue-500/20 text-blue-400' :
'bg-gray-500/20 text-gray-400'
}`}>
{tech.status.replace('-', ' ')}
</span>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Jobs Completed</div>
<div className="text-3xl font-bold text-white">{tech.stats.jobsCompleted}</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Avg Rating</div>
<div className="text-3xl font-bold text-amber-400">{tech.stats.avgRating} </div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Total Revenue</div>
<div className="text-3xl font-bold text-green-400">${tech.stats.totalRevenue.toLocaleString()}</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<div className="text-gray-400 text-sm mb-1">Efficiency</div>
<div className="text-3xl font-bold text-purple-400">{tech.stats.efficiency}%</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6 border-b border-gray-700">
<div className="flex gap-6">
{(['overview', 'schedule', 'performance'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`pb-3 px-1 text-sm font-medium capitalize transition-colors ${
activeTab === tab
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-gray-400 hover:text-gray-300'
}`}
>
{tab}
</button>
))}
</div>
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Contact Information</h3>
<div className="space-y-3">
<div>
<div className="text-gray-400 text-sm">Email</div>
<div className="text-white">{tech.email}</div>
</div>
<div>
<div className="text-gray-400 text-sm">Phone</div>
<div className="text-white">{tech.phone}</div>
</div>
</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Certifications</h3>
<div className="flex flex-wrap gap-2">
{tech.certifications.map((cert, idx) => (
<span key={idx} className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm border border-blue-500/50">
{cert}
</span>
))}
</div>
</div>
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700 md:col-span-2">
<h3 className="text-lg font-semibold text-white mb-4">Specialties</h3>
<div className="flex flex-wrap gap-2">
{tech.specialties.map((specialty, idx) => (
<span key={idx} className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm border border-green-500/50">
{specialty}
</span>
))}
</div>
</div>
</div>
)}
{/* Schedule Tab */}
{activeTab === 'schedule' && (
<div className="space-y-6">
{tech.schedule.map((day, idx) => (
<div key={idx} className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">{day.date}</h3>
<div className="space-y-3">
{day.jobs.map((job, jIdx) => (
<div key={jIdx} className="bg-[#0f172a] rounded-lg p-4 border border-gray-700 hover:border-blue-500 transition-all">
<div className="flex justify-between items-start">
<div>
<div className="text-blue-400 font-semibold mb-1">{job.jobNumber}</div>
<div className="text-white">{job.customer}</div>
<div className="text-sm text-gray-400 mt-1"> {job.timeWindow}</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(job.status)}`}>
{job.status}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Performance Tab */}
{activeTab === 'performance' && (
<div className="bg-[#1e293b] rounded-lg p-6 border border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Performance Metrics</h3>
<div className="space-y-6">
<div>
<div className="flex justify-between mb-2">
<span className="text-gray-400">Job Completion Rate</span>
<span className="text-white font-semibold">96%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-green-500 h-2 rounded-full" style={{ width: '96%' }} />
</div>
</div>
<div>
<div className="flex justify-between mb-2">
<span className="text-gray-400">Customer Satisfaction</span>
<span className="text-white font-semibold">94%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full" style={{ width: '94%' }} />
</div>
</div>
<div>
<div className="flex justify-between mb-2">
<span className="text-gray-400">On-Time Arrival</span>
<span className="text-white font-semibold">89%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div className="bg-amber-500 h-2 rounded-full" style={{ width: '89%' }} />
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
// Get all app directories
const appsDir = path.resolve(__dirname, 'src/apps');
const apps = fs.readdirSync(appsDir).filter(file => {
return fs.statSync(path.join(appsDir, file)).isDirectory();
});
// Build configuration for all apps
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: apps.reduce((acc, app) => {
acc[app] = path.resolve(appsDir, app, 'index.html');
return acc;
}, {} as Record<string, string>),
},
outDir: path.resolve(__dirname, '../../dist/apps'),
emptyOutDir: true,
},
});