Factory V2 complete: all 30 MCP servers with tools + apps — final cleanup
This commit is contained in:
parent
4c546d654a
commit
14f651082c
789
servers/brevo/src/api-client.ts
Normal file
789
servers/brevo/src/api-client.ts
Normal file
@ -0,0 +1,789 @@
|
||||
/**
|
||||
* Brevo API Client
|
||||
* API v3: https://api.brevo.com/v3/
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||
import type {
|
||||
BrevoConfig,
|
||||
Contact,
|
||||
CreateContactParams,
|
||||
UpdateContactParams,
|
||||
ContactList,
|
||||
EmailCampaign,
|
||||
CreateEmailCampaignParams,
|
||||
SendSmtpEmail,
|
||||
SendSmtpEmailResponse,
|
||||
SendTransacSms,
|
||||
SendTransacSmsResponse,
|
||||
SmsCampaign,
|
||||
CreateSmsCampaignParams,
|
||||
EmailTemplate,
|
||||
CreateEmailTemplateParams,
|
||||
Sender,
|
||||
CreateSenderParams,
|
||||
Automation,
|
||||
Deal,
|
||||
CreateDealParams,
|
||||
Company,
|
||||
CreateCompanyParams,
|
||||
Task,
|
||||
CreateTaskParams,
|
||||
Webhook,
|
||||
CreateWebhookParams,
|
||||
AccountInfo,
|
||||
EmailEventReport,
|
||||
Folder,
|
||||
CreateFolderParams,
|
||||
Process,
|
||||
CreateDoiContactParams,
|
||||
PaginationParams,
|
||||
DateRangeParams,
|
||||
} from './types/index.js';
|
||||
|
||||
export class BrevoAPIClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor(config: BrevoConfig) {
|
||||
this.client = axios.create({
|
||||
baseURL: config.baseUrl || 'https://api.brevo.com/v3',
|
||||
headers: {
|
||||
'api-key': config.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Error interceptor
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const errorMessage = error.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: error.message;
|
||||
throw new Error(`Brevo API Error: ${errorMessage}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTACTS
|
||||
// ============================================================================
|
||||
|
||||
async getContact(identifier: string): Promise<Contact> {
|
||||
const { data } = await this.client.get(`/contacts/${encodeURIComponent(identifier)}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createContact(params: CreateContactParams): Promise<{ id: number }> {
|
||||
const { data } = await this.client.post('/contacts', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateContact(identifier: string, params: UpdateContactParams): Promise<void> {
|
||||
await this.client.put(`/contacts/${encodeURIComponent(identifier)}`, params);
|
||||
}
|
||||
|
||||
async deleteContact(identifier: string): Promise<void> {
|
||||
await this.client.delete(`/contacts/${encodeURIComponent(identifier)}`);
|
||||
}
|
||||
|
||||
async getContacts(params?: PaginationParams & { modifiedSince?: string; listIds?: number[]; sort?: string }): Promise<{ contacts: Contact[]; count: number }> {
|
||||
const { data } = await this.client.get('/contacts', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async importContacts(params: {
|
||||
fileBody?: string;
|
||||
fileUrl?: string;
|
||||
listIds?: number[];
|
||||
notifyUrl?: string;
|
||||
newList?: { listName: string; folderId?: number };
|
||||
emailBlacklist?: boolean;
|
||||
smsBlacklist?: boolean;
|
||||
updateExistingContacts?: boolean;
|
||||
emptyContactsAttributes?: boolean;
|
||||
}): Promise<{ processId: number }> {
|
||||
const { data } = await this.client.post('/contacts/import', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async exportContacts(params: {
|
||||
exportAttributes?: string[];
|
||||
filter?: Record<string, any>;
|
||||
notifyUrl?: string;
|
||||
}): Promise<{ exportId: string }> {
|
||||
const { data } = await this.client.post('/contacts/export', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async addContactToList(listId: number, contactEmails: string[]): Promise<{ contacts: { success: string[]; failure: string[] } }> {
|
||||
const { data } = await this.client.post(`/contacts/lists/${listId}/contacts/add`, { emails: contactEmails });
|
||||
return data;
|
||||
}
|
||||
|
||||
async removeContactFromList(listId: number, contactEmails: string[]): Promise<{ contacts: { success: string[]; failure: string[] } }> {
|
||||
const { data } = await this.client.post(`/contacts/lists/${listId}/contacts/remove`, { emails: contactEmails });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getContactStats(identifier: string): Promise<any> {
|
||||
const { data } = await this.client.get(`/contacts/${encodeURIComponent(identifier)}/campaignStats`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getContactAttributes(): Promise<{ attributes: any[] }> {
|
||||
const { data } = await this.client.get('/contacts/attributes');
|
||||
return data;
|
||||
}
|
||||
|
||||
async createAttribute(params: {
|
||||
value?: string;
|
||||
enumeration?: { value: number; label: string }[];
|
||||
type?: 'text' | 'date' | 'float' | 'boolean' | 'id';
|
||||
isRecurring?: boolean;
|
||||
category?: 'normal' | 'transactional' | 'category' | 'calculated' | 'global';
|
||||
}): Promise<void> {
|
||||
await this.client.post('/contacts/attributes/normal', params);
|
||||
}
|
||||
|
||||
async createDoiContact(params: CreateDoiContactParams): Promise<void> {
|
||||
await this.client.post('/contacts/doubleOptinConfirmation', params);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LISTS
|
||||
// ============================================================================
|
||||
|
||||
async getLists(params?: PaginationParams & { sort?: string }): Promise<{ lists: ContactList[]; count: number }> {
|
||||
const { data } = await this.client.get('/contacts/lists', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getList(listId: number): Promise<ContactList> {
|
||||
const { data } = await this.client.get(`/contacts/lists/${listId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createList(params: { name: string; folderId?: number }): Promise<{ id: number }> {
|
||||
const { data } = await this.client.post('/contacts/lists', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateList(listId: number, params: { name?: string; folderId?: number }): Promise<void> {
|
||||
await this.client.put(`/contacts/lists/${listId}`, params);
|
||||
}
|
||||
|
||||
async deleteList(listId: number): Promise<void> {
|
||||
await this.client.delete(`/contacts/lists/${listId}`);
|
||||
}
|
||||
|
||||
async getContactsFromList(listId: number, params?: PaginationParams & { modifiedSince?: string; sort?: string }): Promise<{ contacts: Contact[]; count: number }> {
|
||||
const { data } = await this.client.get(`/contacts/lists/${listId}/contacts`, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FOLDERS
|
||||
// ============================================================================
|
||||
|
||||
async getFolders(params?: { limit?: number; offset?: number }): Promise<{ folders: Folder[]; count: number }> {
|
||||
const { data } = await this.client.get('/contacts/folders', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getFolder(folderId: number): Promise<Folder> {
|
||||
const { data } = await this.client.get(`/contacts/folders/${folderId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createFolder(params: CreateFolderParams): Promise<{ id: number }> {
|
||||
const { data } = await this.client.post('/contacts/folders', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateFolder(folderId: number, params: { name: string }): Promise<void> {
|
||||
await this.client.put(`/contacts/folders/${folderId}`, params);
|
||||
}
|
||||
|
||||
async deleteFolder(folderId: number): Promise<void> {
|
||||
await this.client.delete(`/contacts/folders/${folderId}`);
|
||||
}
|
||||
|
||||
async getListsInFolder(folderId: number, params?: PaginationParams & { sort?: string }): Promise<{ lists: ContactList[]; count: number }> {
|
||||
const { data } = await this.client.get(`/contacts/folders/${folderId}/lists`, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EMAIL CAMPAIGNS
|
||||
// ============================================================================
|
||||
|
||||
async getEmailCampaigns(params?: {
|
||||
type?: 'classic' | 'trigger';
|
||||
status?: 'draft' | 'sent' | 'queued' | 'suspended' | 'archive';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
excludeHtmlContent?: boolean;
|
||||
}): Promise<{ campaigns: EmailCampaign[]; count: number }> {
|
||||
const { data } = await this.client.get('/emailCampaigns', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getEmailCampaign(campaignId: number): Promise<EmailCampaign> {
|
||||
const { data } = await this.client.get(`/emailCampaigns/${campaignId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createEmailCampaign(params: CreateEmailCampaignParams): Promise<{ id: number }> {
|
||||
const { data } = await this.client.post('/emailCampaigns', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateEmailCampaign(campaignId: number, params: Partial<CreateEmailCampaignParams>): Promise<void> {
|
||||
await this.client.put(`/emailCampaigns/${campaignId}`, params);
|
||||
}
|
||||
|
||||
async deleteEmailCampaign(campaignId: number): Promise<void> {
|
||||
await this.client.delete(`/emailCampaigns/${campaignId}`);
|
||||
}
|
||||
|
||||
async sendEmailCampaignNow(campaignId: number): Promise<void> {
|
||||
await this.client.post(`/emailCampaigns/${campaignId}/sendNow`);
|
||||
}
|
||||
|
||||
async sendTestEmail(campaignId: number, emailTo: string[]): Promise<void> {
|
||||
await this.client.post(`/emailCampaigns/${campaignId}/sendTest`, { emailTo });
|
||||
}
|
||||
|
||||
async updateEmailCampaignStatus(campaignId: number, status: 'draft' | 'suspended' | 'archive'): Promise<void> {
|
||||
await this.client.put(`/emailCampaigns/${campaignId}/status`, { status });
|
||||
}
|
||||
|
||||
async getEmailCampaignReport(campaignId: number, params?: DateRangeParams): Promise<any> {
|
||||
const { data } = await this.client.get(`/emailCampaigns/${campaignId}/report`, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async exportEmailCampaignRecipients(campaignId: number, params?: {
|
||||
notifyURL?: string;
|
||||
recipientsType?: 'all' | 'nonClickers' | 'nonOpeners' | 'clickers' | 'openers' | 'softBounces' | 'hardBounces' | 'unsubscribed';
|
||||
}): Promise<{ exportId: string }> {
|
||||
const { data } = await this.client.post(`/emailCampaigns/${campaignId}/exportRecipients`, params);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRANSACTIONAL EMAILS (SMTP)
|
||||
// ============================================================================
|
||||
|
||||
async sendTransacEmail(params: SendSmtpEmail): Promise<SendSmtpEmailResponse> {
|
||||
const { data } = await this.client.post('/smtp/email', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTransacEmailContent(uuid: string): Promise<any> {
|
||||
const { data } = await this.client.get(`/smtp/emails/${uuid}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteScheduledEmail(identifier: string): Promise<void> {
|
||||
await this.client.delete(`/smtp/email/${identifier}`);
|
||||
}
|
||||
|
||||
async getTransacEmailEvents(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
email?: string;
|
||||
templateId?: number;
|
||||
messageId?: string;
|
||||
tags?: string;
|
||||
event?: string;
|
||||
days?: number;
|
||||
sort?: string;
|
||||
} & DateRangeParams): Promise<EmailEventReport> {
|
||||
const { data } = await this.client.get('/smtp/statistics/events', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTransacEmailStats(params?: DateRangeParams & { days?: number; tag?: string }): Promise<any> {
|
||||
const { data } = await this.client.get('/smtp/statistics/aggregatedReport', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTransacEmailReports(params?: DateRangeParams & { days?: number; tag?: string; sort?: string }): Promise<any> {
|
||||
const { data } = await this.client.get('/smtp/statistics/reports', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getBlockedDomains(): Promise<{ domains: string[] }> {
|
||||
const { data } = await this.client.get('/smtp/blockedDomains');
|
||||
return data;
|
||||
}
|
||||
|
||||
async blockDomain(domain: string): Promise<void> {
|
||||
await this.client.post('/smtp/blockedDomains', { domain });
|
||||
}
|
||||
|
||||
async unblockDomain(domain: string): Promise<void> {
|
||||
await this.client.delete(`/smtp/blockedDomains/${domain}`);
|
||||
}
|
||||
|
||||
async getBlockedEmails(): Promise<{ emails: any[] }> {
|
||||
const { data } = await this.client.get('/smtp/blockedContacts');
|
||||
return data;
|
||||
}
|
||||
|
||||
async blockEmail(params: { emails: string[] }): Promise<void> {
|
||||
await this.client.post('/smtp/blockedContacts', params);
|
||||
}
|
||||
|
||||
async unblockEmail(email: string): Promise<void> {
|
||||
await this.client.delete(`/smtp/blockedContacts/${email}`);
|
||||
}
|
||||
|
||||
async getEmailActivityLog(messageId: string): Promise<any> {
|
||||
const { data } = await this.client.get(`/smtp/emails/${messageId}/activity`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EMAIL TEMPLATES
|
||||
// ============================================================================
|
||||
|
||||
async getEmailTemplates(params?: { templateStatus?: boolean; limit?: number; offset?: number; sort?: string }): Promise<{ templates: EmailTemplate[]; count: number }> {
|
||||
const { data } = await this.client.get('/smtp/templates', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getEmailTemplate(templateId: number): Promise<EmailTemplate> {
|
||||
const { data } = await this.client.get(`/smtp/templates/${templateId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createEmailTemplate(params: CreateEmailTemplateParams): Promise<{ id: number }> {
|
||||
const { data } = await this.client.post('/smtp/templates', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateEmailTemplate(templateId: number, params: Partial<CreateEmailTemplateParams>): Promise<void> {
|
||||
await this.client.put(`/smtp/templates/${templateId}`, params);
|
||||
}
|
||||
|
||||
async deleteEmailTemplate(templateId: number): Promise<void> {
|
||||
await this.client.delete(`/smtp/templates/${templateId}`);
|
||||
}
|
||||
|
||||
async sendTestEmailTemplate(templateId: number, params: { emailTo: string[] }): Promise<void> {
|
||||
await this.client.post(`/smtp/templates/${templateId}/sendTest`, params);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SENDERS
|
||||
// ============================================================================
|
||||
|
||||
async getSenders(params?: { ip?: string; domain?: string }): Promise<{ senders: Sender[] }> {
|
||||
const { data } = await this.client.get('/senders', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getSender(senderId: number): Promise<Sender> {
|
||||
const { data } = await this.client.get(`/senders/${senderId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createSender(params: CreateSenderParams): Promise<{ id: number }> {
|
||||
const { data } = await this.client.post('/senders', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateSender(senderId: number, params: { name?: string; email?: string; ips?: { ip: string; domain: string }[] }): Promise<void> {
|
||||
await this.client.put(`/senders/${senderId}`, params);
|
||||
}
|
||||
|
||||
async deleteSender(senderId: number): Promise<void> {
|
||||
await this.client.delete(`/senders/${senderId}`);
|
||||
}
|
||||
|
||||
async validateSenderDomain(domain: string): Promise<any> {
|
||||
const { data } = await this.client.get(`/senders/domains/${domain}/validate`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async authenticateSenderDomain(domain: string): Promise<any> {
|
||||
const { data } = await this.client.put(`/senders/domains/${domain}/authenticate`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SMS
|
||||
// ============================================================================
|
||||
|
||||
async sendTransacSms(params: SendTransacSms): Promise<SendTransacSmsResponse> {
|
||||
const { data } = await this.client.post('/transactionalSMS/sms', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTransacSmsEvents(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
phoneNumber?: string;
|
||||
event?: string;
|
||||
tags?: string;
|
||||
sort?: string;
|
||||
days?: number;
|
||||
} & DateRangeParams): Promise<any> {
|
||||
const { data } = await this.client.get('/transactionalSMS/statistics/events', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTransacSmsReports(params?: DateRangeParams & { days?: number; tag?: string; sort?: string }): Promise<any> {
|
||||
const { data } = await this.client.get('/transactionalSMS/statistics/reports', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTransacSmsStats(params?: DateRangeParams & { days?: number; tag?: string }): Promise<any> {
|
||||
const { data } = await this.client.get('/transactionalSMS/statistics/aggregatedReport', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getSmsCampaigns(params?: {
|
||||
status?: 'draft' | 'sent' | 'queued' | 'suspended' | 'inProgress';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
} & DateRangeParams): Promise<{ campaigns: SmsCampaign[]; count: number }> {
|
||||
const { data } = await this.client.get('/smsCampaigns', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getSmsCampaign(campaignId: number): Promise<SmsCampaign> {
|
||||
const { data } = await this.client.get(`/smsCampaigns/${campaignId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createSmsCampaign(params: CreateSmsCampaignParams): Promise<{ id: number }> {
|
||||
const { data } = await this.client.post('/smsCampaigns', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateSmsCampaign(campaignId: number, params: Partial<CreateSmsCampaignParams>): Promise<void> {
|
||||
await this.client.put(`/smsCampaigns/${campaignId}`, params);
|
||||
}
|
||||
|
||||
async deleteSmsCampaign(campaignId: number): Promise<void> {
|
||||
await this.client.delete(`/smsCampaigns/${campaignId}`);
|
||||
}
|
||||
|
||||
async sendSmsCampaignNow(campaignId: number): Promise<void> {
|
||||
await this.client.post(`/smsCampaigns/${campaignId}/sendNow`);
|
||||
}
|
||||
|
||||
async sendTestSms(campaignId: number, phoneNumber: string): Promise<void> {
|
||||
await this.client.post(`/smsCampaigns/${campaignId}/sendTest`, { phoneNumber });
|
||||
}
|
||||
|
||||
async updateSmsCampaignStatus(campaignId: number, status: 'draft' | 'suspended' | 'archive'): Promise<void> {
|
||||
await this.client.put(`/smsCampaigns/${campaignId}/status`, { status });
|
||||
}
|
||||
|
||||
async requestSmsSender(senderName: string): Promise<void> {
|
||||
await this.client.post('/senders/sms', { senderName });
|
||||
}
|
||||
|
||||
async getSmsSenders(params?: { limit?: number; offset?: number }): Promise<{ senders: any[] }> {
|
||||
const { data } = await this.client.get('/senders/sms', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTOMATIONS (WORKFLOWS)
|
||||
// ============================================================================
|
||||
|
||||
async getAutomations(params?: { limit?: number; offset?: number; sort?: string }): Promise<{ automations: Automation[] }> {
|
||||
const { data } = await this.client.get('/automation/workflows', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getAutomation(workflowId: number): Promise<Automation> {
|
||||
const { data } = await this.client.get(`/automation/workflows/${workflowId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteAutomation(workflowId: number): Promise<void> {
|
||||
await this.client.delete(`/automation/workflows/${workflowId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRM - DEALS
|
||||
// ============================================================================
|
||||
|
||||
async getDeals(params?: {
|
||||
'filters[attributes.deal_name]'?: string;
|
||||
'filters[attributes.deal_stage]'?: string;
|
||||
'filters[linkedContactsIds]'?: number;
|
||||
'filters[linkedCompaniesIds]'?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
}): Promise<{ items: Deal[] }> {
|
||||
const { data } = await this.client.get('/crm/deals', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getDeal(dealId: string): Promise<Deal> {
|
||||
const { data } = await this.client.get(`/crm/deals/${dealId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createDeal(params: CreateDealParams): Promise<{ id: string }> {
|
||||
const { data } = await this.client.post('/crm/deals', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateDeal(dealId: string, params: Partial<CreateDealParams>): Promise<void> {
|
||||
await this.client.patch(`/crm/deals/${dealId}`, params);
|
||||
}
|
||||
|
||||
async deleteDeal(dealId: string): Promise<void> {
|
||||
await this.client.delete(`/crm/deals/${dealId}`);
|
||||
}
|
||||
|
||||
async linkDealWithContact(dealId: string, contactId: number): Promise<void> {
|
||||
await this.client.patch(`/crm/deals/link-unlink/${dealId}`, {
|
||||
linkContactIds: [contactId],
|
||||
});
|
||||
}
|
||||
|
||||
async unlinkDealFromContact(dealId: string, contactId: number): Promise<void> {
|
||||
await this.client.patch(`/crm/deals/link-unlink/${dealId}`, {
|
||||
unlinkContactIds: [contactId],
|
||||
});
|
||||
}
|
||||
|
||||
async getDealsPipeline(): Promise<any> {
|
||||
const { data } = await this.client.get('/crm/pipeline/details');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getDealAttributes(): Promise<{ attributes: any[] }> {
|
||||
const { data } = await this.client.get('/crm/deals/attributes');
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRM - COMPANIES
|
||||
// ============================================================================
|
||||
|
||||
async getCompanies(params?: {
|
||||
'filters[attributes.name]'?: string;
|
||||
'filters[linkedContactsIds]'?: number;
|
||||
'filters[linkedDealsIds]'?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
}): Promise<{ items: Company[] }> {
|
||||
const { data } = await this.client.get('/crm/companies', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getCompany(companyId: string): Promise<Company> {
|
||||
const { data } = await this.client.get(`/crm/companies/${companyId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createCompany(params: CreateCompanyParams): Promise<{ id: string }> {
|
||||
const { data } = await this.client.post('/crm/companies', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateCompany(companyId: string, params: Partial<CreateCompanyParams>): Promise<void> {
|
||||
await this.client.patch(`/crm/companies/${companyId}`, params);
|
||||
}
|
||||
|
||||
async deleteCompany(companyId: string): Promise<void> {
|
||||
await this.client.delete(`/crm/companies/${companyId}`);
|
||||
}
|
||||
|
||||
async linkCompanyWithContact(companyId: string, contactId: number): Promise<void> {
|
||||
await this.client.patch(`/crm/companies/link-unlink/${companyId}`, {
|
||||
linkContactIds: [contactId],
|
||||
});
|
||||
}
|
||||
|
||||
async unlinkCompanyFromContact(companyId: string, contactId: number): Promise<void> {
|
||||
await this.client.patch(`/crm/companies/link-unlink/${companyId}`, {
|
||||
unlinkContactIds: [contactId],
|
||||
});
|
||||
}
|
||||
|
||||
async getCompanyAttributes(): Promise<{ attributes: any[] }> {
|
||||
const { data } = await this.client.get('/crm/companies/attributes');
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRM - TASKS
|
||||
// ============================================================================
|
||||
|
||||
async getTasks(params?: {
|
||||
'filterType'?: 'contacts' | 'companies' | 'deals';
|
||||
'filterId'?: string;
|
||||
'dateFrom'?: string;
|
||||
'dateTo'?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
}): Promise<{ items: Task[] }> {
|
||||
const { data } = await this.client.get('/crm/tasks', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTask(taskId: string): Promise<Task> {
|
||||
const { data } = await this.client.get(`/crm/tasks/${taskId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createTask(params: CreateTaskParams): Promise<{ id: string }> {
|
||||
const { data } = await this.client.post('/crm/tasks', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateTask(taskId: string, params: Partial<CreateTaskParams>): Promise<void> {
|
||||
await this.client.patch(`/crm/tasks/${taskId}`, params);
|
||||
}
|
||||
|
||||
async deleteTask(taskId: string): Promise<void> {
|
||||
await this.client.delete(`/crm/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
async getTaskTypes(): Promise<{ taskTypes: any[] }> {
|
||||
const { data } = await this.client.get('/crm/tasktypes');
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRM - NOTES
|
||||
// ============================================================================
|
||||
|
||||
async getNotes(params?: {
|
||||
entity?: 'companies' | 'deals' | 'contacts';
|
||||
entityIds?: string;
|
||||
dateFrom?: number;
|
||||
dateTo?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
}): Promise<{ items: any[] }> {
|
||||
const { data } = await this.client.get('/crm/notes', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getNote(noteId: string): Promise<any> {
|
||||
const { data } = await this.client.get(`/crm/notes/${noteId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createNote(params: {
|
||||
text: string;
|
||||
contactIds?: number[];
|
||||
dealIds?: string[];
|
||||
companyIds?: string[];
|
||||
}): Promise<{ id: string }> {
|
||||
const { data } = await this.client.post('/crm/notes', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateNote(noteId: string, params: { text: string }): Promise<void> {
|
||||
await this.client.patch(`/crm/notes/${noteId}`, params);
|
||||
}
|
||||
|
||||
async deleteNote(noteId: string): Promise<void> {
|
||||
await this.client.delete(`/crm/notes/${noteId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WEBHOOKS
|
||||
// ============================================================================
|
||||
|
||||
async getWebhooks(params?: { type?: 'marketing' | 'transactional' | 'inbound'; sort?: string }): Promise<{ webhooks: Webhook[] }> {
|
||||
const { data } = await this.client.get('/webhooks', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getWebhook(webhookId: number): Promise<Webhook> {
|
||||
const { data } = await this.client.get(`/webhooks/${webhookId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createWebhook(params: CreateWebhookParams): Promise<{ id: number }> {
|
||||
const { data } = await this.client.post('/webhooks', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateWebhook(webhookId: number, params: Partial<CreateWebhookParams>): Promise<void> {
|
||||
await this.client.put(`/webhooks/${webhookId}`, params);
|
||||
}
|
||||
|
||||
async deleteWebhook(webhookId: number): Promise<void> {
|
||||
await this.client.delete(`/webhooks/${webhookId}`);
|
||||
}
|
||||
|
||||
async exportWebhookEvents(webhookId: number, params?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
days?: number;
|
||||
messageId?: string;
|
||||
email?: string;
|
||||
event?: string;
|
||||
notifyURL?: string;
|
||||
}): Promise<{ exportId: string }> {
|
||||
const { data } = await this.client.post(`/webhooks/${webhookId}/export`, params);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACCOUNT
|
||||
// ============================================================================
|
||||
|
||||
async getAccount(): Promise<AccountInfo> {
|
||||
const { data } = await this.client.get('/account');
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROCESSES
|
||||
// ============================================================================
|
||||
|
||||
async getProcess(processId: number): Promise<Process> {
|
||||
const { data } = await this.client.get(`/processes/${processId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getProcesses(params?: { limit?: number; offset?: number; sort?: string }): Promise<{ processes: Process[] }> {
|
||||
const { data } = await this.client.get('/processes', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INBOUND PARSING
|
||||
// ============================================================================
|
||||
|
||||
async getInboundEmailEvents(params?: {
|
||||
sender?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
}): Promise<{ events: any[] }> {
|
||||
const { data } = await this.client.get('/inbound/events', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getInboundEmailAttachment(downloadToken: string): Promise<any> {
|
||||
const { data } = await this.client.get(`/inbound/attachments/${downloadToken}`);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
779
servers/close/src/api/client.ts
Normal file
779
servers/close/src/api/client.ts
Normal file
@ -0,0 +1,779 @@
|
||||
/**
|
||||
* Close CRM API Client
|
||||
* Full implementation for Close API v1 with Basic Auth
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||
import type {
|
||||
CloseConfig,
|
||||
Lead,
|
||||
Contact,
|
||||
Opportunity,
|
||||
Activity,
|
||||
Task,
|
||||
Pipeline,
|
||||
SmartView,
|
||||
User,
|
||||
CustomField,
|
||||
Sequence,
|
||||
SequenceSubscription,
|
||||
PaginatedResponse,
|
||||
SearchParams,
|
||||
LeadSearchParams,
|
||||
OpportunitySearchParams,
|
||||
} from '../types.js';
|
||||
|
||||
export class CloseAPIClient {
|
||||
private client: AxiosInstance;
|
||||
private apiKey: string;
|
||||
|
||||
constructor(config: CloseConfig) {
|
||||
this.apiKey = config.apiKey;
|
||||
this.client = axios.create({
|
||||
baseURL: config.baseUrl || 'https://api.close.com/api/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
auth: {
|
||||
username: config.apiKey,
|
||||
password: '', // Close uses API key as username with empty password
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(error: any): never {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError;
|
||||
if (axiosError.response) {
|
||||
throw new Error(
|
||||
`Close API Error: ${axiosError.response.status} - ${
|
||||
JSON.stringify(axiosError.response.data) || axiosError.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
throw new Error(`Close API Request Failed: ${axiosError.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LEADS
|
||||
// ============================================================================
|
||||
|
||||
async getLeads(params?: LeadSearchParams): Promise<PaginatedResponse<Lead>> {
|
||||
try {
|
||||
const response = await this.client.get('/lead/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getLead(id: string): Promise<Lead> {
|
||||
try {
|
||||
const response = await this.client.get(`/lead/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createLead(data: Partial<Lead>): Promise<Lead> {
|
||||
try {
|
||||
const response = await this.client.post('/lead/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateLead(id: string, data: Partial<Lead>): Promise<Lead> {
|
||||
try {
|
||||
const response = await this.client.put(`/lead/${id}/`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteLead(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/lead/${id}/`);
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async searchLeads(query: string, params?: SearchParams): Promise<PaginatedResponse<Lead>> {
|
||||
try {
|
||||
const response = await this.client.get('/lead/', {
|
||||
params: { query, ...params },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async mergeLeads(source: string, destination: string): Promise<Lead> {
|
||||
try {
|
||||
const response = await this.client.post('/lead/merge/', {
|
||||
source,
|
||||
destination,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTACTS
|
||||
// ============================================================================
|
||||
|
||||
async getContacts(params?: SearchParams): Promise<PaginatedResponse<Contact>> {
|
||||
try {
|
||||
const response = await this.client.get('/contact/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getContact(id: string): Promise<Contact> {
|
||||
try {
|
||||
const response = await this.client.get(`/contact/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createContact(data: Partial<Contact>): Promise<Contact> {
|
||||
try {
|
||||
const response = await this.client.post('/contact/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateContact(id: string, data: Partial<Contact>): Promise<Contact> {
|
||||
try {
|
||||
const response = await this.client.put(`/contact/${id}/`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteContact(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/contact/${id}/`);
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPPORTUNITIES
|
||||
// ============================================================================
|
||||
|
||||
async getOpportunities(params?: OpportunitySearchParams): Promise<PaginatedResponse<Opportunity>> {
|
||||
try {
|
||||
const response = await this.client.get('/opportunity/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getOpportunity(id: string): Promise<Opportunity> {
|
||||
try {
|
||||
const response = await this.client.get(`/opportunity/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createOpportunity(data: Partial<Opportunity>): Promise<Opportunity> {
|
||||
try {
|
||||
const response = await this.client.post('/opportunity/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateOpportunity(id: string, data: Partial<Opportunity>): Promise<Opportunity> {
|
||||
try {
|
||||
const response = await this.client.put(`/opportunity/${id}/`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOpportunity(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/opportunity/${id}/`);
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACTIVITIES
|
||||
// ============================================================================
|
||||
|
||||
async getActivities(params?: SearchParams & { lead_id?: string }): Promise<PaginatedResponse<Activity>> {
|
||||
try {
|
||||
const response = await this.client.get('/activity/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getActivity(id: string): Promise<Activity> {
|
||||
try {
|
||||
const response = await this.client.get(`/activity/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Email Activities
|
||||
async createEmail(data: any): Promise<Activity> {
|
||||
try {
|
||||
const response = await this.client.post('/activity/email/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(data: any): Promise<Activity> {
|
||||
try {
|
||||
const response = await this.client.post('/activity/email/', {
|
||||
...data,
|
||||
status: 'sent',
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Call Activities
|
||||
async createCall(data: any): Promise<Activity> {
|
||||
try {
|
||||
const response = await this.client.post('/activity/call/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateCall(id: string, data: any): Promise<Activity> {
|
||||
try {
|
||||
const response = await this.client.put(`/activity/call/${id}/`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Note Activities
|
||||
async createNote(data: { lead_id?: string; contact_id?: string; note: string }): Promise<Activity> {
|
||||
try {
|
||||
const response = await this.client.post('/activity/note/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// SMS Activities
|
||||
async sendSMS(data: any): Promise<Activity> {
|
||||
try {
|
||||
const response = await this.client.post('/activity/sms/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Meeting Activities
|
||||
async createMeeting(data: any): Promise<Activity> {
|
||||
try {
|
||||
const response = await this.client.post('/activity/meeting/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TASKS
|
||||
// ============================================================================
|
||||
|
||||
async getTasks(params?: SearchParams & { lead_id?: string; is_complete?: boolean }): Promise<PaginatedResponse<Task>> {
|
||||
try {
|
||||
const response = await this.client.get('/task/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<Task> {
|
||||
try {
|
||||
const response = await this.client.get(`/task/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createTask(data: Partial<Task>): Promise<Task> {
|
||||
try {
|
||||
const response = await this.client.post('/task/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTask(id: string, data: Partial<Task>): Promise<Task> {
|
||||
try {
|
||||
const response = await this.client.put(`/task/${id}/`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTask(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/task/${id}/`);
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async completeTask(id: string): Promise<Task> {
|
||||
try {
|
||||
const response = await this.client.put(`/task/${id}/`, {
|
||||
is_complete: true,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PIPELINES
|
||||
// ============================================================================
|
||||
|
||||
async getPipelines(): Promise<{ data: Pipeline[] }> {
|
||||
try {
|
||||
const response = await this.client.get('/status/opportunity/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getPipeline(id: string): Promise<Pipeline> {
|
||||
try {
|
||||
const response = await this.client.get(`/status/opportunity/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getLeadStatuses(): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get('/status/lead/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SMART VIEWS
|
||||
// ============================================================================
|
||||
|
||||
async getSmartViews(params?: { type?: string }): Promise<PaginatedResponse<SmartView>> {
|
||||
try {
|
||||
const response = await this.client.get('/saved_search/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getSmartView(id: string): Promise<SmartView> {
|
||||
try {
|
||||
const response = await this.client.get(`/saved_search/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createSmartView(data: Partial<SmartView>): Promise<SmartView> {
|
||||
try {
|
||||
const response = await this.client.post('/saved_search/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateSmartView(id: string, data: Partial<SmartView>): Promise<SmartView> {
|
||||
try {
|
||||
const response = await this.client.put(`/saved_search/${id}/`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSmartView(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/saved_search/${id}/`);
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USERS
|
||||
// ============================================================================
|
||||
|
||||
async getUsers(): Promise<{ data: User[] }> {
|
||||
try {
|
||||
const response = await this.client.get('/user/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUser(id: string): Promise<User> {
|
||||
try {
|
||||
const response = await this.client.get(`/user/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<User> {
|
||||
try {
|
||||
const response = await this.client.get('/me/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOM FIELDS
|
||||
// ============================================================================
|
||||
|
||||
async getCustomFields(type?: 'lead' | 'contact' | 'opportunity'): Promise<{ data: CustomField[] }> {
|
||||
try {
|
||||
const response = await this.client.get('/custom_fields/', {
|
||||
params: type ? { type } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomField(id: string): Promise<CustomField> {
|
||||
try {
|
||||
const response = await this.client.get(`/custom_fields/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createCustomField(data: Partial<CustomField>): Promise<CustomField> {
|
||||
try {
|
||||
const response = await this.client.post('/custom_fields/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateCustomField(id: string, data: Partial<CustomField>): Promise<CustomField> {
|
||||
try {
|
||||
const response = await this.client.put(`/custom_fields/${id}/`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomField(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/custom_fields/${id}/`);
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEQUENCES
|
||||
// ============================================================================
|
||||
|
||||
async getSequences(): Promise<{ data: Sequence[] }> {
|
||||
try {
|
||||
const response = await this.client.get('/sequence/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getSequence(id: string): Promise<Sequence> {
|
||||
try {
|
||||
const response = await this.client.get(`/sequence/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToSequence(data: {
|
||||
sequence_id: string;
|
||||
contact_id: string;
|
||||
sender_account_id?: string;
|
||||
}): Promise<SequenceSubscription> {
|
||||
try {
|
||||
const response = await this.client.post('/sequence_subscription/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getSequenceSubscriptions(params?: {
|
||||
sequence_id?: string;
|
||||
contact_id?: string;
|
||||
lead_id?: string;
|
||||
}): Promise<PaginatedResponse<SequenceSubscription>> {
|
||||
try {
|
||||
const response = await this.client.get('/sequence_subscription/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async pauseSequenceSubscription(id: string): Promise<SequenceSubscription> {
|
||||
try {
|
||||
const response = await this.client.post(`/sequence_subscription/${id}/pause/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async resumeSequenceSubscription(id: string): Promise<SequenceSubscription> {
|
||||
try {
|
||||
const response = await this.client.post(`/sequence_subscription/${id}/resume/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribeFromSequence(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/sequence_subscription/${id}/`);
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BULK OPERATIONS
|
||||
// ============================================================================
|
||||
|
||||
async bulkDeleteLeads(leadIds: string[]): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.post('/lead/bulk_delete/', {
|
||||
lead_ids: leadIds,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async bulkUpdateLeads(leadIds: string[], data: Partial<Lead>): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.post('/lead/bulk_update/', {
|
||||
lead_ids: leadIds,
|
||||
...data,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async bulkEmailLeads(leadIds: string[], emailData: any): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.post('/lead/bulk_email/', {
|
||||
lead_ids: leadIds,
|
||||
...emailData,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDeleteContacts(contactIds: string[]): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.post('/contact/bulk_delete/', {
|
||||
contact_ids: contactIds,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REPORTING & ANALYTICS
|
||||
// ============================================================================
|
||||
|
||||
async getLeadsReport(params?: any): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get('/report/lead/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getOpportunitiesReport(params?: any): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get('/report/opportunity/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getActivitiesReport(params?: any): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get('/report/activity/', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserActivityReport(userId: string, params?: any): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get(`/report/user/${userId}/`, { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORGANIZATION
|
||||
// ============================================================================
|
||||
|
||||
async getOrganization(): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get('/organization/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WEBHOOKS
|
||||
// ============================================================================
|
||||
|
||||
async getWebhooks(): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get('/webhook/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createWebhook(data: { url: string; events: string[] }): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.post('/webhook/', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWebhook(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/webhook/${id}/`);
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EMAIL TEMPLATES
|
||||
// ============================================================================
|
||||
|
||||
async getEmailTemplates(): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get('/email_template/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getEmailTemplate(id: string): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get(`/email_template/${id}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONNECTED ACCOUNTS
|
||||
// ============================================================================
|
||||
|
||||
async getConnectedAccounts(): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get('/connected_account/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
133
servers/close/src/apps/ContactsManager.tsx
Normal file
133
servers/close/src/apps/ContactsManager.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Contact, Mail, Phone, Plus, Search } from 'lucide-react';
|
||||
|
||||
export const ContactsManager: React.FC = () => {
|
||||
const [contacts, setContacts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const loadContacts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await window.mcp?.callTool('close_list_contacts', { _limit: 100 });
|
||||
if (result?.content?.[0]?.text) {
|
||||
const data = JSON.parse(result.content[0].text);
|
||||
setContacts(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contacts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadContacts();
|
||||
}, []);
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
c.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.emails?.some((e: any) => e.email.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ background: '#1a1a1a', color: '#e0e0e0', minHeight: '100vh', padding: '20px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Contact size={32} color="#8b5cf6" />
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold', margin: 0 }}>Contacts</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.mcp?.showPrompt('Create a new contact')}
|
||||
style={{
|
||||
background: '#8b5cf6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Contact
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search
|
||||
size={20}
|
||||
style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: '#666' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search contacts..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#2a2a2a',
|
||||
border: '1px solid #3a3a3a',
|
||||
color: '#e0e0e0',
|
||||
padding: '12px 12px 12px 44px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>Loading...</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
|
||||
{filteredContacts.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
onClick={() => window.mcp?.showPrompt(`Show contact ${contact.id}`)}
|
||||
style={{
|
||||
background: '#2a2a2a',
|
||||
border: '1px solid #3a3a3a',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = '#8b5cf6')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = '#3a3a3a')}
|
||||
>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', marginBottom: '4px' }}>
|
||||
{contact.name || 'Unnamed Contact'}
|
||||
</div>
|
||||
{contact.title && (
|
||||
<div style={{ fontSize: '13px', color: '#888', marginBottom: '12px' }}>
|
||||
{contact.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{contact.emails?.[0] && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>
|
||||
<Mail size={14} color="#666" />
|
||||
<span style={{ color: '#888' }}>{contact.emails[0].email}</span>
|
||||
</div>
|
||||
)}
|
||||
{contact.phones?.[0] && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>
|
||||
<Phone size={14} color="#666" />
|
||||
<span style={{ color: '#888' }}>{contact.phones[0].phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactsManager;
|
||||
154
servers/close/src/apps/LeadsDashboard.tsx
Normal file
154
servers/close/src/apps/LeadsDashboard.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Search, Filter, Plus } from 'lucide-react';
|
||||
|
||||
export const LeadsDashboard: React.FC = () => {
|
||||
const [leads, setLeads] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const loadLeads = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await window.mcp?.callTool('close_list_leads', {
|
||||
query: searchQuery,
|
||||
_limit: 50,
|
||||
});
|
||||
if (result?.content?.[0]?.text) {
|
||||
const data = JSON.parse(result.content[0].text);
|
||||
setLeads(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load leads:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLeads();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ background: '#1a1a1a', color: '#e0e0e0', minHeight: '100vh', padding: '20px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Users size={32} color="#3b82f6" />
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold', margin: 0 }}>Leads Dashboard</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.mcp?.showPrompt('Create a new lead')}
|
||||
style={{
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Lead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search
|
||||
size={20}
|
||||
style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: '#666' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search leads..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && loadLeads()}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#2a2a2a',
|
||||
border: '1px solid #3a3a3a',
|
||||
color: '#e0e0e0',
|
||||
padding: '12px 12px 12px 44px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ background: '#2a2a2a', padding: '16px', borderRadius: '8px', border: '1px solid #3a3a3a' }}>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>Total Leads</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#3b82f6' }}>{leads.length}</div>
|
||||
</div>
|
||||
<div style={{ background: '#2a2a2a', padding: '16px', borderRadius: '8px', border: '1px solid #3a3a3a' }}>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>Active</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#10b981' }}>
|
||||
{leads.filter(l => l.status_label?.toLowerCase().includes('active')).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leads Table */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>Loading...</div>
|
||||
) : (
|
||||
<div style={{ background: '#2a2a2a', borderRadius: '8px', border: '1px solid #3a3a3a', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#333', borderBottom: '1px solid #3a3a3a' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#888' }}>Name</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#888' }}>Status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#888' }}>Contacts</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#888' }}>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((lead) => (
|
||||
<tr
|
||||
key={lead.id}
|
||||
onClick={() => window.mcp?.showPrompt(`Show details for lead ${lead.id}`)}
|
||||
style={{ borderBottom: '1px solid #3a3a3a', cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#333')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div style={{ fontWeight: '500' }}>{lead.display_name}</div>
|
||||
{lead.url && <div style={{ fontSize: '12px', color: '#666' }}>{lead.url}</div>}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span
|
||||
style={{
|
||||
background: '#3b82f622',
|
||||
color: '#3b82f6',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
{lead.status_label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: '#888' }}>{lead.contacts?.length || 0}</td>
|
||||
<td style={{ padding: '12px', color: '#888', fontSize: '12px' }}>
|
||||
{new Date(lead.date_created).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeadsDashboard;
|
||||
147
servers/close/src/apps/OpportunitiesPipeline.tsx
Normal file
147
servers/close/src/apps/OpportunitiesPipeline.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrendingUp, DollarSign, Target } from 'lucide-react';
|
||||
|
||||
export const OpportunitiesPipeline: React.FC = () => {
|
||||
const [opportunities, setOpportunities] = useState<any[]>([]);
|
||||
const [pipelines, setPipelines] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [oppsResult, pipelinesResult] = await Promise.all([
|
||||
window.mcp?.callTool('close_list_opportunities', { _limit: 100 }),
|
||||
window.mcp?.callTool('close_get_opportunity_pipelines', {}),
|
||||
]);
|
||||
|
||||
if (oppsResult?.content?.[0]?.text) {
|
||||
const data = JSON.parse(oppsResult.content[0].text);
|
||||
setOpportunities(data.data || []);
|
||||
}
|
||||
if (pipelinesResult?.content?.[0]?.text) {
|
||||
const data = JSON.parse(pipelinesResult.content[0].text);
|
||||
setPipelines(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalValue = opportunities.reduce((sum, opp) => sum + (opp.value || 0), 0) / 100;
|
||||
const activeOpps = opportunities.filter(o => o.status_type === 'active').length;
|
||||
const wonOpps = opportunities.filter(o => o.status_type === 'won').length;
|
||||
|
||||
return (
|
||||
<div style={{ background: '#1a1a1a', color: '#e0e0e0', minHeight: '100vh', padding: '20px' }}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '24px' }}>
|
||||
<TrendingUp size={32} color="#10b981" />
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold', margin: 0 }}>Opportunities Pipeline</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '16px', marginBottom: '32px' }}>
|
||||
<div style={{ background: '#2a2a2a', padding: '20px', borderRadius: '8px', border: '1px solid #3a3a3a' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<DollarSign size={20} color="#10b981" />
|
||||
<span style={{ fontSize: '12px', color: '#888' }}>Total Pipeline Value</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#10b981' }}>
|
||||
${totalValue.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#2a2a2a', padding: '20px', borderRadius: '8px', border: '1px solid #3a3a3a' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<Target size={20} color="#3b82f6" />
|
||||
<span style={{ fontSize: '12px', color: '#888' }}>Active Opportunities</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#3b82f6' }}>
|
||||
{activeOpps}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#2a2a2a', padding: '20px', borderRadius: '8px', border: '1px solid #3a3a3a' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<TrendingUp size={20} color="#f59e0b" />
|
||||
<span style={{ fontSize: '12px', color: '#888' }}>Won This Month</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#f59e0b' }}>
|
||||
{wonOpps}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Board */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>Loading pipeline...</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '16px', overflowX: 'auto', paddingBottom: '16px' }}>
|
||||
{['active', 'won', 'lost'].map((statusType) => {
|
||||
const statusOpps = opportunities.filter(o => o.status_type === statusType);
|
||||
const statusValue = statusOpps.reduce((sum, opp) => sum + (opp.value || 0), 0) / 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={statusType}
|
||||
style={{
|
||||
minWidth: '320px',
|
||||
background: '#2a2a2a',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #3a3a3a',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', textTransform: 'capitalize', marginBottom: '4px' }}>
|
||||
{statusType}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
{statusOpps.length} deals · ${statusValue.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{statusOpps.map((opp) => (
|
||||
<div
|
||||
key={opp.id}
|
||||
onClick={() => window.mcp?.showPrompt(`Show opportunity ${opp.id}`)}
|
||||
style={{
|
||||
background: '#333',
|
||||
border: '1px solid #3a3a3a',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = '#3b82f6')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = '#3a3a3a')}
|
||||
>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '4px' }}>
|
||||
{opp.lead_name || `Opportunity ${opp.id.slice(0, 8)}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold', color: '#10b981', marginBottom: '4px' }}>
|
||||
${((opp.value || 0) / 100).toLocaleString()}
|
||||
</div>
|
||||
{opp.confidence !== undefined && (
|
||||
<div style={{ fontSize: '11px', color: '#888' }}>
|
||||
{opp.confidence}% confidence
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpportunitiesPipeline;
|
||||
976
servers/close/src/tools/index.ts
Normal file
976
servers/close/src/tools/index.ts
Normal file
@ -0,0 +1,976 @@
|
||||
/**
|
||||
* Close CRM MCP Tools - 60+ Tools
|
||||
* Complete tool definitions for MCP server
|
||||
*/
|
||||
|
||||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export const CLOSE_TOOLS: Tool[] = [
|
||||
// ============================================================================
|
||||
// LEAD TOOLS (10 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_leads',
|
||||
description: 'List all leads with optional filtering and pagination',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
status_id: { type: 'string', description: 'Filter by status ID' },
|
||||
user_id: { type: 'string', description: 'Filter by assigned user ID' },
|
||||
_skip: { type: 'number', description: 'Number of records to skip (pagination)' },
|
||||
_limit: { type: 'number', description: 'Max number of records to return (default: 100)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_lead',
|
||||
description: 'Get a specific lead by ID with all details',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Lead ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_lead',
|
||||
description: 'Create a new lead',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Company/lead name' },
|
||||
description: { type: 'string', description: 'Lead description' },
|
||||
url: { type: 'string', description: 'Company website URL' },
|
||||
status_id: { type: 'string', description: 'Lead status ID' },
|
||||
contacts: {
|
||||
type: 'array',
|
||||
description: 'Array of contacts to create with this lead',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
custom: { type: 'object', description: 'Custom field values (key-value pairs)' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_update_lead',
|
||||
description: 'Update an existing lead',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Lead ID' },
|
||||
name: { type: 'string', description: 'Company/lead name' },
|
||||
description: { type: 'string', description: 'Lead description' },
|
||||
url: { type: 'string', description: 'Company website URL' },
|
||||
status_id: { type: 'string', description: 'Lead status ID' },
|
||||
custom: { type: 'object', description: 'Custom field values' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_delete_lead',
|
||||
description: 'Delete a lead permanently',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Lead ID to delete' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_search_leads',
|
||||
description: 'Search leads with advanced query',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query (supports field:value syntax)' },
|
||||
_limit: { type: 'number', description: 'Max results' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_merge_leads',
|
||||
description: 'Merge two leads (moves all data from source to destination)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string', description: 'Source lead ID (will be deleted)' },
|
||||
destination: { type: 'string', description: 'Destination lead ID (will receive all data)' },
|
||||
},
|
||||
required: ['source', 'destination'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_lead_statuses',
|
||||
description: 'Get all available lead statuses',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_bulk_update_leads',
|
||||
description: 'Update multiple leads at once',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of lead IDs to update',
|
||||
},
|
||||
status_id: { type: 'string', description: 'New status ID' },
|
||||
custom: { type: 'object', description: 'Custom field updates' },
|
||||
},
|
||||
required: ['lead_ids'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_bulk_delete_leads',
|
||||
description: 'Delete multiple leads at once',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of lead IDs to delete',
|
||||
},
|
||||
},
|
||||
required: ['lead_ids'],
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// CONTACT TOOLS (7 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_contacts',
|
||||
description: 'List all contacts with pagination',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Filter by lead ID' },
|
||||
_skip: { type: 'number', description: 'Pagination skip' },
|
||||
_limit: { type: 'number', description: 'Pagination limit' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_contact',
|
||||
description: 'Get a specific contact by ID',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Contact ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_contact',
|
||||
description: 'Create a new contact',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Lead ID this contact belongs to' },
|
||||
name: { type: 'string', description: 'Contact full name' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
emails: {
|
||||
type: 'array',
|
||||
description: 'Array of email objects [{email: "...", type: "office"}]',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
phones: {
|
||||
type: 'array',
|
||||
description: 'Array of phone objects [{phone: "...", type: "mobile"}]',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
},
|
||||
required: ['lead_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_update_contact',
|
||||
description: 'Update an existing contact',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Contact ID' },
|
||||
name: { type: 'string', description: 'Contact name' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
emails: { type: 'array', items: { type: 'object' } },
|
||||
phones: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_delete_contact',
|
||||
description: 'Delete a contact',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Contact ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_bulk_delete_contacts',
|
||||
description: 'Delete multiple contacts at once',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
contact_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of contact IDs to delete',
|
||||
},
|
||||
},
|
||||
required: ['contact_ids'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_search_contacts',
|
||||
description: 'Search contacts by name, email, or phone',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
_limit: { type: 'number', description: 'Max results' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// OPPORTUNITY TOOLS (8 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_opportunities',
|
||||
description: 'List all opportunities with filtering',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Filter by lead ID' },
|
||||
status_id: { type: 'string', description: 'Filter by status ID' },
|
||||
status_type: {
|
||||
type: 'string',
|
||||
enum: ['active', 'won', 'lost'],
|
||||
description: 'Filter by status type',
|
||||
},
|
||||
user_id: { type: 'string', description: 'Filter by assigned user' },
|
||||
_skip: { type: 'number' },
|
||||
_limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_opportunity',
|
||||
description: 'Get a specific opportunity by ID',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Opportunity ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_opportunity',
|
||||
description: 'Create a new opportunity/deal',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Lead ID' },
|
||||
status_id: { type: 'string', description: 'Pipeline status ID' },
|
||||
value: { type: 'number', description: 'Deal value in cents' },
|
||||
value_period: {
|
||||
type: 'string',
|
||||
enum: ['one_time', 'monthly', 'annual'],
|
||||
description: 'Value period',
|
||||
},
|
||||
confidence: { type: 'number', description: 'Win probability (0-100)' },
|
||||
note: { type: 'string', description: 'Opportunity note' },
|
||||
contact_id: { type: 'string', description: 'Primary contact ID' },
|
||||
},
|
||||
required: ['lead_id', 'status_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_update_opportunity',
|
||||
description: 'Update an existing opportunity',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Opportunity ID' },
|
||||
status_id: { type: 'string', description: 'New status ID' },
|
||||
value: { type: 'number', description: 'Deal value' },
|
||||
confidence: { type: 'number', description: 'Win probability' },
|
||||
note: { type: 'string', description: 'Note' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_delete_opportunity',
|
||||
description: 'Delete an opportunity',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Opportunity ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_mark_opportunity_won',
|
||||
description: 'Mark an opportunity as won',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Opportunity ID' },
|
||||
status_id: { type: 'string', description: 'Won status ID' },
|
||||
date_won: { type: 'string', description: 'Won date (ISO 8601)' },
|
||||
},
|
||||
required: ['id', 'status_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_mark_opportunity_lost',
|
||||
description: 'Mark an opportunity as lost',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Opportunity ID' },
|
||||
status_id: { type: 'string', description: 'Lost status ID' },
|
||||
date_lost: { type: 'string', description: 'Lost date (ISO 8601)' },
|
||||
},
|
||||
required: ['id', 'status_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_opportunity_pipelines',
|
||||
description: 'Get all opportunity pipelines and their statuses',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// ACTIVITY TOOLS (12 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_activities',
|
||||
description: 'List activities (emails, calls, notes, etc.)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Filter by lead ID' },
|
||||
contact_id: { type: 'string', description: 'Filter by contact ID' },
|
||||
_skip: { type: 'number' },
|
||||
_limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_activity',
|
||||
description: 'Get a specific activity by ID',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Activity ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_send_email',
|
||||
description: 'Send an email to a lead or contact',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Lead ID' },
|
||||
contact_id: { type: 'string', description: 'Contact ID' },
|
||||
to: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of recipient email addresses',
|
||||
},
|
||||
subject: { type: 'string', description: 'Email subject' },
|
||||
body_text: { type: 'string', description: 'Plain text body' },
|
||||
body_html: { type: 'string', description: 'HTML body' },
|
||||
cc: { type: 'array', items: { type: 'string' }, description: 'CC recipients' },
|
||||
bcc: { type: 'array', items: { type: 'string' }, description: 'BCC recipients' },
|
||||
},
|
||||
required: ['to', 'subject'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_email_draft',
|
||||
description: 'Create an email draft (not sent)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string' },
|
||||
contact_id: { type: 'string' },
|
||||
to: { type: 'array', items: { type: 'string' } },
|
||||
subject: { type: 'string' },
|
||||
body_text: { type: 'string' },
|
||||
body_html: { type: 'string' },
|
||||
},
|
||||
required: ['to', 'subject'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_log_call',
|
||||
description: 'Log a phone call activity',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Lead ID' },
|
||||
contact_id: { type: 'string', description: 'Contact ID' },
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['inbound', 'outbound'],
|
||||
description: 'Call direction',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['completed', 'canceled', 'no-answer', 'busy', 'failed'],
|
||||
description: 'Call outcome',
|
||||
},
|
||||
duration: { type: 'number', description: 'Call duration in seconds' },
|
||||
note: { type: 'string', description: 'Call notes' },
|
||||
phone: { type: 'string', description: 'Phone number called' },
|
||||
},
|
||||
required: ['direction', 'status'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_update_call',
|
||||
description: 'Update a call activity (e.g., add notes after call)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Call activity ID' },
|
||||
note: { type: 'string', description: 'Call notes' },
|
||||
disposition: { type: 'string', description: 'Call disposition' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_note',
|
||||
description: 'Add a note to a lead or contact',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Lead ID' },
|
||||
contact_id: { type: 'string', description: 'Contact ID' },
|
||||
note: { type: 'string', description: 'Note content' },
|
||||
},
|
||||
required: ['note'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_send_sms',
|
||||
description: 'Send an SMS message',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Lead ID' },
|
||||
contact_id: { type: 'string', description: 'Contact ID' },
|
||||
text: { type: 'string', description: 'SMS message text' },
|
||||
remote_phone: { type: 'string', description: 'Recipient phone number' },
|
||||
local_phone: { type: 'string', description: 'Sender phone number' },
|
||||
},
|
||||
required: ['text', 'remote_phone'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_meeting',
|
||||
description: 'Schedule a meeting activity',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Lead ID' },
|
||||
contact_id: { type: 'string', description: 'Contact ID' },
|
||||
title: { type: 'string', description: 'Meeting title' },
|
||||
note: { type: 'string', description: 'Meeting notes/agenda' },
|
||||
starts_at: { type: 'string', description: 'Meeting start time (ISO 8601)' },
|
||||
ends_at: { type: 'string', description: 'Meeting end time (ISO 8601)' },
|
||||
location: { type: 'string', description: 'Meeting location' },
|
||||
},
|
||||
required: ['starts_at'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_bulk_email',
|
||||
description: 'Send bulk email to multiple leads',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of lead IDs',
|
||||
},
|
||||
subject: { type: 'string', description: 'Email subject' },
|
||||
body_text: { type: 'string', description: 'Email body (plain text)' },
|
||||
body_html: { type: 'string', description: 'Email body (HTML)' },
|
||||
},
|
||||
required: ['lead_ids', 'subject'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_email_templates',
|
||||
description: 'List all email templates',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_email_template',
|
||||
description: 'Get a specific email template',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Template ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// TASK TOOLS (6 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_tasks',
|
||||
description: 'List tasks with filtering',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Filter by lead ID' },
|
||||
contact_id: { type: 'string', description: 'Filter by contact ID' },
|
||||
is_complete: { type: 'boolean', description: 'Filter by completion status' },
|
||||
assigned_to: { type: 'string', description: 'Filter by assigned user ID' },
|
||||
_skip: { type: 'number' },
|
||||
_limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_task',
|
||||
description: 'Get a specific task by ID',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Task ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_task',
|
||||
description: 'Create a new task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lead_id: { type: 'string', description: 'Lead ID' },
|
||||
contact_id: { type: 'string', description: 'Contact ID' },
|
||||
assigned_to: { type: 'string', description: 'User ID to assign to' },
|
||||
text: { type: 'string', description: 'Task description' },
|
||||
date: { type: 'string', description: 'Due date (ISO 8601)' },
|
||||
},
|
||||
required: ['text', 'assigned_to'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_update_task',
|
||||
description: 'Update an existing task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Task ID' },
|
||||
text: { type: 'string', description: 'Task description' },
|
||||
date: { type: 'string', description: 'Due date' },
|
||||
assigned_to: { type: 'string', description: 'User ID' },
|
||||
is_complete: { type: 'boolean', description: 'Completion status' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_complete_task',
|
||||
description: 'Mark a task as complete',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Task ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_delete_task',
|
||||
description: 'Delete a task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Task ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// SMART VIEW TOOLS (5 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_smart_views',
|
||||
description: 'List all saved smart views/searches',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['lead', 'opportunity', 'contact'],
|
||||
description: 'Filter by view type',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_smart_view',
|
||||
description: 'Get a specific smart view by ID',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Smart view ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_smart_view',
|
||||
description: 'Create a new smart view/saved search',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'View name' },
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['lead', 'opportunity', 'contact'],
|
||||
description: 'View type',
|
||||
},
|
||||
query: { type: 'object', description: 'Search query object' },
|
||||
},
|
||||
required: ['name', 'type', 'query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_update_smart_view',
|
||||
description: 'Update an existing smart view',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Smart view ID' },
|
||||
name: { type: 'string', description: 'View name' },
|
||||
query: { type: 'object', description: 'Search query' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_delete_smart_view',
|
||||
description: 'Delete a smart view',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Smart view ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// USER TOOLS (3 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_users',
|
||||
description: 'List all users in the organization',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_user',
|
||||
description: 'Get a specific user by ID',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'User ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_current_user',
|
||||
description: 'Get the currently authenticated user',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOM FIELD TOOLS (5 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_custom_fields',
|
||||
description: 'List all custom fields',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['lead', 'contact', 'opportunity'],
|
||||
description: 'Filter by object type',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_custom_field',
|
||||
description: 'Get a specific custom field by ID',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Custom field ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_custom_field',
|
||||
description: 'Create a new custom field',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Field name' },
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['text', 'number', 'date', 'choices', 'user'],
|
||||
description: 'Field type',
|
||||
},
|
||||
choices: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Available choices (for choices type)',
|
||||
},
|
||||
},
|
||||
required: ['name', 'type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_update_custom_field',
|
||||
description: 'Update a custom field definition',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Custom field ID' },
|
||||
name: { type: 'string', description: 'Field name' },
|
||||
choices: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_delete_custom_field',
|
||||
description: 'Delete a custom field',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Custom field ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// SEQUENCE TOOLS (6 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_list_sequences',
|
||||
description: 'List all email sequences',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_sequence',
|
||||
description: 'Get a specific sequence by ID',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Sequence ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_subscribe_to_sequence',
|
||||
description: 'Subscribe a contact to an email sequence',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sequence_id: { type: 'string', description: 'Sequence ID' },
|
||||
contact_id: { type: 'string', description: 'Contact ID' },
|
||||
sender_account_id: { type: 'string', description: 'Email account ID to send from' },
|
||||
},
|
||||
required: ['sequence_id', 'contact_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_list_sequence_subscriptions',
|
||||
description: 'List sequence subscriptions with filtering',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sequence_id: { type: 'string', description: 'Filter by sequence ID' },
|
||||
contact_id: { type: 'string', description: 'Filter by contact ID' },
|
||||
lead_id: { type: 'string', description: 'Filter by lead ID' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_pause_sequence_subscription',
|
||||
description: 'Pause a sequence subscription',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Subscription ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_unsubscribe_from_sequence',
|
||||
description: 'Unsubscribe/remove a contact from a sequence',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Subscription ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// REPORTING TOOLS (4 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_get_leads_report',
|
||||
description: 'Get leads report/analytics',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date_start: { type: 'string', description: 'Start date (ISO 8601)' },
|
||||
date_end: { type: 'string', description: 'End date (ISO 8601)' },
|
||||
group_by: { type: 'string', description: 'Grouping field' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_opportunities_report',
|
||||
description: 'Get opportunities/pipeline report',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date_start: { type: 'string', description: 'Start date' },
|
||||
date_end: { type: 'string', description: 'End date' },
|
||||
group_by: { type: 'string', description: 'Grouping field (e.g., status_id, user_id)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_activities_report',
|
||||
description: 'Get activities report (calls, emails, etc.)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date_start: { type: 'string', description: 'Start date' },
|
||||
date_end: { type: 'string', description: 'End date' },
|
||||
activity_type: { type: 'string', description: 'Filter by activity type' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_user_activity_report',
|
||||
description: 'Get activity report for a specific user',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'User ID' },
|
||||
date_start: { type: 'string', description: 'Start date' },
|
||||
date_end: { type: 'string', description: 'End date' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// ORGANIZATION & MISC TOOLS (4 tools)
|
||||
// ============================================================================
|
||||
{
|
||||
name: 'close_get_organization',
|
||||
description: 'Get organization details and settings',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_list_webhooks',
|
||||
description: 'List all configured webhooks',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_create_webhook',
|
||||
description: 'Create a new webhook subscription',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'Webhook endpoint URL' },
|
||||
events: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of event types to subscribe to',
|
||||
},
|
||||
},
|
||||
required: ['url', 'events'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'close_get_connected_accounts',
|
||||
description: 'List all connected email/calendar accounts',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
302
servers/close/src/types.ts
Normal file
302
servers/close/src/types.ts
Normal file
@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Close CRM MCP Server - TypeScript Type Definitions
|
||||
* Complete type system for Close API v1
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
export interface CloseConfig {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
// Core Resources
|
||||
export interface Lead {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
status_id: string;
|
||||
status_label: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
organization_id: string;
|
||||
contacts?: Contact[];
|
||||
opportunities?: Opportunity[];
|
||||
tasks?: Task[];
|
||||
custom?: Record<string, any>;
|
||||
addresses?: Address[];
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
lead_id: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
emails: EmailAddress[];
|
||||
phones: PhoneNumber[];
|
||||
urls?: UrlField[];
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
organization_id: string;
|
||||
}
|
||||
|
||||
export interface EmailAddress {
|
||||
email: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PhoneNumber {
|
||||
phone: string;
|
||||
type: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface UrlField {
|
||||
url: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
address_1?: string;
|
||||
address_2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zipcode?: string;
|
||||
country?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface Opportunity {
|
||||
id: string;
|
||||
lead_id: string;
|
||||
status_id: string;
|
||||
status_label: string;
|
||||
status_type: 'active' | 'won' | 'lost';
|
||||
confidence?: number;
|
||||
value?: number;
|
||||
value_period?: 'one_time' | 'monthly' | 'annual';
|
||||
value_currency?: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
note?: string;
|
||||
organization_id: string;
|
||||
contact_id?: string;
|
||||
user_id?: string;
|
||||
date_won?: string;
|
||||
date_lost?: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: string;
|
||||
lead_id?: string;
|
||||
contact_id?: string;
|
||||
opportunity_id?: string;
|
||||
user_id: string;
|
||||
created_by: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
organization_id: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
_type: 'lead' | 'contact';
|
||||
lead_id?: string;
|
||||
contact_id?: string;
|
||||
assigned_to: string;
|
||||
text: string;
|
||||
date: string;
|
||||
is_complete: boolean;
|
||||
created_by: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
organization_id: string;
|
||||
view?: string;
|
||||
}
|
||||
|
||||
export interface Pipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
organization_id: string;
|
||||
statuses: PipelineStatus[];
|
||||
}
|
||||
|
||||
export interface PipelineStatus {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'active' | 'won' | 'lost';
|
||||
}
|
||||
|
||||
export interface SmartView {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'lead' | 'opportunity' | 'contact';
|
||||
query: any;
|
||||
organization_id: string;
|
||||
created_by: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
image?: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
}
|
||||
|
||||
export interface CustomField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'number' | 'date' | 'choices' | 'user';
|
||||
choices?: string[];
|
||||
accepts_multiple_values?: boolean;
|
||||
editable_with_roles?: string[];
|
||||
created_by: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
organization_id: string;
|
||||
}
|
||||
|
||||
export interface Sequence {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'paused' | 'archived';
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
organization_id: string;
|
||||
}
|
||||
|
||||
export interface SequenceSubscription {
|
||||
id: string;
|
||||
sequence_id: string;
|
||||
contact_id: string;
|
||||
lead_id: string;
|
||||
sender_account_id: string;
|
||||
sender_name: string;
|
||||
sender_email: string;
|
||||
status: 'active' | 'paused' | 'finished';
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
}
|
||||
|
||||
// Activity Types
|
||||
export interface EmailActivity extends Activity {
|
||||
type: 'Email';
|
||||
direction: 'incoming' | 'outgoing';
|
||||
status: 'draft' | 'scheduled' | 'sent' | 'delivered' | 'bounced';
|
||||
subject?: string;
|
||||
body_text?: string;
|
||||
body_html?: string;
|
||||
sender?: string;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
template_id?: string;
|
||||
thread_id?: string;
|
||||
opens?: any[];
|
||||
clicks?: any[];
|
||||
}
|
||||
|
||||
export interface CallActivity extends Activity {
|
||||
type: 'Call';
|
||||
direction: 'inbound' | 'outbound';
|
||||
status: 'completed' | 'canceled' | 'no-answer' | 'busy' | 'failed';
|
||||
duration?: number;
|
||||
note?: string;
|
||||
phone?: string;
|
||||
disposition?: string;
|
||||
recording_url?: string;
|
||||
voicemail_url?: string;
|
||||
}
|
||||
|
||||
export interface NoteActivity extends Activity {
|
||||
type: 'Note';
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SMSActivity extends Activity {
|
||||
type: 'SMS';
|
||||
direction: 'incoming' | 'outgoing';
|
||||
status: 'sent' | 'delivered' | 'failed';
|
||||
text: string;
|
||||
local_phone?: string;
|
||||
remote_phone?: string;
|
||||
}
|
||||
|
||||
export interface MeetingActivity extends Activity {
|
||||
type: 'Meeting';
|
||||
title?: string;
|
||||
note?: string;
|
||||
starts_at?: string;
|
||||
ends_at?: string;
|
||||
location?: string;
|
||||
attendees?: string[];
|
||||
}
|
||||
|
||||
// Bulk Operations
|
||||
export interface BulkAction {
|
||||
id: string;
|
||||
type: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
created_by: string;
|
||||
date_created: string;
|
||||
date_updated: string;
|
||||
organization_id: string;
|
||||
}
|
||||
|
||||
// Reporting
|
||||
export interface Report {
|
||||
data: any[];
|
||||
aggregate?: Record<string, any>;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
has_more: boolean;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Search/Filter
|
||||
export interface SearchParams {
|
||||
query?: string;
|
||||
_skip?: number;
|
||||
_limit?: number;
|
||||
_fields?: string[];
|
||||
_order_by?: string;
|
||||
}
|
||||
|
||||
export interface LeadSearchParams extends SearchParams {
|
||||
status_id?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export interface OpportunitySearchParams extends SearchParams {
|
||||
status_id?: string;
|
||||
status_type?: 'active' | 'won' | 'lost';
|
||||
user_id?: string;
|
||||
lead_id?: string;
|
||||
}
|
||||
|
||||
// Export types
|
||||
export type ActivityType = EmailActivity | CallActivity | NoteActivity | SMSActivity | MeetingActivity;
|
||||
@ -1,290 +1,392 @@
|
||||
/**
|
||||
* FieldEdge API Client
|
||||
* Handles authentication, rate limiting, pagination, and error handling
|
||||
* Handles authentication, requests, error mapping, and rate limiting
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import type {
|
||||
FieldEdgeConfig,
|
||||
ApiResponse,
|
||||
Customer,
|
||||
Job,
|
||||
Invoice,
|
||||
Estimate,
|
||||
Equipment,
|
||||
Technician,
|
||||
InventoryItem,
|
||||
Payment,
|
||||
Appointment,
|
||||
WorkOrder,
|
||||
Location,
|
||||
StockAdjustment,
|
||||
PaginatedResponse,
|
||||
QueryParams,
|
||||
ApiResponse,
|
||||
CustomerSearchParams,
|
||||
JobSearchParams,
|
||||
InvoiceSearchParams,
|
||||
} from '../types/index.js';
|
||||
|
||||
export class FieldEdgeAPIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public response?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FieldEdgeAPIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class FieldEdgeClient {
|
||||
private client: AxiosInstance;
|
||||
private config: FieldEdgeConfig;
|
||||
private rateLimitRemaining: number = 1000;
|
||||
private rateLimitReset: number = 0;
|
||||
private apiKey: string;
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(config: FieldEdgeConfig) {
|
||||
this.config = {
|
||||
apiUrl: 'https://api.fieldedge.com/v1',
|
||||
timeout: 30000,
|
||||
...config,
|
||||
};
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.config.apiUrl,
|
||||
timeout: this.config.timeout,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(this.config.companyId && { 'X-Company-Id': this.config.companyId }),
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for rate limiting
|
||||
this.client.interceptors.request.use(
|
||||
async (config) => {
|
||||
// Check rate limit
|
||||
if (this.rateLimitRemaining <= 0 && Date.now() < this.rateLimitReset) {
|
||||
const waitTime = this.rateLimitReset - Date.now();
|
||||
console.warn(`Rate limit exceeded. Waiting ${waitTime}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor for error handling and rate limit tracking
|
||||
this.client.interceptors.response.use(
|
||||
(response) => {
|
||||
// Update rate limit info from headers
|
||||
const remaining = response.headers['x-ratelimit-remaining'];
|
||||
const reset = response.headers['x-ratelimit-reset'];
|
||||
|
||||
if (remaining) this.rateLimitRemaining = parseInt(remaining);
|
||||
if (reset) this.rateLimitReset = parseInt(reset) * 1000;
|
||||
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
|
||||
// Handle specific error codes
|
||||
switch (status) {
|
||||
case 401:
|
||||
throw new FieldEdgeAPIError('Authentication failed. Invalid API key.', 401, data);
|
||||
case 403:
|
||||
throw new FieldEdgeAPIError('Access forbidden. Check permissions.', 403, data);
|
||||
case 404:
|
||||
throw new FieldEdgeAPIError('Resource not found.', 404, data);
|
||||
case 429:
|
||||
throw new FieldEdgeAPIError('Rate limit exceeded.', 429, data);
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
throw new FieldEdgeAPIError('FieldEdge server error. Please try again later.', status, data);
|
||||
default:
|
||||
const message = (data as any)?.message || (data as any)?.error || 'API request failed';
|
||||
throw new FieldEdgeAPIError(message, status, data);
|
||||
}
|
||||
} else if (error.request) {
|
||||
throw new FieldEdgeAPIError('No response from FieldEdge API. Check network connection.');
|
||||
} else {
|
||||
throw new FieldEdgeAPIError(`Request error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic GET request with pagination support
|
||||
*/
|
||||
async get<T>(endpoint: string, params?: QueryParams): Promise<T> {
|
||||
const response = await this.client.get(endpoint, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic GET request for paginated data
|
||||
*/
|
||||
async getPaginated<T>(
|
||||
endpoint: string,
|
||||
params?: QueryParams
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
const queryParams = {
|
||||
page: params?.page || 1,
|
||||
pageSize: params?.pageSize || 50,
|
||||
...params,
|
||||
};
|
||||
|
||||
const response = await this.client.get(endpoint, { params: queryParams });
|
||||
this.apiKey = config.apiKey;
|
||||
this.baseUrl = config.baseUrl || 'https://api.fieldedge.com/v1';
|
||||
|
||||
return {
|
||||
items: response.data.items || response.data.data || response.data,
|
||||
total: response.data.total || response.data.items?.length || 0,
|
||||
page: response.data.page || queryParams.page,
|
||||
pageSize: response.data.pageSize || queryParams.pageSize,
|
||||
totalPages: response.data.totalPages || Math.ceil((response.data.total || 0) / queryParams.pageSize),
|
||||
if (config.environment === 'sandbox') {
|
||||
this.baseUrl = 'https://sandbox-api.fieldedge.com/v1';
|
||||
}
|
||||
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages of paginated data
|
||||
*/
|
||||
async getAllPaginated<T>(
|
||||
private async request<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
params?: QueryParams
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await this.getPaginated<T>(endpoint, {
|
||||
...params,
|
||||
page,
|
||||
pageSize: params?.pageSize || 100,
|
||||
data?: any,
|
||||
params?: Record<string, any>
|
||||
): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
allItems.push(...response.items);
|
||||
|
||||
hasMore = page < response.totalPages;
|
||||
page++;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic POST request
|
||||
*/
|
||||
async post<T>(endpoint: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.post(endpoint, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic PUT request
|
||||
*/
|
||||
async put<T>(endpoint: string, data?: any): Promise<T> {
|
||||
const response = await this.client.put(endpoint, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic PATCH request
|
||||
*/
|
||||
async patch<T>(endpoint: string, data?: any): Promise<T> {
|
||||
const response = await this.client.patch(endpoint, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic DELETE request
|
||||
*/
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
const response = await this.client.delete(endpoint);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file
|
||||
*/
|
||||
async uploadFile(endpoint: string, file: Buffer, filename: string, mimeType?: string): Promise<any> {
|
||||
const formData = new FormData();
|
||||
const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength) as ArrayBuffer;
|
||||
const blob = new Blob([arrayBuffer], { type: mimeType || 'application/octet-stream' });
|
||||
formData.append('file', blob, filename);
|
||||
|
||||
const response = await this.client.post(endpoint, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file
|
||||
*/
|
||||
async downloadFile(endpoint: string): Promise<Buffer> {
|
||||
const response = await this.client.get(endpoint, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
return Buffer.from(response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API connectivity
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.client.get('/health');
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API usage/rate limit info
|
||||
*/
|
||||
getRateLimitInfo(): { remaining: number; resetAt: number } {
|
||||
return {
|
||||
remaining: this.rateLimitRemaining,
|
||||
resetAt: this.rateLimitReset,
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: this.headers,
|
||||
};
|
||||
|
||||
if (data) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData;
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = { message: errorText };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`FieldEdge API Error (${response.status}): ${errorData.message || errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!responseText) return {} as T;
|
||||
|
||||
return JSON.parse(responseText) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`FieldEdge API request failed: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API configuration
|
||||
*/
|
||||
updateConfig(config: Partial<FieldEdgeConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
if (config.apiKey) {
|
||||
this.client.defaults.headers['Authorization'] = `Bearer ${config.apiKey}`;
|
||||
}
|
||||
|
||||
if (config.companyId) {
|
||||
this.client.defaults.headers['X-Company-Id'] = config.companyId;
|
||||
}
|
||||
|
||||
if (config.apiUrl) {
|
||||
this.client.defaults.baseURL = config.apiUrl;
|
||||
}
|
||||
|
||||
if (config.timeout) {
|
||||
this.client.defaults.timeout = config.timeout;
|
||||
}
|
||||
// Customer Methods
|
||||
async getCustomers(params?: CustomerSearchParams): Promise<PaginatedResponse<Customer>> {
|
||||
return this.request<PaginatedResponse<Customer>>('GET', '/customers', undefined, params);
|
||||
}
|
||||
|
||||
async getCustomer(customerId: string): Promise<Customer> {
|
||||
return this.request<Customer>('GET', `/customers/${customerId}`);
|
||||
}
|
||||
|
||||
async createCustomer(data: Partial<Customer>): Promise<Customer> {
|
||||
return this.request<Customer>('POST', '/customers', data);
|
||||
}
|
||||
|
||||
async updateCustomer(customerId: string, data: Partial<Customer>): Promise<Customer> {
|
||||
return this.request<Customer>('PUT', `/customers/${customerId}`, data);
|
||||
}
|
||||
|
||||
async deleteCustomer(customerId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/customers/${customerId}`);
|
||||
}
|
||||
|
||||
async searchCustomers(query: string): Promise<Customer[]> {
|
||||
const response = await this.request<PaginatedResponse<Customer>>(
|
||||
'GET',
|
||||
'/customers/search',
|
||||
undefined,
|
||||
{ q: query }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Job/Work Order Methods
|
||||
async getJobs(params?: JobSearchParams): Promise<PaginatedResponse<Job>> {
|
||||
return this.request<PaginatedResponse<Job>>('GET', '/jobs', undefined, params);
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<Job> {
|
||||
return this.request<Job>('GET', `/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
async createJob(data: Partial<Job>): Promise<Job> {
|
||||
return this.request<Job>('POST', '/jobs', data);
|
||||
}
|
||||
|
||||
async updateJob(jobId: string, data: Partial<Job>): Promise<Job> {
|
||||
return this.request<Job>('PUT', `/jobs/${jobId}`, data);
|
||||
}
|
||||
|
||||
async deleteJob(jobId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
async getWorkOrders(jobId?: string): Promise<WorkOrder[]> {
|
||||
const endpoint = jobId ? `/jobs/${jobId}/work-orders` : '/work-orders';
|
||||
return this.request<WorkOrder[]>('GET', endpoint);
|
||||
}
|
||||
|
||||
async getWorkOrder(workOrderId: string): Promise<WorkOrder> {
|
||||
return this.request<WorkOrder>('GET', `/work-orders/${workOrderId}`);
|
||||
}
|
||||
|
||||
async createWorkOrder(data: Partial<WorkOrder>): Promise<WorkOrder> {
|
||||
return this.request<WorkOrder>('POST', '/work-orders', data);
|
||||
}
|
||||
|
||||
async updateWorkOrder(workOrderId: string, data: Partial<WorkOrder>): Promise<WorkOrder> {
|
||||
return this.request<WorkOrder>('PUT', `/work-orders/${workOrderId}`, data);
|
||||
}
|
||||
|
||||
// Scheduling Methods
|
||||
async getAppointments(params?: {
|
||||
technicianId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<Appointment[]> {
|
||||
return this.request<Appointment[]>('GET', '/appointments', undefined, params);
|
||||
}
|
||||
|
||||
async getAppointment(appointmentId: string): Promise<Appointment> {
|
||||
return this.request<Appointment>('GET', `/appointments/${appointmentId}`);
|
||||
}
|
||||
|
||||
async createAppointment(data: Partial<Appointment>): Promise<Appointment> {
|
||||
return this.request<Appointment>('POST', '/appointments', data);
|
||||
}
|
||||
|
||||
async updateAppointment(appointmentId: string, data: Partial<Appointment>): Promise<Appointment> {
|
||||
return this.request<Appointment>('PUT', `/appointments/${appointmentId}`, data);
|
||||
}
|
||||
|
||||
async deleteAppointment(appointmentId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/appointments/${appointmentId}`);
|
||||
}
|
||||
|
||||
async getTechnicianSchedule(technicianId: string, startDate: string, endDate: string): Promise<Appointment[]> {
|
||||
return this.request<Appointment[]>(
|
||||
'GET',
|
||||
`/technicians/${technicianId}/schedule`,
|
||||
undefined,
|
||||
{ startDate, endDate }
|
||||
);
|
||||
}
|
||||
|
||||
// Invoice Methods
|
||||
async getInvoices(params?: InvoiceSearchParams): Promise<PaginatedResponse<Invoice>> {
|
||||
return this.request<PaginatedResponse<Invoice>>('GET', '/invoices', undefined, params);
|
||||
}
|
||||
|
||||
async getInvoice(invoiceId: string): Promise<Invoice> {
|
||||
return this.request<Invoice>('GET', `/invoices/${invoiceId}`);
|
||||
}
|
||||
|
||||
async createInvoice(data: Partial<Invoice>): Promise<Invoice> {
|
||||
return this.request<Invoice>('POST', '/invoices', data);
|
||||
}
|
||||
|
||||
async updateInvoice(invoiceId: string, data: Partial<Invoice>): Promise<Invoice> {
|
||||
return this.request<Invoice>('PUT', `/invoices/${invoiceId}`, data);
|
||||
}
|
||||
|
||||
async deleteInvoice(invoiceId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/invoices/${invoiceId}`);
|
||||
}
|
||||
|
||||
async sendInvoice(invoiceId: string, email?: string): Promise<void> {
|
||||
return this.request<void>('POST', `/invoices/${invoiceId}/send`, { email });
|
||||
}
|
||||
|
||||
async recordPayment(invoiceId: string, paymentData: Partial<Payment>): Promise<Payment> {
|
||||
return this.request<Payment>('POST', `/invoices/${invoiceId}/payments`, paymentData);
|
||||
}
|
||||
|
||||
async getPayments(invoiceId: string): Promise<Payment[]> {
|
||||
return this.request<Payment[]>('GET', `/invoices/${invoiceId}/payments`);
|
||||
}
|
||||
|
||||
// Estimate Methods
|
||||
async getEstimates(customerId?: string): Promise<Estimate[]> {
|
||||
const params = customerId ? { customerId } : undefined;
|
||||
return this.request<Estimate[]>('GET', '/estimates', undefined, params);
|
||||
}
|
||||
|
||||
async getEstimate(estimateId: string): Promise<Estimate> {
|
||||
return this.request<Estimate>('GET', `/estimates/${estimateId}`);
|
||||
}
|
||||
|
||||
async createEstimate(data: Partial<Estimate>): Promise<Estimate> {
|
||||
return this.request<Estimate>('POST', '/estimates', data);
|
||||
}
|
||||
|
||||
async updateEstimate(estimateId: string, data: Partial<Estimate>): Promise<Estimate> {
|
||||
return this.request<Estimate>('PUT', `/estimates/${estimateId}`, data);
|
||||
}
|
||||
|
||||
async deleteEstimate(estimateId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/estimates/${estimateId}`);
|
||||
}
|
||||
|
||||
async sendEstimate(estimateId: string, email?: string): Promise<void> {
|
||||
return this.request<void>('POST', `/estimates/${estimateId}/send`, { email });
|
||||
}
|
||||
|
||||
async convertEstimateToJob(estimateId: string): Promise<Job> {
|
||||
return this.request<Job>('POST', `/estimates/${estimateId}/convert-to-job`);
|
||||
}
|
||||
|
||||
// Equipment Methods
|
||||
async getEquipment(customerId?: string): Promise<Equipment[]> {
|
||||
const params = customerId ? { customerId } : undefined;
|
||||
return this.request<Equipment[]>('GET', '/equipment', undefined, params);
|
||||
}
|
||||
|
||||
async getEquipmentById(equipmentId: string): Promise<Equipment> {
|
||||
return this.request<Equipment>('GET', `/equipment/${equipmentId}`);
|
||||
}
|
||||
|
||||
async createEquipment(data: Partial<Equipment>): Promise<Equipment> {
|
||||
return this.request<Equipment>('POST', '/equipment', data);
|
||||
}
|
||||
|
||||
async updateEquipment(equipmentId: string, data: Partial<Equipment>): Promise<Equipment> {
|
||||
return this.request<Equipment>('PUT', `/equipment/${equipmentId}`, data);
|
||||
}
|
||||
|
||||
async deleteEquipment(equipmentId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/equipment/${equipmentId}`);
|
||||
}
|
||||
|
||||
async getEquipmentHistory(equipmentId: string): Promise<Job[]> {
|
||||
return this.request<Job[]>('GET', `/equipment/${equipmentId}/history`);
|
||||
}
|
||||
|
||||
// Technician Methods
|
||||
async getTechnicians(status?: 'active' | 'inactive'): Promise<Technician[]> {
|
||||
const params = status ? { status } : undefined;
|
||||
return this.request<Technician[]>('GET', '/technicians', undefined, params);
|
||||
}
|
||||
|
||||
async getTechnician(technicianId: string): Promise<Technician> {
|
||||
return this.request<Technician>('GET', `/technicians/${technicianId}`);
|
||||
}
|
||||
|
||||
async createTechnician(data: Partial<Technician>): Promise<Technician> {
|
||||
return this.request<Technician>('POST', '/technicians', data);
|
||||
}
|
||||
|
||||
async updateTechnician(technicianId: string, data: Partial<Technician>): Promise<Technician> {
|
||||
return this.request<Technician>('PUT', `/technicians/${technicianId}`, data);
|
||||
}
|
||||
|
||||
async deleteTechnician(technicianId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/technicians/${technicianId}`);
|
||||
}
|
||||
|
||||
// Inventory Methods
|
||||
async getInventoryItems(category?: string): Promise<InventoryItem[]> {
|
||||
const params = category ? { category } : undefined;
|
||||
return this.request<InventoryItem[]>('GET', '/inventory', undefined, params);
|
||||
}
|
||||
|
||||
async getInventoryItem(itemId: string): Promise<InventoryItem> {
|
||||
return this.request<InventoryItem>('GET', `/inventory/${itemId}`);
|
||||
}
|
||||
|
||||
async createInventoryItem(data: Partial<InventoryItem>): Promise<InventoryItem> {
|
||||
return this.request<InventoryItem>('POST', '/inventory', data);
|
||||
}
|
||||
|
||||
async updateInventoryItem(itemId: string, data: Partial<InventoryItem>): Promise<InventoryItem> {
|
||||
return this.request<InventoryItem>('PUT', `/inventory/${itemId}`, data);
|
||||
}
|
||||
|
||||
async deleteInventoryItem(itemId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/inventory/${itemId}`);
|
||||
}
|
||||
|
||||
async adjustStock(itemId: string, adjustment: Partial<StockAdjustment>): Promise<StockAdjustment> {
|
||||
return this.request<StockAdjustment>('POST', `/inventory/${itemId}/adjust`, adjustment);
|
||||
}
|
||||
|
||||
async getStockHistory(itemId: string): Promise<StockAdjustment[]> {
|
||||
return this.request<StockAdjustment[]>('GET', `/inventory/${itemId}/history`);
|
||||
}
|
||||
|
||||
async getLowStockItems(): Promise<InventoryItem[]> {
|
||||
return this.request<InventoryItem[]>('GET', '/inventory/low-stock');
|
||||
}
|
||||
|
||||
// Location Methods
|
||||
async getLocations(customerId: string): Promise<Location[]> {
|
||||
return this.request<Location[]>('GET', `/customers/${customerId}/locations`);
|
||||
}
|
||||
|
||||
async getLocation(locationId: string): Promise<Location> {
|
||||
return this.request<Location>('GET', `/locations/${locationId}`);
|
||||
}
|
||||
|
||||
async createLocation(customerId: string, data: Partial<Location>): Promise<Location> {
|
||||
return this.request<Location>('POST', `/customers/${customerId}/locations`, data);
|
||||
}
|
||||
|
||||
async updateLocation(locationId: string, data: Partial<Location>): Promise<Location> {
|
||||
return this.request<Location>('PUT', `/locations/${locationId}`, data);
|
||||
}
|
||||
|
||||
async deleteLocation(locationId: string): Promise<void> {
|
||||
return this.request<void>('DELETE', `/locations/${locationId}`);
|
||||
}
|
||||
|
||||
// Reporting Methods
|
||||
async getRevenueReport(startDate: string, endDate: string): Promise<any> {
|
||||
return this.request<any>('GET', '/reports/revenue', undefined, { startDate, endDate });
|
||||
}
|
||||
|
||||
async getTechnicianPerformance(startDate: string, endDate: string, technicianId?: string): Promise<any> {
|
||||
const params: any = { startDate, endDate };
|
||||
if (technicianId) params.technicianId = technicianId;
|
||||
return this.request<any>('GET', '/reports/technician-performance', undefined, params);
|
||||
}
|
||||
|
||||
async getCustomerReport(customerId: string): Promise<any> {
|
||||
return this.request<any>('GET', `/reports/customers/${customerId}`);
|
||||
}
|
||||
|
||||
async getJobStatusReport(startDate: string, endDate: string): Promise<any> {
|
||||
return this.request<any>('GET', '/reports/job-status', undefined, { startDate, endDate });
|
||||
}
|
||||
|
||||
async getInventoryValuation(): Promise<any> {
|
||||
return this.request<any>('GET', '/reports/inventory-valuation');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let clientInstance: FieldEdgeClient | null = null;
|
||||
|
||||
export function getFieldEdgeClient(config?: FieldEdgeConfig): FieldEdgeClient {
|
||||
if (!clientInstance && config) {
|
||||
clientInstance = new FieldEdgeClient(config);
|
||||
}
|
||||
|
||||
if (!clientInstance) {
|
||||
throw new Error('FieldEdge client not initialized. Provide API key configuration.');
|
||||
}
|
||||
|
||||
return clientInstance;
|
||||
}
|
||||
|
||||
export function initializeFieldEdgeClient(config: FieldEdgeConfig): FieldEdgeClient {
|
||||
clientInstance = new FieldEdgeClient(config);
|
||||
return clientInstance;
|
||||
}
|
||||
|
||||
160
servers/fieldedge/src/tools/customers-tools.ts
Normal file
160
servers/fieldedge/src/tools/customers-tools.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* FieldEdge Customer Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { FieldEdgeClient } from '../clients/fieldedge.js';
|
||||
|
||||
export function registerCustomerTools(client: FieldEdgeClient) {
|
||||
return {
|
||||
fieldedge_list_customers: {
|
||||
description: 'List all customers with optional filters (status, type, search query)',
|
||||
parameters: z.object({
|
||||
query: z.string().optional().describe('Search query for customer name, email, or phone'),
|
||||
status: z.enum(['active', 'inactive']).optional().describe('Filter by customer status'),
|
||||
type: z.enum(['residential', 'commercial']).optional().describe('Filter by customer type'),
|
||||
tags: z.array(z.string()).optional().describe('Filter by tags'),
|
||||
page: z.number().optional().describe('Page number for pagination'),
|
||||
pageSize: z.number().optional().describe('Number of results per page'),
|
||||
sortBy: z.enum(['name', 'createdDate', 'balance']).optional().describe('Sort field'),
|
||||
sortOrder: z.enum(['asc', 'desc']).optional().describe('Sort order'),
|
||||
}),
|
||||
execute: async (args: any) => {
|
||||
const customers = await client.getCustomers(args);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(customers, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
fieldedge_get_customer: {
|
||||
description: 'Get detailed information about a specific customer by ID',
|
||||
parameters: z.object({
|
||||
customerId: z.string().describe('The customer ID'),
|
||||
}),
|
||||
execute: async (args: { customerId: string }) => {
|
||||
const customer = await client.getCustomer(args.customerId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(customer, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
fieldedge_create_customer: {
|
||||
description: 'Create a new customer',
|
||||
parameters: z.object({
|
||||
firstName: z.string().describe('Customer first name'),
|
||||
lastName: z.string().describe('Customer last name'),
|
||||
companyName: z.string().optional().describe('Company name for commercial customers'),
|
||||
email: z.string().optional().describe('Email address'),
|
||||
phone: z.string().optional().describe('Primary phone number'),
|
||||
mobilePhone: z.string().optional().describe('Mobile phone number'),
|
||||
type: z.enum(['residential', 'commercial']).optional().describe('Customer type'),
|
||||
address: z.object({
|
||||
street1: z.string(),
|
||||
street2: z.string().optional(),
|
||||
city: z.string(),
|
||||
state: z.string(),
|
||||
zip: z.string(),
|
||||
country: z.string().optional(),
|
||||
}).optional().describe('Customer address'),
|
||||
tags: z.array(z.string()).optional().describe('Customer tags'),
|
||||
notes: z.string().optional().describe('Notes about the customer'),
|
||||
}),
|
||||
execute: async (args: any) => {
|
||||
const customer = await client.createCustomer(args);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(customer, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
fieldedge_update_customer: {
|
||||
description: 'Update an existing customer',
|
||||
parameters: z.object({
|
||||
customerId: z.string().describe('The customer ID to update'),
|
||||
firstName: z.string().optional().describe('Customer first name'),
|
||||
lastName: z.string().optional().describe('Customer last name'),
|
||||
companyName: z.string().optional().describe('Company name'),
|
||||
email: z.string().optional().describe('Email address'),
|
||||
phone: z.string().optional().describe('Primary phone number'),
|
||||
mobilePhone: z.string().optional().describe('Mobile phone number'),
|
||||
status: z.enum(['active', 'inactive']).optional().describe('Customer status'),
|
||||
type: z.enum(['residential', 'commercial']).optional().describe('Customer type'),
|
||||
address: z.object({
|
||||
street1: z.string(),
|
||||
street2: z.string().optional(),
|
||||
city: z.string(),
|
||||
state: z.string(),
|
||||
zip: z.string(),
|
||||
country: z.string().optional(),
|
||||
}).optional().describe('Customer address'),
|
||||
tags: z.array(z.string()).optional().describe('Customer tags'),
|
||||
notes: z.string().optional().describe('Notes about the customer'),
|
||||
}),
|
||||
execute: async (args: any) => {
|
||||
const { customerId, ...updateData } = args;
|
||||
const customer = await client.updateCustomer(customerId, updateData);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(customer, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
fieldedge_delete_customer: {
|
||||
description: 'Delete a customer',
|
||||
parameters: z.object({
|
||||
customerId: z.string().describe('The customer ID to delete'),
|
||||
}),
|
||||
execute: async (args: { customerId: string }) => {
|
||||
await client.deleteCustomer(args.customerId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Customer ${args.customerId} deleted successfully`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
fieldedge_search_customers: {
|
||||
description: 'Search for customers by name, email, phone, or other criteria',
|
||||
parameters: z.object({
|
||||
query: z.string().describe('Search query'),
|
||||
}),
|
||||
execute: async (args: { query: string }) => {
|
||||
const customers = await client.searchCustomers(args.query);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(customers, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,13 +1,12 @@
|
||||
/**
|
||||
* FieldEdge MCP Server Type Definitions
|
||||
* Comprehensive types for field service management
|
||||
* FieldEdge API TypeScript Types
|
||||
* Field service management for HVAC, plumbing, electrical contractors
|
||||
*/
|
||||
|
||||
export interface FieldEdgeConfig {
|
||||
apiKey: string;
|
||||
apiUrl?: string;
|
||||
companyId?: string;
|
||||
timeout?: number;
|
||||
baseUrl?: string;
|
||||
environment?: 'production' | 'sandbox';
|
||||
}
|
||||
|
||||
// Customer Types
|
||||
@ -20,17 +19,14 @@ export interface Customer {
|
||||
phone?: string;
|
||||
mobilePhone?: string;
|
||||
address?: Address;
|
||||
billingAddress?: Address;
|
||||
status: 'active' | 'inactive' | 'prospect';
|
||||
customerType: 'residential' | 'commercial';
|
||||
balance: number;
|
||||
creditLimit?: number;
|
||||
taxExempt: boolean;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
balance?: number;
|
||||
status: 'active' | 'inactive';
|
||||
type?: 'residential' | 'commercial';
|
||||
createdDate?: string;
|
||||
modifiedDate?: string;
|
||||
customFields?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
@ -50,210 +46,78 @@ export interface Job {
|
||||
jobNumber: string;
|
||||
customerId: string;
|
||||
locationId?: string;
|
||||
jobType: string;
|
||||
status: JobStatus;
|
||||
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'on_hold';
|
||||
priority: 'low' | 'normal' | 'high' | 'emergency';
|
||||
description: string;
|
||||
scheduledStart?: string;
|
||||
scheduledEnd?: string;
|
||||
actualStart?: string;
|
||||
actualEnd?: string;
|
||||
assignedTechnicians: string[];
|
||||
equipmentIds?: string[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
invoiceId?: string;
|
||||
jobType: string;
|
||||
description?: string;
|
||||
scheduledDate?: string;
|
||||
scheduledStartTime?: string;
|
||||
scheduledEndTime?: string;
|
||||
actualStartTime?: string;
|
||||
actualEndTime?: string;
|
||||
assignedTechnicianIds?: string[];
|
||||
estimatedDuration?: number;
|
||||
tags?: string[];
|
||||
customFields?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdDate: string;
|
||||
modifiedDate?: string;
|
||||
completedDate?: string;
|
||||
}
|
||||
|
||||
export type JobStatus =
|
||||
| 'scheduled'
|
||||
| 'dispatched'
|
||||
| 'in-progress'
|
||||
| 'on-hold'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'invoiced';
|
||||
|
||||
// Invoice Types
|
||||
export interface Invoice {
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
customerId: string;
|
||||
jobId?: string;
|
||||
status: InvoiceStatus;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
paidDate?: string;
|
||||
lineItems: LineItem[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
amountPaid: number;
|
||||
balance: number;
|
||||
paymentTerms?: string;
|
||||
jobId: string;
|
||||
workOrderNumber: string;
|
||||
status: 'open' | 'in_progress' | 'completed' | 'invoiced';
|
||||
lineItems?: LineItem[];
|
||||
subtotal?: number;
|
||||
tax?: number;
|
||||
total?: number;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdDate: string;
|
||||
}
|
||||
|
||||
export type InvoiceStatus = 'draft' | 'sent' | 'viewed' | 'partial' | 'paid' | 'overdue' | 'void';
|
||||
|
||||
export interface LineItem {
|
||||
id: string;
|
||||
type: 'service' | 'part' | 'equipment' | 'labor';
|
||||
id?: string;
|
||||
type: 'labor' | 'material' | 'equipment' | 'service';
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
taxable?: boolean;
|
||||
itemId?: string;
|
||||
}
|
||||
|
||||
// Estimate Types
|
||||
export interface Estimate {
|
||||
id: string;
|
||||
estimateNumber: string;
|
||||
customerId: string;
|
||||
status: EstimateStatus;
|
||||
issueDate: string;
|
||||
expiryDate: string;
|
||||
lineItems: LineItem[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
approvedDate?: string;
|
||||
jobId?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type EstimateStatus = 'draft' | 'sent' | 'viewed' | 'approved' | 'declined' | 'expired';
|
||||
|
||||
// Scheduling/Dispatch Types
|
||||
// Scheduling Types
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
jobId: string;
|
||||
customerId: string;
|
||||
technicianId: string;
|
||||
scheduledDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: number;
|
||||
status: AppointmentStatus;
|
||||
appointmentType: string;
|
||||
arrivalWindow?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
actualArrival?: string;
|
||||
actualDeparture?: string;
|
||||
status: 'scheduled' | 'confirmed' | 'en_route' | 'arrived' | 'completed' | 'cancelled';
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
customerNotified?: boolean;
|
||||
}
|
||||
|
||||
export type AppointmentStatus = 'scheduled' | 'confirmed' | 'dispatched' | 'en-route' | 'arrived' | 'completed' | 'cancelled' | 'no-show';
|
||||
|
||||
export interface DispatchBoard {
|
||||
date: string;
|
||||
technicians: TechnicianSchedule[];
|
||||
unassignedJobs: Job[];
|
||||
}
|
||||
|
||||
export interface TechnicianSchedule {
|
||||
technicianId: string;
|
||||
technicianName: string;
|
||||
appointments: Appointment[];
|
||||
availability: TimeSlot[];
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
start: string;
|
||||
end: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// Inventory Types
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
manufacturer?: string;
|
||||
modelNumber?: string;
|
||||
unitOfMeasure: string;
|
||||
costPrice: number;
|
||||
sellPrice: number;
|
||||
msrp?: number;
|
||||
quantityOnHand: number;
|
||||
quantityAllocated: number;
|
||||
quantityAvailable: number;
|
||||
reorderPoint: number;
|
||||
reorderQuantity: number;
|
||||
warehouse?: string;
|
||||
binLocation?: string;
|
||||
serialized: boolean;
|
||||
taxable: boolean;
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InventoryTransaction {
|
||||
id: string;
|
||||
itemId: string;
|
||||
type: 'receipt' | 'issue' | 'adjustment' | 'transfer' | 'return';
|
||||
quantity: number;
|
||||
unitCost?: number;
|
||||
totalCost?: number;
|
||||
reference?: string;
|
||||
jobId?: string;
|
||||
technicianId?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Technician Types
|
||||
export interface Technician {
|
||||
id: string;
|
||||
employeeNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: 'active' | 'inactive' | 'on-leave';
|
||||
role: string;
|
||||
skills: string[];
|
||||
certifications: Certification[];
|
||||
phone?: string;
|
||||
status: 'active' | 'inactive';
|
||||
certifications?: string[];
|
||||
skills?: string[];
|
||||
hourlyRate?: number;
|
||||
overtimeRate?: number;
|
||||
serviceRadius?: number;
|
||||
homeAddress?: Address;
|
||||
vehicleId?: string;
|
||||
defaultWorkHours?: WorkHours;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
color?: string; // For calendar display
|
||||
defaultSchedule?: WeeklySchedule;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface Certification {
|
||||
name: string;
|
||||
number: string;
|
||||
issuer: string;
|
||||
issueDate: string;
|
||||
expiryDate?: string;
|
||||
}
|
||||
|
||||
export interface WorkHours {
|
||||
export interface WeeklySchedule {
|
||||
monday?: DaySchedule;
|
||||
tuesday?: DaySchedule;
|
||||
wednesday?: DaySchedule;
|
||||
@ -264,339 +128,225 @@ export interface WorkHours {
|
||||
}
|
||||
|
||||
export interface DaySchedule {
|
||||
start: string;
|
||||
start: string; // HH:mm format
|
||||
end: string;
|
||||
breaks?: TimeSlot[];
|
||||
breaks?: TimeRange[];
|
||||
}
|
||||
|
||||
export interface TimeRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
// Invoice Types
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
customerId: string;
|
||||
jobId?: string;
|
||||
workOrderId?: string;
|
||||
status: 'draft' | 'sent' | 'viewed' | 'partial' | 'paid' | 'overdue' | 'void';
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
lineItems: LineItem[];
|
||||
subtotal: number;
|
||||
taxRate?: number;
|
||||
taxAmount: number;
|
||||
discountAmount?: number;
|
||||
total: number;
|
||||
amountPaid?: number;
|
||||
balance?: number;
|
||||
notes?: string;
|
||||
terms?: string;
|
||||
createdDate: string;
|
||||
paidDate?: string;
|
||||
}
|
||||
|
||||
// Payment Types
|
||||
export interface Payment {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
customerId: string;
|
||||
amount: number;
|
||||
paymentMethod: PaymentMethod;
|
||||
paymentDate: string;
|
||||
reference?: string;
|
||||
cardLast4?: string;
|
||||
checkNumber?: string;
|
||||
status: 'pending' | 'processed' | 'failed' | 'refunded';
|
||||
paymentMethod: 'cash' | 'check' | 'credit_card' | 'ach' | 'other';
|
||||
referenceNumber?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type PaymentMethod = 'cash' | 'check' | 'credit-card' | 'debit-card' | 'ach' | 'wire' | 'other';
|
||||
// Estimate Types
|
||||
export interface Estimate {
|
||||
id: string;
|
||||
estimateNumber: string;
|
||||
customerId: string;
|
||||
locationId?: string;
|
||||
status: 'draft' | 'sent' | 'viewed' | 'accepted' | 'declined' | 'expired';
|
||||
issueDate: string;
|
||||
expirationDate?: string;
|
||||
lineItems: LineItem[];
|
||||
subtotal: number;
|
||||
taxRate?: number;
|
||||
taxAmount: number;
|
||||
discountAmount?: number;
|
||||
total: number;
|
||||
notes?: string;
|
||||
terms?: string;
|
||||
acceptedDate?: string;
|
||||
jobId?: string; // If converted to job
|
||||
createdDate: string;
|
||||
}
|
||||
|
||||
// Equipment Types
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
customerId: string;
|
||||
locationId?: string;
|
||||
type: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
type: string; // HVAC, Plumbing, Electrical
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
installDate?: string;
|
||||
warrantyExpiry?: string;
|
||||
warrantyExpiration?: string;
|
||||
notes?: string;
|
||||
status: 'active' | 'inactive' | 'retired';
|
||||
maintenanceSchedule?: MaintenanceSchedule;
|
||||
lastServiceDate?: string;
|
||||
nextServiceDue?: string;
|
||||
status: 'active' | 'inactive' | 'decommissioned';
|
||||
notes?: string;
|
||||
customFields?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceHistory {
|
||||
export interface MaintenanceSchedule {
|
||||
frequency: 'monthly' | 'quarterly' | 'semi_annual' | 'annual';
|
||||
lastServiceDate?: string;
|
||||
nextServiceDate?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Inventory Types
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
equipmentId: string;
|
||||
jobId: string;
|
||||
serviceDate: string;
|
||||
technicianId: string;
|
||||
serviceType: string;
|
||||
description: string;
|
||||
partsReplaced?: LineItem[];
|
||||
cost: number;
|
||||
sku: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
type: 'part' | 'material' | 'equipment';
|
||||
unitCost: number;
|
||||
unitPrice: number;
|
||||
quantityOnHand: number;
|
||||
reorderPoint?: number;
|
||||
reorderQuantity?: number;
|
||||
preferredVendor?: string;
|
||||
location?: string;
|
||||
status: 'active' | 'inactive' | 'discontinued';
|
||||
}
|
||||
|
||||
export interface StockAdjustment {
|
||||
id: string;
|
||||
itemId: string;
|
||||
adjustmentType: 'addition' | 'subtraction' | 'transfer' | 'count';
|
||||
quantity: number;
|
||||
reason?: string;
|
||||
technicianId?: string;
|
||||
jobId?: string;
|
||||
date: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Location Types
|
||||
// Reporting Types
|
||||
export interface RevenueReport {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalRevenue: number;
|
||||
invoicedAmount: number;
|
||||
paidAmount: number;
|
||||
outstandingAmount: number;
|
||||
byJobType?: Record<string, number>;
|
||||
byTechnician?: Record<string, number>;
|
||||
byCustomer?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface TechnicianPerformance {
|
||||
technicianId: string;
|
||||
technicianName: string;
|
||||
period: string;
|
||||
jobsCompleted: number;
|
||||
revenueGenerated: number;
|
||||
hoursWorked: number;
|
||||
avgJobDuration: number;
|
||||
customerRating?: number;
|
||||
efficiency?: number;
|
||||
}
|
||||
|
||||
export interface CustomerReport {
|
||||
customerId: string;
|
||||
totalJobs: number;
|
||||
totalRevenue: number;
|
||||
averageInvoice: number;
|
||||
lastServiceDate?: string;
|
||||
lifetimeValue: number;
|
||||
}
|
||||
|
||||
// Location Types (for multi-location customers)
|
||||
export interface Location {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
address: Address;
|
||||
type: 'primary' | 'secondary' | 'billing' | 'service';
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
accessNotes?: string;
|
||||
gateCode?: string;
|
||||
equipmentIds?: string[];
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Price Book Types
|
||||
export interface PriceBook {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
effectiveDate: string;
|
||||
expiryDate?: string;
|
||||
isDefault: boolean;
|
||||
status: 'active' | 'inactive';
|
||||
items: PriceBookItem[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PriceBookItem {
|
||||
id: string;
|
||||
itemId: string;
|
||||
itemType: 'service' | 'part' | 'equipment' | 'labor';
|
||||
name: string;
|
||||
sku?: string;
|
||||
description?: string;
|
||||
unitPrice: number;
|
||||
costPrice?: number;
|
||||
margin?: number;
|
||||
taxable: boolean;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
// Service Agreement Types
|
||||
export interface ServiceAgreement {
|
||||
id: string;
|
||||
customerId: string;
|
||||
agreementNumber: string;
|
||||
name: string;
|
||||
type: 'maintenance' | 'warranty' | 'service-plan';
|
||||
status: 'active' | 'expired' | 'cancelled';
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
renewalDate?: string;
|
||||
autoRenew: boolean;
|
||||
billingCycle: 'monthly' | 'quarterly' | 'annual';
|
||||
amount: number;
|
||||
services: ServiceItem[];
|
||||
equipmentIds?: string[];
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
frequency: 'weekly' | 'monthly' | 'quarterly' | 'semi-annual' | 'annual';
|
||||
nextDue?: string;
|
||||
lastCompleted?: string;
|
||||
}
|
||||
|
||||
// Task Types
|
||||
export interface Task {
|
||||
id: string;
|
||||
jobId?: string;
|
||||
customerId?: string;
|
||||
technicianId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'call' | 'email' | 'follow-up' | 'inspection' | 'other';
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'cancelled';
|
||||
dueDate?: string;
|
||||
completedDate?: string;
|
||||
assignedTo?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Call Tracking Types
|
||||
export interface Call {
|
||||
id: string;
|
||||
customerId?: string;
|
||||
phone: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
callType: 'inquiry' | 'booking' | 'follow-up' | 'support' | 'sales';
|
||||
status: 'answered' | 'missed' | 'voicemail' | 'busy';
|
||||
duration?: number;
|
||||
recordingUrl?: string;
|
||||
notes?: string;
|
||||
outcome?: string;
|
||||
jobId?: string;
|
||||
technicianId?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Reporting Types
|
||||
export interface Report {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ReportType;
|
||||
parameters?: Record<string, any>;
|
||||
generatedAt: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export type ReportType =
|
||||
| 'revenue'
|
||||
| 'technician-productivity'
|
||||
| 'job-completion'
|
||||
| 'customer-satisfaction'
|
||||
| 'inventory-valuation'
|
||||
| 'aging-receivables'
|
||||
| 'sales-by-category'
|
||||
| 'equipment-maintenance'
|
||||
| 'custom';
|
||||
|
||||
export interface RevenueReport {
|
||||
period: string;
|
||||
totalRevenue: number;
|
||||
invoicedAmount: number;
|
||||
collectedAmount: number;
|
||||
outstandingAmount: number;
|
||||
byCategory: Record<string, number>;
|
||||
byTechnician: Record<string, number>;
|
||||
trend: RevenueDataPoint[];
|
||||
}
|
||||
|
||||
export interface RevenueDataPoint {
|
||||
date: string;
|
||||
revenue: number;
|
||||
jobs: number;
|
||||
}
|
||||
|
||||
export interface TechnicianProductivityReport {
|
||||
period: string;
|
||||
technicians: TechnicianMetrics[];
|
||||
}
|
||||
|
||||
export interface TechnicianMetrics {
|
||||
technicianId: string;
|
||||
technicianName: string;
|
||||
jobsCompleted: number;
|
||||
hoursWorked: number;
|
||||
revenue: number;
|
||||
averageJobTime: number;
|
||||
utilizationRate: number;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
data: T[];
|
||||
totalCount: number;
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: ApiError;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Search/Filter Types
|
||||
export interface CustomerSearchParams {
|
||||
query?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
type?: 'residential' | 'commercial';
|
||||
tags?: string[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortBy?: 'name' | 'createdDate' | 'balance';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
filter?: Record<string, any>;
|
||||
search?: string;
|
||||
[key: string]: any; // Allow additional query parameters
|
||||
}
|
||||
|
||||
// Vehicle Types
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'truck' | 'van' | 'car' | 'trailer';
|
||||
make: string;
|
||||
model: string;
|
||||
year: number;
|
||||
vin?: string;
|
||||
licensePlate: string;
|
||||
status: 'active' | 'maintenance' | 'inactive';
|
||||
assignedTechnicianId?: string;
|
||||
mileage?: number;
|
||||
lastServiceDate?: string;
|
||||
nextServiceDue?: string;
|
||||
inventoryItems?: string[];
|
||||
gpsEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Time Tracking Types
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
technicianId: string;
|
||||
jobId?: string;
|
||||
type: 'regular' | 'overtime' | 'travel' | 'break';
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
billable: boolean;
|
||||
approved: boolean;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Forms/Checklist Types
|
||||
export interface Form {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: 'inspection' | 'safety' | 'maintenance' | 'quote' | 'custom';
|
||||
status: 'active' | 'inactive';
|
||||
fields: FormField[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'checkbox' | 'select' | 'radio' | 'date' | 'signature' | 'photo';
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
defaultValue?: any;
|
||||
validationRules?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface FormSubmission {
|
||||
id: string;
|
||||
formId: string;
|
||||
jobId?: string;
|
||||
technicianId: string;
|
||||
export interface JobSearchParams {
|
||||
customerId?: string;
|
||||
responses: Record<string, any>;
|
||||
attachments?: string[];
|
||||
submittedAt: string;
|
||||
status?: Job['status'];
|
||||
priority?: Job['priority'];
|
||||
technicianId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
jobType?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// Marketing Campaign Types
|
||||
export interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'email' | 'sms' | 'direct-mail' | 'social';
|
||||
status: 'draft' | 'scheduled' | 'active' | 'completed' | 'cancelled';
|
||||
startDate: string;
|
||||
export interface InvoiceSearchParams {
|
||||
customerId?: string;
|
||||
status?: Invoice['status'];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
targetAudience: string[];
|
||||
message: string;
|
||||
sentCount: number;
|
||||
deliveredCount: number;
|
||||
openedCount: number;
|
||||
clickedCount: number;
|
||||
convertedCount: number;
|
||||
roi?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
503
servers/freshdesk/src/api-client.ts
Normal file
503
servers/freshdesk/src/api-client.ts
Normal file
@ -0,0 +1,503 @@
|
||||
// FreshDesk API v2 Client with Basic Auth
|
||||
|
||||
import type { FreshDeskConfig } from './types/index.js';
|
||||
|
||||
export class FreshDeskClient {
|
||||
private domain: string;
|
||||
private authHeader: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(config: FreshDeskConfig) {
|
||||
this.domain = config.domain;
|
||||
// Basic auth: base64(apiKey:X)
|
||||
this.authHeader = 'Basic ' + Buffer.from(`${config.apiKey}:X`).toString('base64');
|
||||
this.baseUrl = `https://${this.domain}.freshdesk.com/api/v2`;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Authorization', this.authHeader);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`FreshDesk API error (${response.status}): ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// GET request
|
||||
async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
|
||||
let url = endpoint;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => searchParams.append(key, String(v)));
|
||||
} else {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
const queryString = searchParams.toString();
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
return this.request<T>(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
// POST request
|
||||
async post<T>(endpoint: string, body?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// PUT request
|
||||
async put<T>(endpoint: string, body?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE request
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// TICKETS
|
||||
async getTickets(params?: any) {
|
||||
return this.get('/tickets', params);
|
||||
}
|
||||
|
||||
async getTicket(id: number, include?: string[]) {
|
||||
const params = include ? { include: include.join(',') } : undefined;
|
||||
return this.get(`/tickets/${id}`, params);
|
||||
}
|
||||
|
||||
async createTicket(data: any) {
|
||||
return this.post('/tickets', data);
|
||||
}
|
||||
|
||||
async updateTicket(id: number, data: any) {
|
||||
return this.put(`/tickets/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteTicket(id: number) {
|
||||
return this.delete(`/tickets/${id}`);
|
||||
}
|
||||
|
||||
async restoreTicket(id: number) {
|
||||
return this.put(`/tickets/${id}/restore`, {});
|
||||
}
|
||||
|
||||
async getTicketConversations(id: number) {
|
||||
return this.get(`/tickets/${id}/conversations`);
|
||||
}
|
||||
|
||||
async addTicketReply(id: number, data: any) {
|
||||
return this.post(`/tickets/${id}/reply`, data);
|
||||
}
|
||||
|
||||
async addTicketNote(id: number, data: any) {
|
||||
return this.post(`/tickets/${id}/notes`, data);
|
||||
}
|
||||
|
||||
async getTicketTimeEntries(id: number) {
|
||||
return this.get(`/tickets/${id}/time_entries`);
|
||||
}
|
||||
|
||||
async getTicketSatisfactionRatings(id: number) {
|
||||
return this.get(`/tickets/${id}/satisfaction_ratings`);
|
||||
}
|
||||
|
||||
async searchTickets(query: string) {
|
||||
return this.get(`/search/tickets?query="${encodeURIComponent(query)}"`);
|
||||
}
|
||||
|
||||
async filterTickets(query: string, params?: any) {
|
||||
return this.get(`/tickets/filter?query=${encodeURIComponent(query)}`, params);
|
||||
}
|
||||
|
||||
// CONTACTS
|
||||
async getContacts(params?: any) {
|
||||
return this.get('/contacts', params);
|
||||
}
|
||||
|
||||
async getContact(id: number) {
|
||||
return this.get(`/contacts/${id}`);
|
||||
}
|
||||
|
||||
async createContact(data: any) {
|
||||
return this.post('/contacts', data);
|
||||
}
|
||||
|
||||
async updateContact(id: number, data: any) {
|
||||
return this.put(`/contacts/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteContact(id: number) {
|
||||
return this.delete(`/contacts/${id}`);
|
||||
}
|
||||
|
||||
async makeContactAgent(id: number) {
|
||||
return this.put(`/contacts/${id}/make_agent`, {});
|
||||
}
|
||||
|
||||
async searchContacts(params: any) {
|
||||
return this.get('/contacts/autocomplete', params);
|
||||
}
|
||||
|
||||
async filterContacts(query: string) {
|
||||
return this.get(`/contacts?query=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// COMPANIES
|
||||
async getCompanies(params?: any) {
|
||||
return this.get('/companies', params);
|
||||
}
|
||||
|
||||
async getCompany(id: number) {
|
||||
return this.get(`/companies/${id}`);
|
||||
}
|
||||
|
||||
async createCompany(data: any) {
|
||||
return this.post('/companies', data);
|
||||
}
|
||||
|
||||
async updateCompany(id: number, data: any) {
|
||||
return this.put(`/companies/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteCompany(id: number) {
|
||||
return this.delete(`/companies/${id}`);
|
||||
}
|
||||
|
||||
async searchCompanies(name: string) {
|
||||
return this.get('/companies/autocomplete', { name });
|
||||
}
|
||||
|
||||
async filterCompanies(query: string) {
|
||||
return this.get(`/companies/filter?query=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// AGENTS
|
||||
async getAgents(params?: any) {
|
||||
return this.get('/agents', params);
|
||||
}
|
||||
|
||||
async getAgent(id: number) {
|
||||
return this.get(`/agents/${id}`);
|
||||
}
|
||||
|
||||
async updateAgent(id: number, data: any) {
|
||||
return this.put(`/agents/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteAgent(id: number) {
|
||||
return this.delete(`/agents/${id}`);
|
||||
}
|
||||
|
||||
async getCurrentAgent() {
|
||||
return this.get('/agents/me');
|
||||
}
|
||||
|
||||
// GROUPS
|
||||
async getGroups(params?: any) {
|
||||
return this.get('/groups', params);
|
||||
}
|
||||
|
||||
async getGroup(id: number) {
|
||||
return this.get(`/groups/${id}`);
|
||||
}
|
||||
|
||||
async createGroup(data: any) {
|
||||
return this.post('/groups', data);
|
||||
}
|
||||
|
||||
async updateGroup(id: number, data: any) {
|
||||
return this.put(`/groups/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteGroup(id: number) {
|
||||
return this.delete(`/groups/${id}`);
|
||||
}
|
||||
|
||||
// PRODUCTS
|
||||
async getProducts(params?: any) {
|
||||
return this.get('/products', params);
|
||||
}
|
||||
|
||||
async getProduct(id: number) {
|
||||
return this.get(`/products/${id}`);
|
||||
}
|
||||
|
||||
async createProduct(data: any) {
|
||||
return this.post('/products', data);
|
||||
}
|
||||
|
||||
async updateProduct(id: number, data: any) {
|
||||
return this.put(`/products/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteProduct(id: number) {
|
||||
return this.delete(`/products/${id}`);
|
||||
}
|
||||
|
||||
// ROLES
|
||||
async getRoles() {
|
||||
return this.get('/roles');
|
||||
}
|
||||
|
||||
async getRole(id: number) {
|
||||
return this.get(`/roles/${id}`);
|
||||
}
|
||||
|
||||
// CANNED RESPONSES
|
||||
async getCannedResponses(params?: any) {
|
||||
return this.get('/canned_responses', params);
|
||||
}
|
||||
|
||||
async getCannedResponse(id: number) {
|
||||
return this.get(`/canned_responses/${id}`);
|
||||
}
|
||||
|
||||
async createCannedResponse(data: any) {
|
||||
return this.post('/canned_responses', data);
|
||||
}
|
||||
|
||||
async updateCannedResponse(id: number, data: any) {
|
||||
return this.put(`/canned_responses/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteCannedResponse(id: number) {
|
||||
return this.delete(`/canned_responses/${id}`);
|
||||
}
|
||||
|
||||
async getCannedResponseFolders() {
|
||||
return this.get('/canned_response_folders');
|
||||
}
|
||||
|
||||
// SOLUTIONS (KB)
|
||||
async getSolutionCategories(params?: any) {
|
||||
return this.get('/solutions/categories', params);
|
||||
}
|
||||
|
||||
async getSolutionCategory(id: number) {
|
||||
return this.get(`/solutions/categories/${id}`);
|
||||
}
|
||||
|
||||
async createSolutionCategory(data: any) {
|
||||
return this.post('/solutions/categories', data);
|
||||
}
|
||||
|
||||
async updateSolutionCategory(id: number, data: any) {
|
||||
return this.put(`/solutions/categories/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteSolutionCategory(id: number) {
|
||||
return this.delete(`/solutions/categories/${id}`);
|
||||
}
|
||||
|
||||
async getSolutionFolders(category_id: number) {
|
||||
return this.get(`/solutions/categories/${category_id}/folders`);
|
||||
}
|
||||
|
||||
async getSolutionFolder(category_id: number, folder_id: number) {
|
||||
return this.get(`/solutions/categories/${category_id}/folders/${folder_id}`);
|
||||
}
|
||||
|
||||
async createSolutionFolder(category_id: number, data: any) {
|
||||
return this.post(`/solutions/categories/${category_id}/folders`, data);
|
||||
}
|
||||
|
||||
async updateSolutionFolder(category_id: number, folder_id: number, data: any) {
|
||||
return this.put(`/solutions/categories/${category_id}/folders/${folder_id}`, data);
|
||||
}
|
||||
|
||||
async deleteSolutionFolder(category_id: number, folder_id: number) {
|
||||
return this.delete(`/solutions/categories/${category_id}/folders/${folder_id}`);
|
||||
}
|
||||
|
||||
async getSolutionArticles(folder_id: number, params?: any) {
|
||||
return this.get(`/solutions/folders/${folder_id}/articles`, params);
|
||||
}
|
||||
|
||||
async getSolutionArticle(id: number) {
|
||||
return this.get(`/solutions/articles/${id}`);
|
||||
}
|
||||
|
||||
async createSolutionArticle(folder_id: number, data: any) {
|
||||
return this.post(`/solutions/folders/${folder_id}/articles`, data);
|
||||
}
|
||||
|
||||
async updateSolutionArticle(id: number, data: any) {
|
||||
return this.put(`/solutions/articles/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteSolutionArticle(id: number) {
|
||||
return this.delete(`/solutions/articles/${id}`);
|
||||
}
|
||||
|
||||
async searchSolutions(term: string) {
|
||||
return this.get(`/solutions/articles?term=${encodeURIComponent(term)}`);
|
||||
}
|
||||
|
||||
// FORUMS
|
||||
async getForumCategories() {
|
||||
return this.get('/discussions/categories');
|
||||
}
|
||||
|
||||
async getForums(category_id?: number) {
|
||||
if (category_id) {
|
||||
return this.get(`/discussions/categories/${category_id}/forums`);
|
||||
}
|
||||
return this.get('/discussions/forums');
|
||||
}
|
||||
|
||||
async getForum(id: number) {
|
||||
return this.get(`/discussions/forums/${id}`);
|
||||
}
|
||||
|
||||
async createForum(data: any) {
|
||||
return this.post('/discussions/forums', data);
|
||||
}
|
||||
|
||||
async updateForum(id: number, data: any) {
|
||||
return this.put(`/discussions/forums/${id}`, data);
|
||||
}
|
||||
|
||||
async getForumTopics(forum_id: number, params?: any) {
|
||||
return this.get(`/discussions/forums/${forum_id}/topics`, params);
|
||||
}
|
||||
|
||||
async getForumTopic(id: number) {
|
||||
return this.get(`/discussions/topics/${id}`);
|
||||
}
|
||||
|
||||
async createForumTopic(forum_id: number, data: any) {
|
||||
return this.post(`/discussions/forums/${forum_id}/topics`, data);
|
||||
}
|
||||
|
||||
async updateForumTopic(id: number, data: any) {
|
||||
return this.put(`/discussions/topics/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteForumTopic(id: number) {
|
||||
return this.delete(`/discussions/topics/${id}`);
|
||||
}
|
||||
|
||||
async getForumPosts(topic_id: number) {
|
||||
return this.get(`/discussions/topics/${topic_id}/posts`);
|
||||
}
|
||||
|
||||
async createForumPost(topic_id: number, data: any) {
|
||||
return this.post(`/discussions/topics/${topic_id}/posts`, data);
|
||||
}
|
||||
|
||||
// SURVEYS
|
||||
async getSurveys() {
|
||||
return this.get('/surveys');
|
||||
}
|
||||
|
||||
async getSurveyResults(survey_id: number, params?: any) {
|
||||
return this.get(`/surveys/${survey_id}/survey_results`, params);
|
||||
}
|
||||
|
||||
async getSatisfactionRatings(params?: any) {
|
||||
return this.get('/surveys/satisfaction_ratings', params);
|
||||
}
|
||||
|
||||
// BUSINESS HOURS
|
||||
async getBusinessHours() {
|
||||
return this.get('/business_hours');
|
||||
}
|
||||
|
||||
async getBusinessHour(id: number) {
|
||||
return this.get(`/business_hours/${id}`);
|
||||
}
|
||||
|
||||
// SLA POLICIES
|
||||
async getSLAPolicies() {
|
||||
return this.get('/sla_policies');
|
||||
}
|
||||
|
||||
async getSLAPolicy(id: number) {
|
||||
return this.get(`/sla_policies/${id}`);
|
||||
}
|
||||
|
||||
// EMAIL CONFIGS
|
||||
async getEmailConfigs() {
|
||||
return this.get('/email_configs');
|
||||
}
|
||||
|
||||
async getEmailConfig(id: number) {
|
||||
return this.get(`/email_configs/${id}`);
|
||||
}
|
||||
|
||||
// TICKET FIELDS
|
||||
async getTicketFields() {
|
||||
return this.get('/ticket_fields');
|
||||
}
|
||||
|
||||
async getTicketField(id: number) {
|
||||
return this.get(`/ticket_fields/${id}`);
|
||||
}
|
||||
|
||||
// SETTINGS
|
||||
async getSettings() {
|
||||
return this.get('/settings/helpdesk');
|
||||
}
|
||||
|
||||
// TIME ENTRIES
|
||||
async getAllTimeEntries(params?: any) {
|
||||
return this.get('/time_entries', params);
|
||||
}
|
||||
|
||||
async getTimeEntry(id: number) {
|
||||
return this.get(`/time_entries/${id}`);
|
||||
}
|
||||
|
||||
async createTimeEntry(data: any) {
|
||||
return this.post('/time_entries', data);
|
||||
}
|
||||
|
||||
async updateTimeEntry(id: number, data: any) {
|
||||
return this.put(`/time_entries/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteTimeEntry(id: number) {
|
||||
return this.delete(`/time_entries/${id}`);
|
||||
}
|
||||
|
||||
async startTimer(id: number) {
|
||||
return this.put(`/time_entries/${id}/start_timer`, {});
|
||||
}
|
||||
|
||||
async stopTimer(id: number) {
|
||||
return this.put(`/time_entries/${id}/stop_timer`, {});
|
||||
}
|
||||
}
|
||||
1355
servers/freshdesk/src/tools/index.ts
Normal file
1355
servers/freshdesk/src/tools/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,3 @@
|
||||
# HelpScout API Credentials
|
||||
# Get these from: https://secure.helpscout.net/apps/
|
||||
|
||||
HELPSCOUT_APP_ID=your_app_id_here
|
||||
HELPSCOUT_APP_SECRET=your_app_secret_here
|
||||
HELPSCOUT_ACCESS_TOKEN=your_oauth2_access_token_here
|
||||
|
||||
@ -1,34 +1,38 @@
|
||||
{
|
||||
"name": "@mcpengine/helpscout-server",
|
||||
"name": "@mcpengine/helpscout-mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "HelpScout MCP Server - Complete Mailbox API v2 integration",
|
||||
"description": "Complete HelpScout Mailbox API v2 MCP Server with OAuth2 authentication",
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"bin": {
|
||||
"helpscout-mcp": "./dist/main.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && npm run copy-apps",
|
||||
"copy-apps": "mkdir -p dist/apps && cp -r src/apps/*.tsx dist/apps/ 2>/dev/null || true",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/main.js",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"helpscout",
|
||||
"customer-support",
|
||||
"mailbox",
|
||||
"conversations"
|
||||
"mcp",
|
||||
"support",
|
||||
"customer-service",
|
||||
"helpdesk",
|
||||
"oauth2"
|
||||
],
|
||||
"author": "MCPEngine",
|
||||
"author": "MCP Engine",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"axios": "^1.7.9",
|
||||
"dotenv": "^16.4.7"
|
||||
"dotenv": "^16.4.7",
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.3.18",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,252 +1,127 @@
|
||||
// HelpScout API Types
|
||||
// HelpScout Mailbox API v2 Types
|
||||
|
||||
export interface HelpScoutConfig {
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
accessToken?: string;
|
||||
tokenExpiry?: number;
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface AccessTokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
_embedded?: { [key: string]: T[] };
|
||||
export interface APIError {
|
||||
message: string;
|
||||
logref?: string;
|
||||
_links?: {
|
||||
about?: { href: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
_embedded: T;
|
||||
_links?: {
|
||||
self?: { href: string };
|
||||
first?: { href: string };
|
||||
last?: { href: string };
|
||||
next?: { href: string };
|
||||
prev?: { href: string };
|
||||
self?: { href: string };
|
||||
page?: { href: string };
|
||||
};
|
||||
page?: {
|
||||
number: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Conversations
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
number: number;
|
||||
threads: number;
|
||||
type: 'email' | 'chat' | 'phone';
|
||||
folderId: number;
|
||||
status: 'active' | 'pending' | 'closed' | 'spam';
|
||||
state: 'draft' | 'published' | 'deleted';
|
||||
status: 'active' | 'closed' | 'open' | 'pending' | 'spam';
|
||||
state: 'deleted' | 'draft' | 'published';
|
||||
subject: string;
|
||||
preview: string;
|
||||
mailboxId: number;
|
||||
assignee?: User;
|
||||
createdBy: User | Customer;
|
||||
assignee?: Person;
|
||||
createdBy: Person;
|
||||
createdAt: string;
|
||||
closedBy?: number;
|
||||
closedByUser?: Person;
|
||||
closedAt?: string;
|
||||
closedBy?: User;
|
||||
updatedAt?: string;
|
||||
userUpdatedAt?: string;
|
||||
customerWaitingSince?: CustomerWaitingSince;
|
||||
source?: ConversationSource;
|
||||
userUpdatedAt: string;
|
||||
customerWaitingSince?: {
|
||||
time: string;
|
||||
friendly: string;
|
||||
};
|
||||
source: {
|
||||
type: string;
|
||||
via: 'user' | 'customer';
|
||||
};
|
||||
tags?: Tag[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
primaryCustomer: Customer;
|
||||
snooze?: {
|
||||
snoozedBy: number;
|
||||
snoozedUntil: string;
|
||||
unsnoozeOnCustomerReply: boolean;
|
||||
};
|
||||
nextEvent?: {
|
||||
time: string;
|
||||
eventType: 'snooze' | 'scheduled';
|
||||
userId: number;
|
||||
cancelOnCustomerReply: boolean;
|
||||
};
|
||||
customFields?: CustomField[];
|
||||
_embedded?: {
|
||||
threads?: Thread[];
|
||||
};
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: number;
|
||||
assignedTo?: User;
|
||||
status: 'active' | 'nochange' | 'pending' | 'closed';
|
||||
createdAt: string;
|
||||
createdBy: User | Customer;
|
||||
source?: ThreadSource;
|
||||
type: 'note' | 'message' | 'reply' | 'customer' | 'lineitem' | 'forwardparent' | 'forwardchild' | 'phone';
|
||||
state: 'draft' | 'published' | 'deleted' | 'hidden';
|
||||
type: 'note' | 'message' | 'customer' | 'lineitem' | 'chat' | 'phone';
|
||||
status: 'active' | 'nochange' | 'pending';
|
||||
state: 'published' | 'draft' | 'deleted' | 'hidden';
|
||||
action?: {
|
||||
type: string;
|
||||
text: string;
|
||||
};
|
||||
body: string;
|
||||
source: {
|
||||
type: string;
|
||||
via: string;
|
||||
};
|
||||
customer?: Customer;
|
||||
fromMailbox?: Mailbox;
|
||||
body?: string;
|
||||
createdBy: Person;
|
||||
assignedTo?: Person;
|
||||
savedReplyId?: number;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
attachments?: Attachment[];
|
||||
savedReplyId?: number;
|
||||
createdAt: string;
|
||||
openedAt?: string;
|
||||
_embedded?: {
|
||||
attachments?: Attachment[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
photoUrl?: string;
|
||||
photoType?: string;
|
||||
gender?: string;
|
||||
age?: string;
|
||||
organization?: string;
|
||||
jobTitle?: string;
|
||||
location?: string;
|
||||
background?: string;
|
||||
address?: CustomerAddress;
|
||||
socialProfiles?: SocialProfile[];
|
||||
chats?: Chat[];
|
||||
websites?: Website[];
|
||||
emails?: CustomerEmail[];
|
||||
phones?: CustomerPhone[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerAddress {
|
||||
id: number;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
lines: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerEmail {
|
||||
id: number;
|
||||
value: string;
|
||||
type: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface CustomerPhone {
|
||||
id: number;
|
||||
value: string;
|
||||
type: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface SocialProfile {
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: number;
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Website {
|
||||
id: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'mine' | 'drafts' | 'assigned' | 'closed' | 'spam' | 'open';
|
||||
userId: number;
|
||||
totalCount: number;
|
||||
activeCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
timezone: string;
|
||||
photoUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
type?: 'user' | 'team';
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id?: number;
|
||||
name?: string;
|
||||
color?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
ticketCount?: number;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: number;
|
||||
mailboxId: number;
|
||||
type: 'manual' | 'automatic';
|
||||
status: 'active' | 'inactive' | 'invalid';
|
||||
order: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStats {
|
||||
totalRuns: number;
|
||||
successfulRuns: number;
|
||||
failedRuns: number;
|
||||
}
|
||||
|
||||
export interface SavedReply {
|
||||
id: number;
|
||||
name: string;
|
||||
export interface CreateThread {
|
||||
type: 'note' | 'message' | 'customer' | 'reply' | 'forwardCustomer' | 'forwardParent';
|
||||
text: string;
|
||||
mailboxId?: number;
|
||||
userId?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
memberCount?: number;
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
events: string[];
|
||||
state: 'enabled' | 'disabled';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomField {
|
||||
id: number;
|
||||
name: string;
|
||||
value: any;
|
||||
type: 'TEXT' | 'NUMBER' | 'DATE' | 'DROPDOWN';
|
||||
}
|
||||
|
||||
export interface ConversationSource {
|
||||
type: string;
|
||||
via: string;
|
||||
}
|
||||
|
||||
export interface ThreadSource {
|
||||
type: string;
|
||||
via: string;
|
||||
}
|
||||
|
||||
export interface CustomerWaitingSince {
|
||||
time: string;
|
||||
friendly: string;
|
||||
latestReplyFrom: 'customer' | 'user';
|
||||
user?: number;
|
||||
customer?: number;
|
||||
imported?: boolean;
|
||||
createdAt?: string;
|
||||
status?: 'active' | 'nochange' | 'pending';
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
attachments?: CreateAttachment[];
|
||||
draft?: boolean;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
@ -257,64 +132,369 @@ export interface Attachment {
|
||||
width?: number;
|
||||
height?: number;
|
||||
url: string;
|
||||
_links?: {
|
||||
data?: { href: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateAttachment {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
data: string; // base64
|
||||
}
|
||||
|
||||
// Customers
|
||||
export interface Customer {
|
||||
id: number;
|
||||
type?: 'customer';
|
||||
first?: string;
|
||||
last?: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
photoUrl?: string;
|
||||
photoType?: 'twitter' | 'facebook' | 'gravatar' | 'google' | 'unknown';
|
||||
gender?: 'male' | 'female' | 'unknown';
|
||||
age?: string;
|
||||
organization?: string;
|
||||
jobTitle?: string;
|
||||
location?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
background?: string;
|
||||
address?: Address;
|
||||
socialProfiles?: SocialProfile[];
|
||||
emails?: CustomerEmail[];
|
||||
phones?: CustomerPhone[];
|
||||
chats?: CustomerChat[];
|
||||
websites?: CustomerWebsite[];
|
||||
_embedded?: {
|
||||
entries?: CustomerEntry[];
|
||||
};
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
id?: number;
|
||||
lines: string[];
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface SocialProfile {
|
||||
id?: number;
|
||||
type: 'twitter' | 'facebook' | 'linkedin' | 'aboutme' | 'google' | 'googleplus' | 'tungleme' | 'quora' | 'foursquare' | 'youtube' | 'flickr' | 'other';
|
||||
value: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CustomerEmail {
|
||||
id?: number;
|
||||
type: 'home' | 'work' | 'other';
|
||||
value: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CustomerPhone {
|
||||
id?: number;
|
||||
type: 'home' | 'work' | 'mobile' | 'fax' | 'pager' | 'other';
|
||||
value: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CustomerChat {
|
||||
id?: number;
|
||||
type: 'aim' | 'gtalk' | 'icq' | 'xmpp' | 'msn' | 'skype' | 'yahoo' | 'qq' | 'other';
|
||||
value: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CustomerWebsite {
|
||||
id?: number;
|
||||
value: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CustomerEntry {
|
||||
id: number;
|
||||
type: 'email' | 'phone' | 'chat' | 'website';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CreateCustomer {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
photoUrl?: string;
|
||||
photoType?: 'twitter' | 'facebook' | 'gravatar' | 'google' | 'unknown';
|
||||
gender?: 'male' | 'female' | 'unknown';
|
||||
age?: string;
|
||||
organization?: string;
|
||||
jobTitle?: string;
|
||||
location?: string;
|
||||
background?: string;
|
||||
address?: Omit<Address, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
socialProfiles?: Omit<SocialProfile, 'id' | 'createdAt' | 'updatedAt'>[];
|
||||
emails?: Omit<CustomerEmail, 'id' | 'createdAt' | 'updatedAt'>[];
|
||||
phones?: Omit<CustomerPhone, 'id' | 'createdAt' | 'updatedAt'>[];
|
||||
chats?: Omit<CustomerChat, 'id' | 'createdAt' | 'updatedAt'>[];
|
||||
websites?: Omit<CustomerWebsite, 'id' | 'createdAt' | 'updatedAt'>[];
|
||||
}
|
||||
|
||||
// Mailboxes
|
||||
export interface Mailbox {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
export interface MailboxFields {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'SINGLE_LINE' | 'MULTI_LINE' | 'DATE' | 'NUMBER' | 'DROPDOWN';
|
||||
order: number;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'mytickets' | 'unassigned' | 'drafts' | 'assigned' | 'closed' | 'spam' | 'deleted' | 'mine';
|
||||
userId?: number;
|
||||
totalCount: number;
|
||||
activeCount: number;
|
||||
updatedAt: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
// Users
|
||||
export interface User {
|
||||
id: number;
|
||||
type: 'user' | 'team';
|
||||
first?: string;
|
||||
last?: string;
|
||||
email: string;
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
timezone: string;
|
||||
photoUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: number;
|
||||
type: 'user' | 'customer' | 'team';
|
||||
first?: string;
|
||||
last?: string;
|
||||
email?: string;
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
// Teams
|
||||
export interface Team {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: number;
|
||||
first: string;
|
||||
last: string;
|
||||
email: string;
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
photoUrl?: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
// Tags
|
||||
export interface Tag {
|
||||
id: number;
|
||||
tag: string;
|
||||
color: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
// Workflows
|
||||
export interface Workflow {
|
||||
id: number;
|
||||
mailboxId: number;
|
||||
type: 'manual' | 'automatic';
|
||||
status: 'active' | 'inactive' | 'invalid';
|
||||
order: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
// Saved Replies
|
||||
export interface SavedReply {
|
||||
id: number;
|
||||
text: string;
|
||||
name: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
// Webhooks
|
||||
export interface Webhook {
|
||||
id: number;
|
||||
url: string;
|
||||
state: 'enabled' | 'disabled';
|
||||
events: string[];
|
||||
notification: boolean;
|
||||
payloadVersion: 'V1' | 'V2';
|
||||
label?: string;
|
||||
secret?: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
export interface CreateWebhook {
|
||||
url: string;
|
||||
events: string[];
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
// Reports
|
||||
export interface Report {
|
||||
filterTags?: any;
|
||||
current?: any;
|
||||
previous?: any;
|
||||
deltas?: any;
|
||||
filterTags?: string[];
|
||||
}
|
||||
|
||||
export interface CompanyReport extends Report {
|
||||
current?: {
|
||||
conversationsCreated: number;
|
||||
conversationsClosed: number;
|
||||
customersHelped: number;
|
||||
happinessScore: number;
|
||||
repliesSent: number;
|
||||
resolutionTime: number;
|
||||
firstResponseTime: number;
|
||||
export interface ConversationReport {
|
||||
current: ReportMetrics;
|
||||
previous?: ReportMetrics;
|
||||
deltas?: {
|
||||
[key: string]: {
|
||||
value: number;
|
||||
percent: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversationReport extends Report {
|
||||
current?: {
|
||||
newConversations: number;
|
||||
totalConversations: number;
|
||||
conversationsBusiest: any;
|
||||
conversationsVolumeByDay: any[];
|
||||
export interface ReportMetrics {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
conversations: number;
|
||||
conversationsCreated: number;
|
||||
newConversations?: number;
|
||||
customers?: number;
|
||||
resolved?: number;
|
||||
replies?: number;
|
||||
repliesSent?: number;
|
||||
resolvedOnFirstReply?: number;
|
||||
responseTime?: TimeMetrics;
|
||||
resolutionTime?: TimeMetrics;
|
||||
firstResponseTime?: TimeMetrics;
|
||||
}
|
||||
|
||||
export interface TimeMetrics {
|
||||
friendly: string;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export interface UserReport {
|
||||
user: Person;
|
||||
current: UserMetrics;
|
||||
previous?: UserMetrics;
|
||||
deltas?: Record<string, { value: number; percent: number }>;
|
||||
}
|
||||
|
||||
export interface UserMetrics {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalConversations: number;
|
||||
conversationsCreated: number;
|
||||
conversationsResolved: number;
|
||||
repliesSent: number;
|
||||
resolvedOnFirstReply?: number;
|
||||
responseTime?: TimeMetrics;
|
||||
resolutionTime?: TimeMetrics;
|
||||
percentResolved?: number;
|
||||
happiness?: {
|
||||
score: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HappinessReport extends Report {
|
||||
current?: {
|
||||
happinessScore: number;
|
||||
ratingsCount: number;
|
||||
greatCount: number;
|
||||
okayCount: number;
|
||||
notGoodCount: number;
|
||||
};
|
||||
export interface HappinessReport {
|
||||
current: HappinessMetrics;
|
||||
previous?: HappinessMetrics;
|
||||
}
|
||||
|
||||
export interface ProductivityReport extends Report {
|
||||
current?: {
|
||||
repliesSent: number;
|
||||
resolutionTime: number;
|
||||
firstResponseTime: number;
|
||||
responseTime: number;
|
||||
resolvedConversations: number;
|
||||
};
|
||||
export interface HappinessMetrics {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
happinessScore: number;
|
||||
ratingsCount: number;
|
||||
}
|
||||
|
||||
export interface UserReport extends Report {
|
||||
current?: {
|
||||
user: User;
|
||||
repliesSent: number;
|
||||
conversationsCreated: number;
|
||||
conversationsClosed: number;
|
||||
happinessScore: number;
|
||||
resolutionTime: number;
|
||||
firstResponseTime: number;
|
||||
};
|
||||
// Custom Fields
|
||||
export interface CustomField {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string | number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// Notes
|
||||
export interface Note {
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Search
|
||||
export interface SearchConversation {
|
||||
id: number;
|
||||
number: number;
|
||||
mailboxid: number;
|
||||
subject: string;
|
||||
status: string;
|
||||
threadCount: number;
|
||||
preview: string;
|
||||
customerId: number;
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
updatedAt: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface SearchCustomer {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
photoUrl?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// Ratings
|
||||
export interface Rating {
|
||||
id: number;
|
||||
customerId: number;
|
||||
userId?: number;
|
||||
threadId: number;
|
||||
rating: 'great' | 'okay' | 'bad';
|
||||
comments?: string;
|
||||
createdAt: string;
|
||||
modifiedAt?: string;
|
||||
_links?: Record<string, { href: string }>;
|
||||
}
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
392
servers/touchbistro/README.md
Normal file
392
servers/touchbistro/README.md
Normal file
@ -0,0 +1,392 @@
|
||||
# TouchBistro MCP Server
|
||||
|
||||
A comprehensive Model Context Protocol (MCP) server for TouchBistro restaurant management platform, providing **190 tools** across 12 domains and **15 React MCP Apps** for visual management.
|
||||
|
||||
## Features
|
||||
|
||||
### 🍽️ Core Restaurant Management
|
||||
|
||||
- **Orders Management** (15 tools)
|
||||
- Create, update, track orders with full CRUD operations
|
||||
- Send to kitchen, manage modifications, void/cancel orders
|
||||
- Table orders, open orders, customer order history
|
||||
|
||||
- **Menu Management** (21 tools)
|
||||
- Menu items, categories, modifiers with full CRUD
|
||||
- Bulk updates, availability control
|
||||
- Modifier groups and custom modifiers
|
||||
|
||||
- **Customer Management** (12 tools)
|
||||
- Customer profiles with preferences and allergens
|
||||
- VIP tracking, tagging, address management
|
||||
- Customer statistics and analytics
|
||||
|
||||
- **Employee Management** (19 tools)
|
||||
- Employee profiles and permissions
|
||||
- Shift scheduling and time clock
|
||||
- Performance tracking and labor management
|
||||
|
||||
- **Table Management** (17 tools)
|
||||
- Floor plans and table layouts
|
||||
- Table status tracking (available, occupied, reserved)
|
||||
- Table merging/splitting, server assignments
|
||||
|
||||
### 💰 Financial Operations
|
||||
|
||||
- **Payment Processing** (13 tools)
|
||||
- Multiple payment methods (cash, card, mobile, gift cards)
|
||||
- Authorization, capture, void, refund operations
|
||||
- Split payments and tip management
|
||||
|
||||
- **Reservations** (15 tools)
|
||||
- Create, confirm, seat, and track reservations
|
||||
- Availability checking and reminders
|
||||
- No-show tracking
|
||||
|
||||
- **Loyalty Programs** (10 tools)
|
||||
- Points earning and redemption
|
||||
- Birthday bonuses and rewards
|
||||
- Member analytics and tier management
|
||||
|
||||
- **Gift Cards** (13 tools)
|
||||
- Purchase, reload, redeem gift cards
|
||||
- Balance checking and email delivery
|
||||
- Transaction history and sales tracking
|
||||
|
||||
### 📊 Inventory & Analytics
|
||||
|
||||
- **Inventory Management** (17 tools)
|
||||
- Stock tracking with par levels
|
||||
- Purchase orders and receiving
|
||||
- Low stock alerts and reorder management
|
||||
|
||||
- **Reports & Analytics** (24 tools)
|
||||
- Sales reports (daily, hourly, by category)
|
||||
- Employee performance and labor costs
|
||||
- Customer acquisition and retention
|
||||
- Menu performance and profitability
|
||||
- Real-time dashboard
|
||||
|
||||
- **Discounts & Promotions** (14 tools)
|
||||
- Create and manage discounts
|
||||
- Happy hour and daily specials
|
||||
- Promotion scheduling and tracking
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install touchbistro-mcp-server
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
TOUCHBISTRO_API_KEY=your_api_key
|
||||
TOUCHBISTRO_RESTAURANT_ID=your_restaurant_id
|
||||
TOUCHBISTRO_BASE_URL=https://api.touchbistro.com/v1 # Optional
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### As an MCP Server
|
||||
|
||||
Add to your Claude Desktop or other MCP client configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"touchbistro": {
|
||||
"command": "touchbistro-mcp-server",
|
||||
"env": {
|
||||
"TOUCHBISTRO_API_KEY": "your_api_key",
|
||||
"TOUCHBISTRO_RESTAURANT_ID": "your_restaurant_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Usage
|
||||
|
||||
```bash
|
||||
touchbistro-mcp-server
|
||||
```
|
||||
|
||||
## React MCP Apps (15 Apps)
|
||||
|
||||
This server includes 15 standalone React applications for visual management:
|
||||
|
||||
1. **Orders Dashboard** - Real-time order tracking and management
|
||||
2. **Menu Manager** - Visual menu builder with drag-and-drop
|
||||
3. **Reservations Calendar** - Interactive reservation scheduling
|
||||
4. **Table Layout** - Visual floor plan editor
|
||||
5. **Customer Directory** - Customer profiles and history
|
||||
6. **Employee Scheduler** - Shift scheduling and time tracking
|
||||
7. **Payments Dashboard** - Payment processing and reconciliation
|
||||
8. **Loyalty Manager** - Loyalty program administration
|
||||
9. **Gift Card Manager** - Gift card sales and tracking
|
||||
10. **Inventory Tracker** - Stock levels and purchase orders
|
||||
11. **Sales Reports** - Interactive sales analytics
|
||||
12. **Analytics Dashboard** - Business intelligence and KPIs
|
||||
13. **Discounts Manager** - Promotion creation and tracking
|
||||
14. **Settings Panel** - Restaurant configuration
|
||||
15. **Real-time Dashboard** - Live business overview
|
||||
|
||||
Each app is:
|
||||
- Dark theme optimized
|
||||
- Fully responsive
|
||||
- Built with React 18 + TypeScript
|
||||
- Vite-powered for fast development
|
||||
- Standalone deployable
|
||||
|
||||
### Building Apps
|
||||
|
||||
```bash
|
||||
# Build all apps
|
||||
npm run build
|
||||
|
||||
# Build specific app
|
||||
npm run build:orders-app
|
||||
npm run build:menu-app
|
||||
# ... etc
|
||||
```
|
||||
|
||||
## API Structure
|
||||
|
||||
### Orders
|
||||
|
||||
```typescript
|
||||
// Create an order
|
||||
{
|
||||
"name": "create_order",
|
||||
"arguments": {
|
||||
"tableId": "table-123",
|
||||
"employeeId": "emp-456",
|
||||
"orderType": "dine_in",
|
||||
"items": [
|
||||
{
|
||||
"menuItemId": "item-789",
|
||||
"quantity": 2,
|
||||
"modifiers": [{ "modifierId": "mod-123" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Menu Management
|
||||
|
||||
```typescript
|
||||
// Create menu item
|
||||
{
|
||||
"name": "create_menu_item",
|
||||
"arguments": {
|
||||
"name": "Margherita Pizza",
|
||||
"categoryId": "cat-pizza",
|
||||
"price": 14.99,
|
||||
"description": "Classic Italian pizza",
|
||||
"allergens": ["gluten", "dairy"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reservations
|
||||
|
||||
```typescript
|
||||
// Create reservation
|
||||
{
|
||||
"name": "create_reservation",
|
||||
"arguments": {
|
||||
"customerName": "John Doe",
|
||||
"customerPhone": "555-0123",
|
||||
"partySize": 4,
|
||||
"date": "2024-12-25",
|
||||
"time": "19:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reports
|
||||
|
||||
```typescript
|
||||
// Get sales report
|
||||
{
|
||||
"name": "get_sales_report",
|
||||
"arguments": {
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": "2024-01-31"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### Orders (15 tools)
|
||||
- list_orders, get_order, create_order, update_order
|
||||
- add_order_items, remove_order_item, update_order_item
|
||||
- send_to_kitchen, complete_order, cancel_order, void_order
|
||||
- get_table_orders, get_open_orders, search_orders, get_customer_orders
|
||||
|
||||
### Menu (21 tools)
|
||||
- **Items**: list_menu_items, get_menu_item, create_menu_item, update_menu_item, delete_menu_item, bulk_update_menu_items, set_item_availability
|
||||
- **Categories**: list_menu_categories, get_menu_category, create_menu_category, update_menu_category, delete_menu_category, reorder_menu_categories
|
||||
- **Modifiers**: list_modifier_groups, get_modifier_group, create_modifier_group, update_modifier_group, delete_modifier_group, add_modifier, update_modifier, delete_modifier
|
||||
|
||||
### Customers (12 tools)
|
||||
- list_customers, get_customer, create_customer, update_customer, delete_customer
|
||||
- search_customers, add_customer_address, update_customer_preferences
|
||||
- get_customer_stats, get_vip_customers, tag_customer, untag_customer
|
||||
|
||||
### Employees (19 tools)
|
||||
- **Staff**: list_employees, get_employee, create_employee, update_employee, delete_employee, deactivate_employee, update_employee_permissions
|
||||
- **Shifts**: list_shifts, get_shift, create_shift, start_shift, end_shift, get_current_shifts
|
||||
- **Time Clock**: clock_in, clock_out, start_break, end_break, get_time_clock_entries, get_employee_hours
|
||||
|
||||
### Tables (17 tools)
|
||||
- **Tables**: list_tables, get_table, create_table, update_table, delete_table, set_table_status, assign_server, get_available_tables, get_occupied_tables, merge_tables, split_tables
|
||||
- **Floors**: list_floors, get_floor, create_floor, update_floor, delete_floor, get_floor_layout
|
||||
|
||||
### Payments (13 tools)
|
||||
- list_payments, get_payment, process_payment, authorize_payment, capture_payment
|
||||
- void_payment, refund_payment, get_payment_refunds, list_refunds
|
||||
- split_payment, add_tip, get_payment_summary, get_payment_methods_summary
|
||||
|
||||
### Reservations (15 tools)
|
||||
- list_reservations, get_reservation, create_reservation, update_reservation, delete_reservation
|
||||
- confirm_reservation, cancel_reservation, seat_reservation, mark_no_show, complete_reservation
|
||||
- get_reservations_by_date, get_upcoming_reservations, search_reservations, send_reminder, get_available_slots
|
||||
|
||||
### Loyalty (10 tools)
|
||||
- get_loyalty_program, update_loyalty_program, get_customer_points
|
||||
- add_loyalty_points, redeem_loyalty_points, get_loyalty_transactions
|
||||
- get_loyalty_members, get_top_loyalty_members, award_birthday_points, get_loyalty_analytics
|
||||
|
||||
### Gift Cards (13 tools)
|
||||
- list_gift_cards, get_gift_card, get_gift_card_by_number, create_gift_card
|
||||
- purchase_gift_card, reload_gift_card, redeem_gift_card, void_gift_card
|
||||
- activate_gift_card, check_balance, get_gift_card_transactions, send_gift_card_email, get_gift_card_sales
|
||||
|
||||
### Inventory (17 tools)
|
||||
- **Items**: list_inventory_items, get_inventory_item, create_inventory_item, update_inventory_item, delete_inventory_item, adjust_stock, get_stock_adjustments, get_low_stock_items, get_items_to_reorder, get_inventory_valuation
|
||||
- **Purchase Orders**: list_purchase_orders, get_purchase_order, create_purchase_order, update_purchase_order, send_purchase_order, receive_purchase_order, cancel_purchase_order
|
||||
|
||||
### Reports (24 tools)
|
||||
- **Sales**: get_sales_report, get_daily_sales, get_sales_by_hour, get_sales_by_day, get_sales_by_category, get_sales_by_payment_method
|
||||
- **Menu**: get_top_selling_items, get_menu_performance, get_menu_item_analytics
|
||||
- **Staff**: get_employee_performance, get_employee_sales, get_labor_cost_report
|
||||
- **Customers**: get_customer_analytics, get_customer_acquisition, get_customer_retention
|
||||
- **Other**: get_tax_report, get_discount_usage, get_tip_report, get_order_type_distribution, get_table_turnover, get_average_order_value, export_report_csv, get_realtime_dashboard, get_profit_loss_report
|
||||
|
||||
### Discounts (14 tools)
|
||||
- **Discounts**: list_discounts, get_discount, create_discount, update_discount, delete_discount, validate_discount_code, apply_discount, remove_discount
|
||||
- **Promotions**: list_promotions, get_promotion, create_promotion, update_promotion, delete_promotion, get_active_promotions
|
||||
|
||||
## Resources
|
||||
|
||||
- `touchbistro://config` - View current API configuration
|
||||
- `touchbistro://docs` - Complete documentation
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
touchbistro/
|
||||
├── src/
|
||||
│ ├── clients/
|
||||
│ │ └── touchbistro.ts # API client with auth & error handling
|
||||
│ ├── types/
|
||||
│ │ └── index.ts # TypeScript type definitions
|
||||
│ ├── tools/
|
||||
│ │ ├── orders.ts # Order management tools
|
||||
│ │ ├── menus.ts # Menu management tools
|
||||
│ │ ├── customers.ts # Customer management tools
|
||||
│ │ ├── employees.ts # Employee management tools
|
||||
│ │ ├── tables.ts # Table management tools
|
||||
│ │ ├── payments.ts # Payment processing tools
|
||||
│ │ ├── reservations.ts # Reservation tools
|
||||
│ │ ├── loyalty.ts # Loyalty program tools
|
||||
│ │ ├── giftcards.ts # Gift card tools
|
||||
│ │ ├── inventory.ts # Inventory management tools
|
||||
│ │ ├── reports.ts # Reporting tools
|
||||
│ │ └── discounts.ts # Discount & promotion tools
|
||||
│ ├── ui/ # 15 React MCP Apps
|
||||
│ │ ├── orders-app/
|
||||
│ │ ├── menu-app/
|
||||
│ │ ├── reservations-app/
|
||||
│ │ ├── tables-app/
|
||||
│ │ ├── customers-app/
|
||||
│ │ ├── employees-app/
|
||||
│ │ ├── payments-app/
|
||||
│ │ ├── loyalty-app/
|
||||
│ │ ├── giftcards-app/
|
||||
│ │ ├── inventory-app/
|
||||
│ │ ├── reports-app/
|
||||
│ │ ├── analytics-app/
|
||||
│ │ ├── discounts-app/
|
||||
│ │ ├── settings-app/
|
||||
│ │ └── dashboard-app/
|
||||
│ ├── server.ts # MCP server implementation
|
||||
│ └── main.ts # Entry point
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build TypeScript
|
||||
npm run build
|
||||
|
||||
# Watch mode for development
|
||||
npm run dev
|
||||
|
||||
# Run the server
|
||||
npm start
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test connection
|
||||
echo '{"method":"tools/list"}' | touchbistro-mcp-server
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions, please visit the [GitHub repository](https://github.com/BusyBee3333/mcpengine).
|
||||
|
||||
## TouchBistro API
|
||||
|
||||
This server implements the TouchBistro REST API v1. For API documentation, contact TouchBistro or visit their developer portal.
|
||||
|
||||
### API Authentication
|
||||
|
||||
TouchBistro uses API key authentication:
|
||||
- Header: `Authorization: Bearer {api_key}`
|
||||
- Header: `X-Restaurant-ID: {restaurant_id}`
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Please be aware of TouchBistro's API rate limits. This server implements automatic retry with exponential backoff for rate-limited requests.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please submit pull requests to the main repository.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.0.0 (2024)
|
||||
- Initial release
|
||||
- 190 tools across 12 domains
|
||||
- 15 React MCP Apps
|
||||
- Full CRUD operations for all resources
|
||||
- Comprehensive reporting and analytics
|
||||
623
servers/touchbistro/src/lib/api-client.ts
Normal file
623
servers/touchbistro/src/lib/api-client.ts
Normal file
@ -0,0 +1,623 @@
|
||||
/**
|
||||
* TouchBistro API Client
|
||||
* Production-grade client with auth, rate limiting, retry logic
|
||||
*/
|
||||
|
||||
import type {
|
||||
TouchBistroConfig,
|
||||
ApiResponse,
|
||||
ListParams,
|
||||
Order,
|
||||
OrderItem,
|
||||
MenuItem,
|
||||
MenuCategory,
|
||||
Menu,
|
||||
ModifierGroup,
|
||||
Table,
|
||||
Section,
|
||||
FloorPlan,
|
||||
Staff,
|
||||
TimeSheet,
|
||||
Customer,
|
||||
LoyaltyTransaction,
|
||||
Reservation,
|
||||
InventoryItem,
|
||||
StockAdjustment,
|
||||
Supplier,
|
||||
PurchaseOrder,
|
||||
Payment,
|
||||
SalesReport,
|
||||
InventoryReport,
|
||||
StaffReport,
|
||||
} from './types.js';
|
||||
|
||||
export class TouchBistroApiClient {
|
||||
private config: TouchBistroConfig;
|
||||
private baseUrl: string;
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
private rateLimitRemaining: number = 1000;
|
||||
private rateLimitReset: number = 0;
|
||||
|
||||
constructor(config: TouchBistroConfig) {
|
||||
this.config = config;
|
||||
this.baseUrl = config.baseUrl || 'https://cloud.touchbistro.com/api/v1';
|
||||
if (config.sandbox) {
|
||||
this.baseUrl = 'https://sandbox.touchbistro.com/api/v1';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authentication & Request Management
|
||||
// ============================================================================
|
||||
|
||||
private async ensureAuthenticated(): Promise<void> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Authentication failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = Date.now() + (data.expires_in - 60) * 1000; // Refresh 1 min early
|
||||
}
|
||||
|
||||
private async checkRateLimit(): Promise<void> {
|
||||
if (this.rateLimitRemaining <= 0 && Date.now() < this.rateLimitReset) {
|
||||
const waitTime = this.rateLimitReset - Date.now();
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
body?: any,
|
||||
retries = 3
|
||||
): Promise<ApiResponse<T>> {
|
||||
await this.ensureAuthenticated();
|
||||
await this.checkRateLimit();
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'X-Restaurant-ID': this.config.restaurantId,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Update rate limit tracking
|
||||
this.rateLimitRemaining = parseInt(
|
||||
response.headers.get('X-RateLimit-Remaining') || '1000'
|
||||
);
|
||||
this.rateLimitReset = parseInt(
|
||||
response.headers.get('X-RateLimit-Reset') || '0'
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429 && retries > 0) {
|
||||
// Rate limited, wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return this.request<T>(method, endpoint, body, retries - 1);
|
||||
}
|
||||
|
||||
if (response.status >= 500 && retries > 0) {
|
||||
// Server error, retry with exponential backoff
|
||||
await new Promise((resolve) => setTimeout(resolve, (4 - retries) * 1000));
|
||||
return this.request<T>(method, endpoint, body, retries - 1);
|
||||
}
|
||||
|
||||
const error = await response.json().catch(() => ({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: response.statusText,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: error.code || `HTTP_${response.status}`,
|
||||
message: error.message || response.statusText,
|
||||
details: error.details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (retries > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return this.request<T>(method, endpoint, body, retries - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Orders API
|
||||
// ============================================================================
|
||||
|
||||
async getOrders(params?: ListParams): Promise<ApiResponse<Order[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.pageSize) queryParams.set('pageSize', params.pageSize.toString());
|
||||
if (params?.sortBy) queryParams.set('sortBy', params.sortBy);
|
||||
if (params?.sortOrder) queryParams.set('sortOrder', params.sortOrder);
|
||||
if (params?.filter) {
|
||||
Object.entries(params.filter).forEach(([key, value]) => {
|
||||
queryParams.set(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return this.request<Order[]>('GET', `/orders?${queryParams}`);
|
||||
}
|
||||
|
||||
async getOrder(orderId: string): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('GET', `/orders/${orderId}`);
|
||||
}
|
||||
|
||||
async createOrder(order: Partial<Order>): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('POST', '/orders', order);
|
||||
}
|
||||
|
||||
async updateOrder(orderId: string, updates: Partial<Order>): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('PATCH', `/orders/${orderId}`, updates);
|
||||
}
|
||||
|
||||
async addOrderItem(orderId: string, item: Partial<OrderItem>): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('POST', `/orders/${orderId}/items`, item);
|
||||
}
|
||||
|
||||
async updateOrderItem(
|
||||
orderId: string,
|
||||
itemId: string,
|
||||
updates: Partial<OrderItem>
|
||||
): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('PATCH', `/orders/${orderId}/items/${itemId}`, updates);
|
||||
}
|
||||
|
||||
async removeOrderItem(orderId: string, itemId: string): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('DELETE', `/orders/${orderId}/items/${itemId}`);
|
||||
}
|
||||
|
||||
async sendOrderToKitchen(orderId: string): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('POST', `/orders/${orderId}/send-to-kitchen`);
|
||||
}
|
||||
|
||||
async completeOrder(orderId: string): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('POST', `/orders/${orderId}/complete`);
|
||||
}
|
||||
|
||||
async voidOrder(orderId: string, reason: string): Promise<ApiResponse<Order>> {
|
||||
return this.request<Order>('POST', `/orders/${orderId}/void`, { reason });
|
||||
}
|
||||
|
||||
async splitOrder(
|
||||
orderId: string,
|
||||
splits: { items: string[]; amount?: number }[]
|
||||
): Promise<ApiResponse<Order[]>> {
|
||||
return this.request<Order[]>('POST', `/orders/${orderId}/split`, { splits });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Menu Management API
|
||||
// ============================================================================
|
||||
|
||||
async getMenuItems(params?: ListParams): Promise<ApiResponse<MenuItem[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.filter) {
|
||||
Object.entries(params.filter).forEach(([key, value]) => {
|
||||
queryParams.set(key, String(value));
|
||||
});
|
||||
}
|
||||
return this.request<MenuItem[]>('GET', `/menu/items?${queryParams}`);
|
||||
}
|
||||
|
||||
async getMenuItem(itemId: string): Promise<ApiResponse<MenuItem>> {
|
||||
return this.request<MenuItem>('GET', `/menu/items/${itemId}`);
|
||||
}
|
||||
|
||||
async createMenuItem(item: Partial<MenuItem>): Promise<ApiResponse<MenuItem>> {
|
||||
return this.request<MenuItem>('POST', '/menu/items', item);
|
||||
}
|
||||
|
||||
async updateMenuItem(itemId: string, updates: Partial<MenuItem>): Promise<ApiResponse<MenuItem>> {
|
||||
return this.request<MenuItem>('PATCH', `/menu/items/${itemId}`, updates);
|
||||
}
|
||||
|
||||
async deleteMenuItem(itemId: string): Promise<ApiResponse<void>> {
|
||||
return this.request<void>('DELETE', `/menu/items/${itemId}`);
|
||||
}
|
||||
|
||||
async getMenuCategories(): Promise<ApiResponse<MenuCategory[]>> {
|
||||
return this.request<MenuCategory[]>('GET', '/menu/categories');
|
||||
}
|
||||
|
||||
async createMenuCategory(category: Partial<MenuCategory>): Promise<ApiResponse<MenuCategory>> {
|
||||
return this.request<MenuCategory>('POST', '/menu/categories', category);
|
||||
}
|
||||
|
||||
async updateMenuCategory(
|
||||
categoryId: string,
|
||||
updates: Partial<MenuCategory>
|
||||
): Promise<ApiResponse<MenuCategory>> {
|
||||
return this.request<MenuCategory>('PATCH', `/menu/categories/${categoryId}`, updates);
|
||||
}
|
||||
|
||||
async deleteMenuCategory(categoryId: string): Promise<ApiResponse<void>> {
|
||||
return this.request<void>('DELETE', `/menu/categories/${categoryId}`);
|
||||
}
|
||||
|
||||
async getMenus(): Promise<ApiResponse<Menu[]>> {
|
||||
return this.request<Menu[]>('GET', '/menu/menus');
|
||||
}
|
||||
|
||||
async createMenu(menu: Partial<Menu>): Promise<ApiResponse<Menu>> {
|
||||
return this.request<Menu>('POST', '/menu/menus', menu);
|
||||
}
|
||||
|
||||
async updateMenu(menuId: string, updates: Partial<Menu>): Promise<ApiResponse<Menu>> {
|
||||
return this.request<Menu>('PATCH', `/menu/menus/${menuId}`, updates);
|
||||
}
|
||||
|
||||
async getModifierGroups(): Promise<ApiResponse<ModifierGroup[]>> {
|
||||
return this.request<ModifierGroup[]>('GET', '/menu/modifier-groups');
|
||||
}
|
||||
|
||||
async createModifierGroup(group: Partial<ModifierGroup>): Promise<ApiResponse<ModifierGroup>> {
|
||||
return this.request<ModifierGroup>('POST', '/menu/modifier-groups', group);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Table & Floor Management API
|
||||
// ============================================================================
|
||||
|
||||
async getTables(params?: ListParams): Promise<ApiResponse<Table[]>> {
|
||||
return this.request<Table[]>('GET', '/tables');
|
||||
}
|
||||
|
||||
async getTable(tableId: string): Promise<ApiResponse<Table>> {
|
||||
return this.request<Table>('GET', `/tables/${tableId}`);
|
||||
}
|
||||
|
||||
async updateTableStatus(
|
||||
tableId: string,
|
||||
status: string,
|
||||
guestCount?: number
|
||||
): Promise<ApiResponse<Table>> {
|
||||
return this.request<Table>('PATCH', `/tables/${tableId}/status`, { status, guestCount });
|
||||
}
|
||||
|
||||
async assignServer(tableId: string, serverId: string): Promise<ApiResponse<Table>> {
|
||||
return this.request<Table>('PATCH', `/tables/${tableId}/assign`, { serverId });
|
||||
}
|
||||
|
||||
async getSections(): Promise<ApiResponse<Section[]>> {
|
||||
return this.request<Section[]>('GET', '/sections');
|
||||
}
|
||||
|
||||
async createSection(section: Partial<Section>): Promise<ApiResponse<Section>> {
|
||||
return this.request<Section>('POST', '/sections', section);
|
||||
}
|
||||
|
||||
async getFloorPlans(): Promise<ApiResponse<FloorPlan[]>> {
|
||||
return this.request<FloorPlan[]>('GET', '/floor-plans');
|
||||
}
|
||||
|
||||
async getActiveFloorPlan(): Promise<ApiResponse<FloorPlan>> {
|
||||
return this.request<FloorPlan>('GET', '/floor-plans/active');
|
||||
}
|
||||
|
||||
async createTable(table: Partial<Table>): Promise<ApiResponse<Table>> {
|
||||
return this.request<Table>('POST', '/tables', table);
|
||||
}
|
||||
|
||||
async updateTable(tableId: string, updates: Partial<Table>): Promise<ApiResponse<Table>> {
|
||||
return this.request<Table>('PATCH', `/tables/${tableId}`, updates);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Staff Management API
|
||||
// ============================================================================
|
||||
|
||||
async getStaff(params?: ListParams): Promise<ApiResponse<Staff[]>> {
|
||||
return this.request<Staff[]>('GET', '/staff');
|
||||
}
|
||||
|
||||
async getStaffMember(staffId: string): Promise<ApiResponse<Staff>> {
|
||||
return this.request<Staff>('GET', `/staff/${staffId}`);
|
||||
}
|
||||
|
||||
async createStaffMember(staff: Partial<Staff>): Promise<ApiResponse<Staff>> {
|
||||
return this.request<Staff>('POST', '/staff', staff);
|
||||
}
|
||||
|
||||
async updateStaffMember(staffId: string, updates: Partial<Staff>): Promise<ApiResponse<Staff>> {
|
||||
return this.request<Staff>('PATCH', `/staff/${staffId}`, updates);
|
||||
}
|
||||
|
||||
async deactivateStaffMember(staffId: string): Promise<ApiResponse<Staff>> {
|
||||
return this.request<Staff>('POST', `/staff/${staffId}/deactivate`);
|
||||
}
|
||||
|
||||
async clockIn(staffId: string): Promise<ApiResponse<TimeSheet>> {
|
||||
return this.request<TimeSheet>('POST', `/staff/${staffId}/clock-in`);
|
||||
}
|
||||
|
||||
async clockOut(staffId: string): Promise<ApiResponse<TimeSheet>> {
|
||||
return this.request<TimeSheet>('POST', `/staff/${staffId}/clock-out`);
|
||||
}
|
||||
|
||||
async getTimeSheets(params?: {
|
||||
staffId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}): Promise<ApiResponse<TimeSheet[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.staffId) queryParams.set('staffId', params.staffId);
|
||||
if (params?.dateFrom) queryParams.set('dateFrom', params.dateFrom);
|
||||
if (params?.dateTo) queryParams.set('dateTo', params.dateTo);
|
||||
return this.request<TimeSheet[]>('GET', `/timesheets?${queryParams}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Customer Management API
|
||||
// ============================================================================
|
||||
|
||||
async getCustomers(params?: ListParams): Promise<ApiResponse<Customer[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.filter?.search) {
|
||||
queryParams.set('search', params.filter.search);
|
||||
}
|
||||
return this.request<Customer[]>('GET', `/customers?${queryParams}`);
|
||||
}
|
||||
|
||||
async getCustomer(customerId: string): Promise<ApiResponse<Customer>> {
|
||||
return this.request<Customer>('GET', `/customers/${customerId}`);
|
||||
}
|
||||
|
||||
async createCustomer(customer: Partial<Customer>): Promise<ApiResponse<Customer>> {
|
||||
return this.request<Customer>('POST', '/customers', customer);
|
||||
}
|
||||
|
||||
async updateCustomer(
|
||||
customerId: string,
|
||||
updates: Partial<Customer>
|
||||
): Promise<ApiResponse<Customer>> {
|
||||
return this.request<Customer>('PATCH', `/customers/${customerId}`, updates);
|
||||
}
|
||||
|
||||
async searchCustomers(query: string): Promise<ApiResponse<Customer[]>> {
|
||||
return this.request<Customer[]>('GET', `/customers/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
async getLoyaltyTransactions(customerId: string): Promise<ApiResponse<LoyaltyTransaction[]>> {
|
||||
return this.request<LoyaltyTransaction[]>('GET', `/customers/${customerId}/loyalty`);
|
||||
}
|
||||
|
||||
async addLoyaltyPoints(
|
||||
customerId: string,
|
||||
points: number,
|
||||
description: string
|
||||
): Promise<ApiResponse<LoyaltyTransaction>> {
|
||||
return this.request<LoyaltyTransaction>('POST', `/customers/${customerId}/loyalty`, {
|
||||
points,
|
||||
description,
|
||||
type: 'earned',
|
||||
});
|
||||
}
|
||||
|
||||
async redeemLoyaltyPoints(
|
||||
customerId: string,
|
||||
points: number,
|
||||
description: string
|
||||
): Promise<ApiResponse<LoyaltyTransaction>> {
|
||||
return this.request<LoyaltyTransaction>('POST', `/customers/${customerId}/loyalty`, {
|
||||
points: -points,
|
||||
description,
|
||||
type: 'redeemed',
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reservations API
|
||||
// ============================================================================
|
||||
|
||||
async getReservations(params?: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
status?: string;
|
||||
}): Promise<ApiResponse<Reservation[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.dateFrom) queryParams.set('dateFrom', params.dateFrom);
|
||||
if (params?.dateTo) queryParams.set('dateTo', params.dateTo);
|
||||
if (params?.status) queryParams.set('status', params.status);
|
||||
return this.request<Reservation[]>('GET', `/reservations?${queryParams}`);
|
||||
}
|
||||
|
||||
async getReservation(reservationId: string): Promise<ApiResponse<Reservation>> {
|
||||
return this.request<Reservation>('GET', `/reservations/${reservationId}`);
|
||||
}
|
||||
|
||||
async createReservation(reservation: Partial<Reservation>): Promise<ApiResponse<Reservation>> {
|
||||
return this.request<Reservation>('POST', '/reservations', reservation);
|
||||
}
|
||||
|
||||
async updateReservation(
|
||||
reservationId: string,
|
||||
updates: Partial<Reservation>
|
||||
): Promise<ApiResponse<Reservation>> {
|
||||
return this.request<Reservation>('PATCH', `/reservations/${reservationId}`, updates);
|
||||
}
|
||||
|
||||
async confirmReservation(reservationId: string): Promise<ApiResponse<Reservation>> {
|
||||
return this.request<Reservation>('POST', `/reservations/${reservationId}/confirm`);
|
||||
}
|
||||
|
||||
async seatReservation(reservationId: string, tableId: string): Promise<ApiResponse<Reservation>> {
|
||||
return this.request<Reservation>('POST', `/reservations/${reservationId}/seat`, { tableId });
|
||||
}
|
||||
|
||||
async cancelReservation(reservationId: string, reason?: string): Promise<ApiResponse<Reservation>> {
|
||||
return this.request<Reservation>('POST', `/reservations/${reservationId}/cancel`, { reason });
|
||||
}
|
||||
|
||||
async markNoShow(reservationId: string): Promise<ApiResponse<Reservation>> {
|
||||
return this.request<Reservation>('POST', `/reservations/${reservationId}/no-show`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inventory Management API
|
||||
// ============================================================================
|
||||
|
||||
async getInventoryItems(params?: ListParams): Promise<ApiResponse<InventoryItem[]>> {
|
||||
return this.request<InventoryItem[]>('GET', '/inventory/items');
|
||||
}
|
||||
|
||||
async getInventoryItem(itemId: string): Promise<ApiResponse<InventoryItem>> {
|
||||
return this.request<InventoryItem>('GET', `/inventory/items/${itemId}`);
|
||||
}
|
||||
|
||||
async createInventoryItem(item: Partial<InventoryItem>): Promise<ApiResponse<InventoryItem>> {
|
||||
return this.request<InventoryItem>('POST', '/inventory/items', item);
|
||||
}
|
||||
|
||||
async updateInventoryItem(
|
||||
itemId: string,
|
||||
updates: Partial<InventoryItem>
|
||||
): Promise<ApiResponse<InventoryItem>> {
|
||||
return this.request<InventoryItem>('PATCH', `/inventory/items/${itemId}`, updates);
|
||||
}
|
||||
|
||||
async adjustStock(
|
||||
itemId: string,
|
||||
quantity: number,
|
||||
type: 'addition' | 'subtraction' | 'correction',
|
||||
reason: string
|
||||
): Promise<ApiResponse<StockAdjustment>> {
|
||||
return this.request<StockAdjustment>('POST', `/inventory/items/${itemId}/adjust`, {
|
||||
quantity,
|
||||
type,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
async getLowStockItems(): Promise<ApiResponse<InventoryItem[]>> {
|
||||
return this.request<InventoryItem[]>('GET', '/inventory/items/low-stock');
|
||||
}
|
||||
|
||||
async getSuppliers(): Promise<ApiResponse<Supplier[]>> {
|
||||
return this.request<Supplier[]>('GET', '/inventory/suppliers');
|
||||
}
|
||||
|
||||
async createSupplier(supplier: Partial<Supplier>): Promise<ApiResponse<Supplier>> {
|
||||
return this.request<Supplier>('POST', '/inventory/suppliers', supplier);
|
||||
}
|
||||
|
||||
async getPurchaseOrders(params?: { status?: string }): Promise<ApiResponse<PurchaseOrder[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.status) queryParams.set('status', params.status);
|
||||
return this.request<PurchaseOrder[]>('GET', `/inventory/purchase-orders?${queryParams}`);
|
||||
}
|
||||
|
||||
async createPurchaseOrder(order: Partial<PurchaseOrder>): Promise<ApiResponse<PurchaseOrder>> {
|
||||
return this.request<PurchaseOrder>('POST', '/inventory/purchase-orders', order);
|
||||
}
|
||||
|
||||
async receivePurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrder>> {
|
||||
return this.request<PurchaseOrder>('POST', `/inventory/purchase-orders/${orderId}/receive`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Payment Processing API
|
||||
// ============================================================================
|
||||
|
||||
async processPayment(
|
||||
orderId: string,
|
||||
payment: Partial<Payment>
|
||||
): Promise<ApiResponse<Payment>> {
|
||||
return this.request<Payment>('POST', `/orders/${orderId}/payments`, payment);
|
||||
}
|
||||
|
||||
async refundPayment(paymentId: string, amount?: number): Promise<ApiResponse<Payment>> {
|
||||
return this.request<Payment>('POST', `/payments/${paymentId}/refund`, { amount });
|
||||
}
|
||||
|
||||
async voidPayment(paymentId: string): Promise<ApiResponse<Payment>> {
|
||||
return this.request<Payment>('POST', `/payments/${paymentId}/void`);
|
||||
}
|
||||
|
||||
async getPayments(orderId: string): Promise<ApiResponse<Payment[]>> {
|
||||
return this.request<Payment[]>('GET', `/orders/${orderId}/payments`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reporting & Analytics API
|
||||
// ============================================================================
|
||||
|
||||
async getSalesReport(dateFrom: string, dateTo: string): Promise<ApiResponse<SalesReport>> {
|
||||
return this.request<SalesReport>(
|
||||
'GET',
|
||||
`/reports/sales?dateFrom=${dateFrom}&dateTo=${dateTo}`
|
||||
);
|
||||
}
|
||||
|
||||
async getInventoryReport(): Promise<ApiResponse<InventoryReport>> {
|
||||
return this.request<InventoryReport>('GET', '/reports/inventory');
|
||||
}
|
||||
|
||||
async getStaffReport(dateFrom: string, dateTo: string): Promise<ApiResponse<StaffReport>> {
|
||||
return this.request<StaffReport>(
|
||||
'GET',
|
||||
`/reports/staff?dateFrom=${dateFrom}&dateTo=${dateTo}`
|
||||
);
|
||||
}
|
||||
|
||||
async getTopSellingItems(limit: number = 10): Promise<ApiResponse<any[]>> {
|
||||
return this.request<any[]>('GET', `/reports/top-items?limit=${limit}`);
|
||||
}
|
||||
|
||||
async getRevenueByHour(date: string): Promise<ApiResponse<any[]>> {
|
||||
return this.request<any[]>('GET', `/reports/revenue-by-hour?date=${date}`);
|
||||
}
|
||||
|
||||
async getCustomerAnalytics(): Promise<ApiResponse<any>> {
|
||||
return this.request<any>('GET', '/reports/customer-analytics');
|
||||
}
|
||||
}
|
||||
|
||||
export default TouchBistroApiClient;
|
||||
686
servers/touchbistro/src/lib/types.ts
Normal file
686
servers/touchbistro/src/lib/types.ts
Normal file
@ -0,0 +1,686 @@
|
||||
/**
|
||||
* TouchBistro MCP Server - Complete Type Definitions
|
||||
* Full production-grade types for restaurant POS operations
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Core Domain Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TouchBistroConfig {
|
||||
apiKey: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
restaurantId: string;
|
||||
baseUrl?: string;
|
||||
sandbox?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Menu Management
|
||||
// ============================================================================
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
cost?: number;
|
||||
categoryId: string;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
taxable: boolean;
|
||||
available: boolean;
|
||||
prepTime?: number; // minutes
|
||||
modifierGroupIds?: string[];
|
||||
allergens?: string[];
|
||||
nutritionInfo?: NutritionInfo;
|
||||
image?: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MenuCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parentCategoryId?: string;
|
||||
sortOrder: number;
|
||||
available: boolean;
|
||||
menuIds: string[];
|
||||
itemCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Menu {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
startTime?: string; // HH:mm
|
||||
endTime?: string; // HH:mm
|
||||
daysOfWeek?: number[]; // 0-6
|
||||
available: boolean;
|
||||
categoryIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ModifierGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
minSelections: number;
|
||||
maxSelections: number;
|
||||
required: boolean;
|
||||
modifiers: Modifier[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface Modifier {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
available: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface NutritionInfo {
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbohydrates?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sodium?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Order Management
|
||||
// ============================================================================
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: OrderStatus;
|
||||
type: OrderType;
|
||||
tableId?: string;
|
||||
customerId?: string;
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
tip: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
payments: Payment[];
|
||||
serverId: string;
|
||||
guestCount: number;
|
||||
notes?: string;
|
||||
specialInstructions?: string;
|
||||
sentToKitchen: boolean;
|
||||
kitchenSentAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
voidedAt?: string;
|
||||
voidReason?: string;
|
||||
}
|
||||
|
||||
export enum OrderStatus {
|
||||
Draft = 'draft',
|
||||
Pending = 'pending',
|
||||
InProgress = 'in_progress',
|
||||
Ready = 'ready',
|
||||
Completed = 'completed',
|
||||
Voided = 'voided',
|
||||
Cancelled = 'cancelled'
|
||||
}
|
||||
|
||||
export enum OrderType {
|
||||
DineIn = 'dine_in',
|
||||
Takeout = 'takeout',
|
||||
Delivery = 'delivery',
|
||||
Curbside = 'curbside',
|
||||
DriveThru = 'drive_thru'
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
menuItemId: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
modifiers: OrderModifier[];
|
||||
specialInstructions?: string;
|
||||
sentToKitchen: boolean;
|
||||
preparedBy?: string;
|
||||
preparedAt?: string;
|
||||
seat?: number;
|
||||
courseNumber?: number;
|
||||
}
|
||||
|
||||
export interface OrderModifier {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Payment Processing
|
||||
// ============================================================================
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
orderId: string;
|
||||
type: PaymentType;
|
||||
method: PaymentMethod;
|
||||
amount: number;
|
||||
tip: number;
|
||||
processorFee?: number;
|
||||
status: PaymentStatus;
|
||||
cardLast4?: string;
|
||||
cardBrand?: string;
|
||||
transactionId?: string;
|
||||
authCode?: string;
|
||||
processedAt?: string;
|
||||
refundedAt?: string;
|
||||
refundAmount?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export enum PaymentType {
|
||||
Full = 'full',
|
||||
Partial = 'partial',
|
||||
Split = 'split',
|
||||
Refund = 'refund'
|
||||
}
|
||||
|
||||
export enum PaymentMethod {
|
||||
Cash = 'cash',
|
||||
CreditCard = 'credit_card',
|
||||
DebitCard = 'debit_card',
|
||||
GiftCard = 'gift_card',
|
||||
MobilePayment = 'mobile_payment',
|
||||
Check = 'check',
|
||||
HouseAccount = 'house_account'
|
||||
}
|
||||
|
||||
export enum PaymentStatus {
|
||||
Pending = 'pending',
|
||||
Authorized = 'authorized',
|
||||
Captured = 'captured',
|
||||
Declined = 'declined',
|
||||
Refunded = 'refunded',
|
||||
Voided = 'voided'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Table & Floor Management
|
||||
// ============================================================================
|
||||
|
||||
export interface Table {
|
||||
id: string;
|
||||
number: string;
|
||||
name?: string;
|
||||
sectionId: string;
|
||||
capacity: number;
|
||||
shape: TableShape;
|
||||
status: TableStatus;
|
||||
currentOrderId?: string;
|
||||
serverId?: string;
|
||||
guestCount?: number;
|
||||
seatedAt?: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
export enum TableShape {
|
||||
Square = 'square',
|
||||
Rectangle = 'rectangle',
|
||||
Circle = 'circle',
|
||||
Oval = 'oval'
|
||||
}
|
||||
|
||||
export enum TableStatus {
|
||||
Available = 'available',
|
||||
Occupied = 'occupied',
|
||||
Reserved = 'reserved',
|
||||
Cleaning = 'cleaning',
|
||||
OutOfService = 'out_of_service'
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
name: string;
|
||||
floorPlanId: string;
|
||||
color?: string;
|
||||
assignedServerIds: string[];
|
||||
tableCount: number;
|
||||
}
|
||||
|
||||
export interface FloorPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
sections: Section[];
|
||||
tableCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Staff Management
|
||||
// ============================================================================
|
||||
|
||||
export interface Staff {
|
||||
id: string;
|
||||
employeeNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role: StaffRole;
|
||||
pinCode: string;
|
||||
active: boolean;
|
||||
hireDate: string;
|
||||
terminationDate?: string;
|
||||
wage?: number;
|
||||
wageType?: WageType;
|
||||
permissions: StaffPermission[];
|
||||
assignedSections?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export enum StaffRole {
|
||||
Owner = 'owner',
|
||||
Manager = 'manager',
|
||||
Server = 'server',
|
||||
Bartender = 'bartender',
|
||||
Host = 'host',
|
||||
Chef = 'chef',
|
||||
Cook = 'cook',
|
||||
Busser = 'busser',
|
||||
Cashier = 'cashier'
|
||||
}
|
||||
|
||||
export enum WageType {
|
||||
Hourly = 'hourly',
|
||||
Salary = 'salary',
|
||||
TippedHourly = 'tipped_hourly'
|
||||
}
|
||||
|
||||
export interface StaffPermission {
|
||||
resource: string;
|
||||
actions: ('create' | 'read' | 'update' | 'delete' | 'void')[];
|
||||
}
|
||||
|
||||
export interface TimeSheet {
|
||||
id: string;
|
||||
staffId: string;
|
||||
clockIn: string;
|
||||
clockOut?: string;
|
||||
breakMinutes: number;
|
||||
totalHours?: number;
|
||||
wage: number;
|
||||
tips: number;
|
||||
date: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Customer Management
|
||||
// ============================================================================
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
birthday?: string;
|
||||
notes?: string;
|
||||
loyaltyPoints: number;
|
||||
loyaltyTier?: string;
|
||||
totalSpent: number;
|
||||
visitCount: number;
|
||||
averageSpend: number;
|
||||
lastVisit?: string;
|
||||
preferredServer?: string;
|
||||
dietaryRestrictions?: string[];
|
||||
allergies?: string[];
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LoyaltyTransaction {
|
||||
id: string;
|
||||
customerId: string;
|
||||
orderId?: string;
|
||||
points: number;
|
||||
type: 'earned' | 'redeemed' | 'adjusted';
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reservations
|
||||
// ============================================================================
|
||||
|
||||
export interface Reservation {
|
||||
id: string;
|
||||
customerId?: string;
|
||||
customerName: string;
|
||||
customerPhone?: string;
|
||||
customerEmail?: string;
|
||||
partySize: number;
|
||||
date: string;
|
||||
time: string;
|
||||
duration: number; // minutes
|
||||
status: ReservationStatus;
|
||||
tableId?: string;
|
||||
sectionId?: string;
|
||||
notes?: string;
|
||||
specialRequests?: string;
|
||||
confirmationSent: boolean;
|
||||
reminderSent: boolean;
|
||||
seatedAt?: string;
|
||||
completedAt?: string;
|
||||
noShowAt?: string;
|
||||
cancelledAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export enum ReservationStatus {
|
||||
Pending = 'pending',
|
||||
Confirmed = 'confirmed',
|
||||
Seated = 'seated',
|
||||
Completed = 'completed',
|
||||
NoShow = 'no_show',
|
||||
Cancelled = 'cancelled'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inventory Management
|
||||
// ============================================================================
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
unit: string;
|
||||
currentStock: number;
|
||||
parLevel: number;
|
||||
reorderPoint: number;
|
||||
reorderQuantity: number;
|
||||
unitCost: number;
|
||||
supplierId?: string;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
location?: string;
|
||||
expirationDate?: string;
|
||||
lastRestocked?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StockAdjustment {
|
||||
id: string;
|
||||
inventoryItemId: string;
|
||||
quantity: number;
|
||||
type: 'addition' | 'subtraction' | 'correction';
|
||||
reason: string;
|
||||
performedBy: string;
|
||||
cost?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Supplier {
|
||||
id: string;
|
||||
name: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
paymentTerms?: string;
|
||||
notes?: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrder {
|
||||
id: string;
|
||||
supplierId: string;
|
||||
orderNumber: string;
|
||||
status: 'draft' | 'sent' | 'received' | 'cancelled';
|
||||
items: PurchaseOrderItem[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
expectedDate?: string;
|
||||
receivedDate?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderItem {
|
||||
inventoryItemId: string;
|
||||
quantity: number;
|
||||
unitCost: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reporting & Analytics
|
||||
// ============================================================================
|
||||
|
||||
export interface SalesReport {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalSales: number;
|
||||
totalOrders: number;
|
||||
averageOrderValue: number;
|
||||
totalTax: number;
|
||||
totalTips: number;
|
||||
totalDiscounts: number;
|
||||
grossSales: number;
|
||||
netSales: number;
|
||||
salesByHour: HourlySales[];
|
||||
salesByCategory: CategorySales[];
|
||||
salesByMenuItem: MenuItemSales[];
|
||||
salesByPaymentMethod: PaymentMethodSales[];
|
||||
salesByServer: ServerSales[];
|
||||
orderTypeMix: OrderTypeMix[];
|
||||
}
|
||||
|
||||
export interface HourlySales {
|
||||
hour: number;
|
||||
sales: number;
|
||||
orders: number;
|
||||
averageValue: number;
|
||||
}
|
||||
|
||||
export interface CategorySales {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
quantity: number;
|
||||
revenue: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface MenuItemSales {
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
revenue: number;
|
||||
cost?: number;
|
||||
profit?: number;
|
||||
margin?: number;
|
||||
}
|
||||
|
||||
export interface PaymentMethodSales {
|
||||
method: PaymentMethod;
|
||||
count: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ServerSales {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
orders: number;
|
||||
sales: number;
|
||||
tips: number;
|
||||
averageCheck: number;
|
||||
}
|
||||
|
||||
export interface OrderTypeMix {
|
||||
type: OrderType;
|
||||
count: number;
|
||||
revenue: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface InventoryReport {
|
||||
totalValue: number;
|
||||
lowStockItems: number;
|
||||
outOfStockItems: number;
|
||||
expiringItems: number;
|
||||
itemsByCategory: {
|
||||
category: string;
|
||||
count: number;
|
||||
value: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface StaffReport {
|
||||
totalHours: number;
|
||||
totalWages: number;
|
||||
totalTips: number;
|
||||
laborCostPercentage: number;
|
||||
staffPerformance: StaffPerformance[];
|
||||
}
|
||||
|
||||
export interface StaffPerformance {
|
||||
staffId: string;
|
||||
staffName: string;
|
||||
hours: number;
|
||||
sales: number;
|
||||
ordersServed: number;
|
||||
salesPerHour: number;
|
||||
averageCheck: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: ApiError;
|
||||
metadata?: ResponseMetadata;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ResponseMetadata {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
totalCount?: number;
|
||||
totalPages?: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
filter?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP App State Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AppState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
|
||||
export interface OrdersAppState extends AppState {
|
||||
orders: Order[];
|
||||
selectedOrder: Order | null;
|
||||
filters: OrderFilters;
|
||||
}
|
||||
|
||||
export interface OrderFilters {
|
||||
status?: OrderStatus[];
|
||||
type?: OrderType[];
|
||||
serverId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface MenuAppState extends AppState {
|
||||
items: MenuItem[];
|
||||
categories: MenuCategory[];
|
||||
menus: Menu[];
|
||||
selectedItem: MenuItem | null;
|
||||
editMode: boolean;
|
||||
}
|
||||
|
||||
export interface TablesAppState extends AppState {
|
||||
tables: Table[];
|
||||
sections: Section[];
|
||||
selectedTable: Table | null;
|
||||
floorPlan: FloorPlan | null;
|
||||
}
|
||||
|
||||
export interface StaffAppState extends AppState {
|
||||
staff: Staff[];
|
||||
selectedStaff: Staff | null;
|
||||
timesheets: TimeSheet[];
|
||||
}
|
||||
|
||||
export interface CustomersAppState extends AppState {
|
||||
customers: Customer[];
|
||||
selectedCustomer: Customer | null;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export interface ReservationsAppState extends AppState {
|
||||
reservations: Reservation[];
|
||||
selectedReservation: Reservation | null;
|
||||
filters: ReservationFilters;
|
||||
}
|
||||
|
||||
export interface ReservationFilters {
|
||||
status?: ReservationStatus[];
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface InventoryAppState extends AppState {
|
||||
items: InventoryItem[];
|
||||
lowStockItems: InventoryItem[];
|
||||
suppliers: Supplier[];
|
||||
selectedItem: InventoryItem | null;
|
||||
}
|
||||
|
||||
export interface ReportsAppState extends AppState {
|
||||
salesReport: SalesReport | null;
|
||||
inventoryReport: InventoryReport | null;
|
||||
staffReport: StaffReport | null;
|
||||
dateRange: { start: string; end: string };
|
||||
}
|
||||
223
servers/touchbistro/src/tools/customers.ts
Normal file
223
servers/touchbistro/src/tools/customers.ts
Normal file
@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Customer Management Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TouchBistroApiClient } from '../lib/api-client.js';
|
||||
|
||||
export const customerTools = {
|
||||
touchbistro_list_customers: {
|
||||
description: 'List all customers with loyalty points, visit history, and preferences.',
|
||||
parameters: z.object({
|
||||
search: z.string().optional().describe('Search by name, email, or phone'),
|
||||
page: z.number().optional().describe('Page number'),
|
||||
pageSize: z.number().optional().describe('Items per page'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const filter: any = {};
|
||||
if (params.search) filter.search = params.search;
|
||||
|
||||
const response = await client.getCustomers({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
filter,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch customers');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_customer: {
|
||||
description: 'Get detailed customer information including loyalty, visit history, and preferences.',
|
||||
parameters: z.object({
|
||||
customerId: z.string().describe('Customer ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getCustomer(params.customerId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch customer');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_customer: {
|
||||
description: 'Create a new customer profile with contact information and preferences.',
|
||||
parameters: z.object({
|
||||
firstName: z.string().describe('First name'),
|
||||
lastName: z.string().describe('Last name'),
|
||||
email: z.string().optional().describe('Email address'),
|
||||
phone: z.string().optional().describe('Phone number'),
|
||||
birthday: z.string().optional().describe('Birthday (ISO date format)'),
|
||||
notes: z.string().optional().describe('Customer notes'),
|
||||
dietaryRestrictions: z.array(z.string()).optional().describe('Dietary restrictions'),
|
||||
allergies: z.array(z.string()).optional().describe('Allergies'),
|
||||
tags: z.array(z.string()).optional().describe('Customer tags/labels'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createCustomer(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create customer');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Customer created successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_customer: {
|
||||
description: 'Update customer information, preferences, or notes.',
|
||||
parameters: z.object({
|
||||
customerId: z.string().describe('Customer ID'),
|
||||
firstName: z.string().optional().describe('First name'),
|
||||
lastName: z.string().optional().describe('Last name'),
|
||||
email: z.string().optional().describe('Email address'),
|
||||
phone: z.string().optional().describe('Phone number'),
|
||||
birthday: z.string().optional().describe('Birthday (ISO date format)'),
|
||||
notes: z.string().optional().describe('Customer notes'),
|
||||
dietaryRestrictions: z.array(z.string()).optional().describe('Dietary restrictions'),
|
||||
allergies: z.array(z.string()).optional().describe('Allergies'),
|
||||
tags: z.array(z.string()).optional().describe('Customer tags/labels'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { customerId, ...updates } = params;
|
||||
const response = await client.updateCustomer(customerId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update customer');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Customer updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_search_customers: {
|
||||
description: 'Search customers by name, email, phone, or tags.',
|
||||
parameters: z.object({
|
||||
query: z.string().describe('Search query'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.searchCustomers(params.query);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to search customers');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_loyalty_transactions: {
|
||||
description: 'Get customer loyalty point transaction history.',
|
||||
parameters: z.object({
|
||||
customerId: z.string().describe('Customer ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getLoyaltyTransactions(params.customerId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch loyalty transactions');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_add_loyalty_points: {
|
||||
description: 'Add loyalty points to a customer account.',
|
||||
parameters: z.object({
|
||||
customerId: z.string().describe('Customer ID'),
|
||||
points: z.number().describe('Points to add'),
|
||||
description: z.string().describe('Description/reason for points'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.addLoyaltyPoints(params.customerId, params.points, params.description);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to add loyalty points');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${params.points} loyalty points added!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_redeem_loyalty_points: {
|
||||
description: 'Redeem loyalty points from a customer account.',
|
||||
parameters: z.object({
|
||||
customerId: z.string().describe('Customer ID'),
|
||||
points: z.number().describe('Points to redeem'),
|
||||
description: z.string().describe('Description of redemption'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.redeemLoyaltyPoints(params.customerId, params.points, params.description);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to redeem loyalty points');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${params.points} loyalty points redeemed!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
290
servers/touchbistro/src/tools/inventory.ts
Normal file
290
servers/touchbistro/src/tools/inventory.ts
Normal file
@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Inventory Management Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TouchBistroApiClient } from '../lib/api-client.js';
|
||||
|
||||
export const inventoryTools = {
|
||||
touchbistro_list_inventory_items: {
|
||||
description: 'List all inventory items with stock levels, costs, and suppliers.',
|
||||
parameters: z.object({
|
||||
category: z.string().optional().describe('Filter by category'),
|
||||
lowStock: z.boolean().optional().describe('Show only low stock items'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
if (params.lowStock) {
|
||||
const response = await client.getLowStockItems();
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch low stock items');
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
const filter: any = {};
|
||||
if (params.category) filter.category = params.category;
|
||||
|
||||
const response = await client.getInventoryItems({ filter });
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch inventory items');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_inventory_item: {
|
||||
description: 'Get detailed information about a specific inventory item.',
|
||||
parameters: z.object({
|
||||
itemId: z.string().describe('Inventory item ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getInventoryItem(params.itemId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch inventory item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_inventory_item: {
|
||||
description: 'Create a new inventory item with stock tracking.',
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Item name'),
|
||||
category: z.string().describe('Item category'),
|
||||
unit: z.string().describe('Unit of measurement (e.g., lb, oz, each)'),
|
||||
currentStock: z.number().describe('Current stock quantity'),
|
||||
parLevel: z.number().describe('Par level (target stock)'),
|
||||
reorderPoint: z.number().describe('Reorder point (minimum before restocking)'),
|
||||
reorderQuantity: z.number().describe('Default reorder quantity'),
|
||||
unitCost: z.number().describe('Cost per unit'),
|
||||
supplierId: z.string().optional().describe('Supplier ID'),
|
||||
sku: z.string().optional().describe('SKU'),
|
||||
barcode: z.string().optional().describe('Barcode'),
|
||||
location: z.string().optional().describe('Storage location'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createInventoryItem(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create inventory item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Inventory item created!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_inventory_item: {
|
||||
description: 'Update inventory item details.',
|
||||
parameters: z.object({
|
||||
itemId: z.string().describe('Inventory item ID'),
|
||||
name: z.string().optional().describe('Item name'),
|
||||
category: z.string().optional().describe('Item category'),
|
||||
parLevel: z.number().optional().describe('Par level'),
|
||||
reorderPoint: z.number().optional().describe('Reorder point'),
|
||||
reorderQuantity: z.number().optional().describe('Reorder quantity'),
|
||||
unitCost: z.number().optional().describe('Cost per unit'),
|
||||
supplierId: z.string().optional().describe('Supplier ID'),
|
||||
location: z.string().optional().describe('Storage location'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { itemId, ...updates } = params;
|
||||
const response = await client.updateInventoryItem(itemId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update inventory item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Inventory item updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_adjust_stock: {
|
||||
description: 'Adjust inventory stock levels (add, subtract, or correct).',
|
||||
parameters: z.object({
|
||||
itemId: z.string().describe('Inventory item ID'),
|
||||
quantity: z.number().describe('Quantity to adjust (positive or negative)'),
|
||||
type: z.enum(['addition', 'subtraction', 'correction']).describe('Adjustment type'),
|
||||
reason: z.string().describe('Reason for adjustment'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.adjustStock(params.itemId, params.quantity, params.type, params.reason);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to adjust stock');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Stock adjusted!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_list_suppliers: {
|
||||
description: 'List all suppliers with contact information.',
|
||||
parameters: z.object({}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getSuppliers();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch suppliers');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_supplier: {
|
||||
description: 'Create a new supplier.',
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Supplier name'),
|
||||
contactName: z.string().optional().describe('Contact person name'),
|
||||
email: z.string().optional().describe('Email address'),
|
||||
phone: z.string().optional().describe('Phone number'),
|
||||
address: z.string().optional().describe('Address'),
|
||||
paymentTerms: z.string().optional().describe('Payment terms'),
|
||||
notes: z.string().optional().describe('Notes'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createSupplier(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create supplier');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Supplier created!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_list_purchase_orders: {
|
||||
description: 'List purchase orders with filters for status.',
|
||||
parameters: z.object({
|
||||
status: z.enum(['draft', 'sent', 'received', 'cancelled']).optional().describe('Filter by status'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getPurchaseOrders(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch purchase orders');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_purchase_order: {
|
||||
description: 'Create a new purchase order for a supplier.',
|
||||
parameters: z.object({
|
||||
supplierId: z.string().describe('Supplier ID'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
inventoryItemId: z.string(),
|
||||
quantity: z.number(),
|
||||
unitCost: z.number(),
|
||||
})
|
||||
)
|
||||
.describe('Items to order'),
|
||||
expectedDate: z.string().optional().describe('Expected delivery date (ISO format)'),
|
||||
notes: z.string().optional().describe('Order notes'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createPurchaseOrder(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create purchase order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Purchase order created!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_receive_purchase_order: {
|
||||
description: 'Mark a purchase order as received and update inventory.',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Purchase order ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.receivePurchaseOrder(params.orderId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to receive purchase order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Purchase order received!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
386
servers/touchbistro/src/tools/menu.ts
Normal file
386
servers/touchbistro/src/tools/menu.ts
Normal file
@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Menu Management Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TouchBistroApiClient } from '../lib/api-client.js';
|
||||
|
||||
export const menuTools = {
|
||||
touchbistro_list_menu_items: {
|
||||
description:
|
||||
'List all menu items with optional filters (category, availability, search). Returns prices, descriptions, and modifiers.',
|
||||
parameters: z.object({
|
||||
categoryId: z.string().optional().describe('Filter by category ID'),
|
||||
available: z.boolean().optional().describe('Filter by availability'),
|
||||
search: z.string().optional().describe('Search by name or description'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const filter: any = {};
|
||||
if (params.categoryId) filter.categoryId = params.categoryId;
|
||||
if (params.available !== undefined) filter.available = params.available;
|
||||
if (params.search) filter.search = params.search;
|
||||
|
||||
const response = await client.getMenuItems({ filter });
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch menu items');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_menu_item: {
|
||||
description: 'Get detailed information about a specific menu item including pricing, modifiers, and nutrition.',
|
||||
parameters: z.object({
|
||||
itemId: z.string().describe('Menu item ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getMenuItem(params.itemId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch menu item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_menu_item: {
|
||||
description: 'Create a new menu item with pricing, category, and modifiers.',
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Item name'),
|
||||
description: z.string().optional().describe('Item description'),
|
||||
price: z.number().describe('Item price'),
|
||||
cost: z.number().optional().describe('Item cost'),
|
||||
categoryId: z.string().describe('Category ID'),
|
||||
sku: z.string().optional().describe('SKU'),
|
||||
barcode: z.string().optional().describe('Barcode'),
|
||||
taxable: z.boolean().default(true).describe('Is taxable'),
|
||||
available: z.boolean().default(true).describe('Is available'),
|
||||
prepTime: z.number().optional().describe('Preparation time in minutes'),
|
||||
modifierGroupIds: z.array(z.string()).optional().describe('Modifier group IDs'),
|
||||
allergens: z.array(z.string()).optional().describe('Allergen information'),
|
||||
image: z.string().optional().describe('Image URL'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createMenuItem(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create menu item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Menu item created successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_menu_item: {
|
||||
description: 'Update menu item details including price, availability, and description.',
|
||||
parameters: z.object({
|
||||
itemId: z.string().describe('Menu item ID'),
|
||||
name: z.string().optional().describe('Updated name'),
|
||||
description: z.string().optional().describe('Updated description'),
|
||||
price: z.number().optional().describe('Updated price'),
|
||||
cost: z.number().optional().describe('Updated cost'),
|
||||
available: z.boolean().optional().describe('Availability'),
|
||||
prepTime: z.number().optional().describe('Preparation time in minutes'),
|
||||
image: z.string().optional().describe('Image URL'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { itemId, ...updates } = params;
|
||||
const response = await client.updateMenuItem(itemId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update menu item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Menu item updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_delete_menu_item: {
|
||||
description: 'Delete a menu item permanently.',
|
||||
parameters: z.object({
|
||||
itemId: z.string().describe('Menu item ID to delete'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.deleteMenuItem(params.itemId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to delete menu item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Menu item deleted successfully!',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_list_menu_categories: {
|
||||
description: 'List all menu categories with item counts and hierarchy.',
|
||||
parameters: z.object({}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getMenuCategories();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch menu categories');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_menu_category: {
|
||||
description: 'Create a new menu category with name and display order.',
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Category name'),
|
||||
description: z.string().optional().describe('Category description'),
|
||||
parentCategoryId: z.string().optional().describe('Parent category ID (for sub-categories)'),
|
||||
sortOrder: z.number().optional().describe('Display sort order'),
|
||||
available: z.boolean().default(true).describe('Is available'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createMenuCategory(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create menu category');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Category created successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_menu_category: {
|
||||
description: 'Update menu category details.',
|
||||
parameters: z.object({
|
||||
categoryId: z.string().describe('Category ID'),
|
||||
name: z.string().optional().describe('Updated name'),
|
||||
description: z.string().optional().describe('Updated description'),
|
||||
sortOrder: z.number().optional().describe('Display sort order'),
|
||||
available: z.boolean().optional().describe('Availability'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { categoryId, ...updates } = params;
|
||||
const response = await client.updateMenuCategory(categoryId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update menu category');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Category updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_delete_menu_category: {
|
||||
description: 'Delete a menu category (items in category must be reassigned first).',
|
||||
parameters: z.object({
|
||||
categoryId: z.string().describe('Category ID to delete'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.deleteMenuCategory(params.categoryId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to delete menu category');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Menu category deleted successfully!',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_list_menus: {
|
||||
description: 'List all menus (breakfast, lunch, dinner, etc.) with active times and categories.',
|
||||
parameters: z.object({}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getMenus();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch menus');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_menu: {
|
||||
description: 'Create a new menu with time-based availability (breakfast, lunch, happy hour, etc.).',
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Menu name'),
|
||||
description: z.string().optional().describe('Menu description'),
|
||||
startTime: z.string().optional().describe('Start time (HH:mm format)'),
|
||||
endTime: z.string().optional().describe('End time (HH:mm format)'),
|
||||
daysOfWeek: z.array(z.number()).optional().describe('Active days (0=Sunday, 6=Saturday)'),
|
||||
available: z.boolean().default(true).describe('Is available'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createMenu(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create menu');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Menu created successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_menu: {
|
||||
description: 'Update menu details and availability schedule.',
|
||||
parameters: z.object({
|
||||
menuId: z.string().describe('Menu ID'),
|
||||
name: z.string().optional().describe('Updated name'),
|
||||
description: z.string().optional().describe('Updated description'),
|
||||
startTime: z.string().optional().describe('Start time (HH:mm format)'),
|
||||
endTime: z.string().optional().describe('End time (HH:mm format)'),
|
||||
daysOfWeek: z.array(z.number()).optional().describe('Active days (0=Sunday, 6=Saturday)'),
|
||||
available: z.boolean().optional().describe('Availability'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { menuId, ...updates } = params;
|
||||
const response = await client.updateMenu(menuId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update menu');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Menu updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_list_modifier_groups: {
|
||||
description: 'List all modifier groups (toppings, sides, cooking temps, etc.).',
|
||||
parameters: z.object({}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getModifierGroups();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch modifier groups');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_modifier_group: {
|
||||
description: 'Create a new modifier group with modifiers.',
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Modifier group name'),
|
||||
minSelections: z.number().default(0).describe('Minimum selections required'),
|
||||
maxSelections: z.number().default(1).describe('Maximum selections allowed'),
|
||||
required: z.boolean().default(false).describe('Is required'),
|
||||
modifiers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
price: z.number(),
|
||||
available: z.boolean().default(true),
|
||||
sortOrder: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.describe('Modifiers in this group'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createModifierGroup(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create modifier group');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Modifier group created!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
338
servers/touchbistro/src/tools/orders.ts
Normal file
338
servers/touchbistro/src/tools/orders.ts
Normal file
@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Order Management Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TouchBistroApiClient } from '../lib/api-client.js';
|
||||
|
||||
export const orderTools = {
|
||||
touchbistro_list_orders: {
|
||||
description:
|
||||
'List all orders with optional filters (status, type, date range, server). Returns active, completed, and historical orders.',
|
||||
parameters: z.object({
|
||||
status: z
|
||||
.enum(['draft', 'pending', 'in_progress', 'ready', 'completed', 'voided', 'cancelled'])
|
||||
.optional()
|
||||
.describe('Filter by order status'),
|
||||
type: z
|
||||
.enum(['dine_in', 'takeout', 'delivery', 'curbside', 'drive_thru'])
|
||||
.optional()
|
||||
.describe('Filter by order type'),
|
||||
serverId: z.string().optional().describe('Filter by server ID'),
|
||||
dateFrom: z.string().optional().describe('Start date (ISO format)'),
|
||||
dateTo: z.string().optional().describe('End date (ISO format)'),
|
||||
page: z.number().optional().describe('Page number'),
|
||||
pageSize: z.number().optional().describe('Items per page'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const filter: any = {};
|
||||
if (params.status) filter.status = params.status;
|
||||
if (params.type) filter.type = params.type;
|
||||
if (params.serverId) filter.serverId = params.serverId;
|
||||
if (params.dateFrom) filter.dateFrom = params.dateFrom;
|
||||
if (params.dateTo) filter.dateTo = params.dateTo;
|
||||
|
||||
const response = await client.getOrders({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
filter,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch orders');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_order: {
|
||||
description: 'Get detailed information about a specific order including items, payments, and status.',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getOrder(params.orderId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_order: {
|
||||
description: 'Create a new order with initial items, table assignment, and server.',
|
||||
parameters: z.object({
|
||||
type: z.enum(['dine_in', 'takeout', 'delivery', 'curbside', 'drive_thru']).describe('Order type'),
|
||||
tableId: z.string().optional().describe('Table ID (for dine-in orders)'),
|
||||
customerId: z.string().optional().describe('Customer ID'),
|
||||
serverId: z.string().describe('Server/staff ID'),
|
||||
guestCount: z.number().optional().describe('Number of guests'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
menuItemId: z.string(),
|
||||
quantity: z.number(),
|
||||
modifiers: z.array(z.object({ id: z.string(), name: z.string(), price: z.number() })).optional(),
|
||||
specialInstructions: z.string().optional(),
|
||||
seat: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.describe('Initial order items'),
|
||||
notes: z.string().optional().describe('Order notes'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createOrder(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Order created successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_order: {
|
||||
description: 'Update order details like guest count, notes, or special instructions.',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
guestCount: z.number().optional().describe('Updated guest count'),
|
||||
notes: z.string().optional().describe('Updated notes'),
|
||||
specialInstructions: z.string().optional().describe('Special instructions'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { orderId, ...updates } = params;
|
||||
const response = await client.updateOrder(orderId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Order updated successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_add_order_item: {
|
||||
description: 'Add an item to an existing order with modifiers and special instructions.',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
menuItemId: z.string().describe('Menu item ID'),
|
||||
quantity: z.number().describe('Quantity'),
|
||||
modifiers: z
|
||||
.array(z.object({ id: z.string(), name: z.string(), price: z.number() }))
|
||||
.optional()
|
||||
.describe('Item modifiers'),
|
||||
specialInstructions: z.string().optional().describe('Special cooking instructions'),
|
||||
seat: z.number().optional().describe('Seat number'),
|
||||
courseNumber: z.number().optional().describe('Course number'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { orderId, ...item } = params;
|
||||
const response = await client.addOrderItem(orderId, item);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to add item to order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Item added to order!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_order_item: {
|
||||
description: 'Update an existing order item (quantity, modifiers, instructions).',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
itemId: z.string().describe('Order item ID'),
|
||||
quantity: z.number().optional().describe('Updated quantity'),
|
||||
modifiers: z
|
||||
.array(z.object({ id: z.string(), name: z.string(), price: z.number() }))
|
||||
.optional()
|
||||
.describe('Updated modifiers'),
|
||||
specialInstructions: z.string().optional().describe('Updated special instructions'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { orderId, itemId, ...updates } = params;
|
||||
const response = await client.updateOrderItem(orderId, itemId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update order item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Order item updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_remove_order_item: {
|
||||
description: 'Remove an item from an order.',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
itemId: z.string().describe('Order item ID to remove'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.removeOrderItem(params.orderId, params.itemId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to remove order item');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Order item removed successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_send_to_kitchen: {
|
||||
description: 'Send an order to the kitchen for preparation. Marks items as fired.',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.sendOrderToKitchen(params.orderId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to send order to kitchen');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Order sent to kitchen!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_complete_order: {
|
||||
description: 'Mark an order as completed (all items served, payment received).',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.completeOrder(params.orderId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to complete order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Order completed successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_void_order: {
|
||||
description: 'Void an entire order with a reason (requires manager approval).',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
reason: z.string().describe('Reason for voiding the order'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.voidOrder(params.orderId, params.reason);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to void order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Order voided!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_split_order: {
|
||||
description: 'Split an order by items or amount across multiple checks.',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID to split'),
|
||||
splits: z
|
||||
.array(
|
||||
z.object({
|
||||
items: z.array(z.string()).describe('Order item IDs for this split'),
|
||||
amount: z.number().optional().describe('Fixed amount (alternative to items)'),
|
||||
})
|
||||
)
|
||||
.describe('Split configurations'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.splitOrder(params.orderId, params.splits);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to split order');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Order split into ${params.splits.length} checks!\n\n${JSON.stringify(
|
||||
response.data,
|
||||
null,
|
||||
2
|
||||
)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
110
servers/touchbistro/src/tools/payments.ts
Normal file
110
servers/touchbistro/src/tools/payments.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Payment Processing Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TouchBistroApiClient } from '../lib/api-client.js';
|
||||
|
||||
export const paymentTools = {
|
||||
touchbistro_process_payment: {
|
||||
description: 'Process a payment for an order (full, partial, or split).',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
type: z.enum(['full', 'partial', 'split']).describe('Payment type'),
|
||||
method: z
|
||||
.enum(['cash', 'credit_card', 'debit_card', 'gift_card', 'mobile_payment', 'check', 'house_account'])
|
||||
.describe('Payment method'),
|
||||
amount: z.number().describe('Payment amount'),
|
||||
tip: z.number().optional().describe('Tip amount'),
|
||||
cardLast4: z.string().optional().describe('Last 4 digits of card'),
|
||||
cardBrand: z.string().optional().describe('Card brand (Visa, Mastercard, etc.)'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { orderId, ...payment } = params;
|
||||
const response = await client.processPayment(orderId, payment);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to process payment');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Payment processed successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_payments: {
|
||||
description: 'Get all payments for an order.',
|
||||
parameters: z.object({
|
||||
orderId: z.string().describe('Order ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getPayments(params.orderId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch payments');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_refund_payment: {
|
||||
description: 'Refund a payment (full or partial).',
|
||||
parameters: z.object({
|
||||
paymentId: z.string().describe('Payment ID'),
|
||||
amount: z.number().optional().describe('Refund amount (full refund if not specified)'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.refundPayment(params.paymentId, params.amount);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to refund payment');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Payment refunded!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_void_payment: {
|
||||
description: 'Void a payment transaction.',
|
||||
parameters: z.object({
|
||||
paymentId: z.string().describe('Payment ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.voidPayment(params.paymentId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to void payment');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Payment voided!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
218
servers/touchbistro/src/tools/reservations.ts
Normal file
218
servers/touchbistro/src/tools/reservations.ts
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Reservation Management Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TouchBistroApiClient } from '../lib/api-client.js';
|
||||
|
||||
export const reservationTools = {
|
||||
touchbistro_list_reservations: {
|
||||
description: 'List reservations with filters for date range and status.',
|
||||
parameters: z.object({
|
||||
dateFrom: z.string().optional().describe('Start date (ISO format)'),
|
||||
dateTo: z.string().optional().describe('End date (ISO format)'),
|
||||
status: z
|
||||
.enum(['pending', 'confirmed', 'seated', 'completed', 'no_show', 'cancelled'])
|
||||
.optional()
|
||||
.describe('Filter by status'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getReservations(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch reservations');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_reservation: {
|
||||
description: 'Get detailed information about a specific reservation.',
|
||||
parameters: z.object({
|
||||
reservationId: z.string().describe('Reservation ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getReservation(params.reservationId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch reservation');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_reservation: {
|
||||
description: 'Create a new reservation for a customer.',
|
||||
parameters: z.object({
|
||||
customerId: z.string().optional().describe('Customer ID (if existing customer)'),
|
||||
customerName: z.string().describe('Customer name'),
|
||||
customerPhone: z.string().optional().describe('Customer phone number'),
|
||||
customerEmail: z.string().optional().describe('Customer email'),
|
||||
partySize: z.number().describe('Number of guests'),
|
||||
date: z.string().describe('Reservation date (ISO format)'),
|
||||
time: z.string().describe('Reservation time (HH:mm format)'),
|
||||
duration: z.number().optional().describe('Estimated duration in minutes'),
|
||||
tableId: z.string().optional().describe('Specific table ID'),
|
||||
sectionId: z.string().optional().describe('Preferred section ID'),
|
||||
notes: z.string().optional().describe('Reservation notes'),
|
||||
specialRequests: z.string().optional().describe('Special requests'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createReservation(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create reservation');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reservation created successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_reservation: {
|
||||
description: 'Update reservation details (time, party size, table, etc.).',
|
||||
parameters: z.object({
|
||||
reservationId: z.string().describe('Reservation ID'),
|
||||
partySize: z.number().optional().describe('Updated party size'),
|
||||
date: z.string().optional().describe('Updated date (ISO format)'),
|
||||
time: z.string().optional().describe('Updated time (HH:mm format)'),
|
||||
duration: z.number().optional().describe('Updated duration in minutes'),
|
||||
tableId: z.string().optional().describe('Updated table ID'),
|
||||
notes: z.string().optional().describe('Updated notes'),
|
||||
specialRequests: z.string().optional().describe('Updated special requests'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { reservationId, ...updates } = params;
|
||||
const response = await client.updateReservation(reservationId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update reservation');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reservation updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_confirm_reservation: {
|
||||
description: 'Confirm a pending reservation.',
|
||||
parameters: z.object({
|
||||
reservationId: z.string().describe('Reservation ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.confirmReservation(params.reservationId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to confirm reservation');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reservation confirmed!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_seat_reservation: {
|
||||
description: 'Seat a reservation at a table.',
|
||||
parameters: z.object({
|
||||
reservationId: z.string().describe('Reservation ID'),
|
||||
tableId: z.string().describe('Table ID to seat at'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.seatReservation(params.reservationId, params.tableId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to seat reservation');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reservation seated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_cancel_reservation: {
|
||||
description: 'Cancel a reservation.',
|
||||
parameters: z.object({
|
||||
reservationId: z.string().describe('Reservation ID'),
|
||||
reason: z.string().optional().describe('Cancellation reason'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.cancelReservation(params.reservationId, params.reason);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to cancel reservation');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reservation cancelled!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_mark_no_show: {
|
||||
description: 'Mark a reservation as no-show.',
|
||||
parameters: z.object({
|
||||
reservationId: z.string().describe('Reservation ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.markNoShow(params.reservationId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to mark as no-show');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reservation marked as no-show!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
226
servers/touchbistro/src/tools/staff.ts
Normal file
226
servers/touchbistro/src/tools/staff.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Staff Management Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TouchBistroApiClient } from '../lib/api-client.js';
|
||||
|
||||
export const staffTools = {
|
||||
touchbistro_list_staff: {
|
||||
description: 'List all staff members with roles, active status, and assigned sections.',
|
||||
parameters: z.object({
|
||||
role: z
|
||||
.enum(['owner', 'manager', 'server', 'bartender', 'host', 'chef', 'cook', 'busser', 'cashier'])
|
||||
.optional()
|
||||
.describe('Filter by role'),
|
||||
active: z.boolean().optional().describe('Filter by active status'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const filter: any = {};
|
||||
if (params.role) filter.role = params.role;
|
||||
if (params.active !== undefined) filter.active = params.active;
|
||||
|
||||
const response = await client.getStaff({ filter });
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch staff');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_staff_member: {
|
||||
description: 'Get detailed information about a specific staff member.',
|
||||
parameters: z.object({
|
||||
staffId: z.string().describe('Staff member ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getStaffMember(params.staffId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch staff member');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_staff_member: {
|
||||
description: 'Create a new staff member with role, permissions, and wage information.',
|
||||
parameters: z.object({
|
||||
employeeNumber: z.string().describe('Employee number'),
|
||||
firstName: z.string().describe('First name'),
|
||||
lastName: z.string().describe('Last name'),
|
||||
email: z.string().optional().describe('Email address'),
|
||||
phone: z.string().optional().describe('Phone number'),
|
||||
role: z
|
||||
.enum(['owner', 'manager', 'server', 'bartender', 'host', 'chef', 'cook', 'busser', 'cashier'])
|
||||
.describe('Staff role'),
|
||||
pinCode: z.string().describe('POS PIN code'),
|
||||
hireDate: z.string().describe('Hire date (ISO format)'),
|
||||
wage: z.number().optional().describe('Wage amount'),
|
||||
wageType: z.enum(['hourly', 'salary', 'tipped_hourly']).optional().describe('Wage type'),
|
||||
assignedSections: z.array(z.string()).optional().describe('Assigned section IDs'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createStaffMember(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create staff member');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Staff member created successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_staff_member: {
|
||||
description: 'Update staff member details, role, or wage information.',
|
||||
parameters: z.object({
|
||||
staffId: z.string().describe('Staff member ID'),
|
||||
firstName: z.string().optional().describe('First name'),
|
||||
lastName: z.string().optional().describe('Last name'),
|
||||
email: z.string().optional().describe('Email address'),
|
||||
phone: z.string().optional().describe('Phone number'),
|
||||
role: z
|
||||
.enum(['owner', 'manager', 'server', 'bartender', 'host', 'chef', 'cook', 'busser', 'cashier'])
|
||||
.optional()
|
||||
.describe('Staff role'),
|
||||
wage: z.number().optional().describe('Wage amount'),
|
||||
wageType: z.enum(['hourly', 'salary', 'tipped_hourly']).optional().describe('Wage type'),
|
||||
assignedSections: z.array(z.string()).optional().describe('Assigned section IDs'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { staffId, ...updates } = params;
|
||||
const response = await client.updateStaffMember(staffId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update staff member');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Staff member updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_deactivate_staff_member: {
|
||||
description: 'Deactivate a staff member (termination/resignation).',
|
||||
parameters: z.object({
|
||||
staffId: z.string().describe('Staff member ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.deactivateStaffMember(params.staffId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to deactivate staff member');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Staff member deactivated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_clock_in: {
|
||||
description: 'Clock in a staff member to start their shift.',
|
||||
parameters: z.object({
|
||||
staffId: z.string().describe('Staff member ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.clockIn(params.staffId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to clock in');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Staff member clocked in!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_clock_out: {
|
||||
description: 'Clock out a staff member to end their shift.',
|
||||
parameters: z.object({
|
||||
staffId: z.string().describe('Staff member ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.clockOut(params.staffId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to clock out');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Staff member clocked out!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_timesheets: {
|
||||
description: 'Get staff timesheets with hours, wages, and tips for a date range.',
|
||||
parameters: z.object({
|
||||
staffId: z.string().optional().describe('Filter by staff member ID'),
|
||||
dateFrom: z.string().optional().describe('Start date (ISO format)'),
|
||||
dateTo: z.string().optional().describe('End date (ISO format)'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getTimeSheets(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch timesheets');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
262
servers/touchbistro/src/tools/tables.ts
Normal file
262
servers/touchbistro/src/tools/tables.ts
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Table & Floor Management Tools
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TouchBistroApiClient } from '../lib/api-client.js';
|
||||
|
||||
export const tableTools = {
|
||||
touchbistro_list_tables: {
|
||||
description: 'List all tables with current status, assigned servers, and guest counts.',
|
||||
parameters: z.object({
|
||||
status: z
|
||||
.enum(['available', 'occupied', 'reserved', 'cleaning', 'out_of_service'])
|
||||
.optional()
|
||||
.describe('Filter by table status'),
|
||||
sectionId: z.string().optional().describe('Filter by section ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const filter: any = {};
|
||||
if (params.status) filter.status = params.status;
|
||||
if (params.sectionId) filter.sectionId = params.sectionId;
|
||||
|
||||
const response = await client.getTables({ filter });
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch tables');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_table: {
|
||||
description: 'Get detailed information about a specific table.',
|
||||
parameters: z.object({
|
||||
tableId: z.string().describe('Table ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getTable(params.tableId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch table');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_table: {
|
||||
description: 'Create a new table in a section with position and capacity.',
|
||||
parameters: z.object({
|
||||
number: z.string().describe('Table number'),
|
||||
name: z.string().optional().describe('Table name'),
|
||||
sectionId: z.string().describe('Section ID'),
|
||||
capacity: z.number().describe('Maximum guest capacity'),
|
||||
shape: z.enum(['square', 'rectangle', 'circle', 'oval']).describe('Table shape'),
|
||||
positionX: z.number().describe('X position on floor plan'),
|
||||
positionY: z.number().describe('Y position on floor plan'),
|
||||
rotation: z.number().optional().describe('Rotation in degrees'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createTable(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create table');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Table created successfully!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_table: {
|
||||
description: 'Update table details like capacity, position, or name.',
|
||||
parameters: z.object({
|
||||
tableId: z.string().describe('Table ID'),
|
||||
number: z.string().optional().describe('Table number'),
|
||||
name: z.string().optional().describe('Table name'),
|
||||
capacity: z.number().optional().describe('Maximum guest capacity'),
|
||||
positionX: z.number().optional().describe('X position on floor plan'),
|
||||
positionY: z.number().optional().describe('Y position on floor plan'),
|
||||
rotation: z.number().optional().describe('Rotation in degrees'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const { tableId, ...updates } = params;
|
||||
const response = await client.updateTable(tableId, updates);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update table');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Table updated!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_update_table_status: {
|
||||
description: 'Update table status (seat guests, mark as cleaning, reserve, etc.).',
|
||||
parameters: z.object({
|
||||
tableId: z.string().describe('Table ID'),
|
||||
status: z
|
||||
.enum(['available', 'occupied', 'reserved', 'cleaning', 'out_of_service'])
|
||||
.describe('New table status'),
|
||||
guestCount: z.number().optional().describe('Number of guests (for occupied status)'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.updateTableStatus(params.tableId, params.status, params.guestCount);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to update table status');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Table status updated to ${params.status}!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_assign_server_to_table: {
|
||||
description: 'Assign a server to a table.',
|
||||
parameters: z.object({
|
||||
tableId: z.string().describe('Table ID'),
|
||||
serverId: z.string().describe('Server/staff ID'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.assignServer(params.tableId, params.serverId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to assign server');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Server assigned to table!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_list_sections: {
|
||||
description: 'List all sections/stations with assigned servers and table counts.',
|
||||
parameters: z.object({}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getSections();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch sections');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_create_section: {
|
||||
description: 'Create a new section/station on a floor plan.',
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Section name'),
|
||||
floorPlanId: z.string().describe('Floor plan ID'),
|
||||
color: z.string().optional().describe('Color code for display'),
|
||||
assignedServerIds: z.array(z.string()).optional().describe('Assigned server IDs'),
|
||||
}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.createSection(params);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to create section');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Section created!\n\n${JSON.stringify(response.data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_list_floor_plans: {
|
||||
description: 'List all floor plans with sections and table layouts.',
|
||||
parameters: z.object({}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getFloorPlans();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch floor plans');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
touchbistro_get_active_floor_plan: {
|
||||
description: 'Get the currently active floor plan with all tables and sections.',
|
||||
parameters: z.object({}),
|
||||
handler: async (params: any, client: TouchBistroApiClient) => {
|
||||
const response = await client.getActiveFloorPlan();
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error?.message || 'Failed to fetch active floor plan');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
165
servers/touchbistro/src/ui/orders-app/App.css
Normal file
165
servers/touchbistro/src/ui/orders-app/App.css
Normal file
@ -0,0 +1,165 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #334155;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.orders-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.order-card:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.order-number {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.order-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.order-total {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.order-time {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: #334155;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
104
servers/touchbistro/src/ui/orders-app/App.tsx
Normal file
104
servers/touchbistro/src/ui/orders-app/App.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './App.css';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
total: number;
|
||||
items: any[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function OrdersApp() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
// Simulated data for demonstration
|
||||
const mockOrders: Order[] = [
|
||||
{
|
||||
id: '1',
|
||||
orderNumber: 'ORD-001',
|
||||
status: 'preparing',
|
||||
total: 45.99,
|
||||
items: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
orderNumber: 'ORD-002',
|
||||
status: 'ready',
|
||||
total: 32.50,
|
||||
items: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
setOrders(mockOrders);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: '#fbbf24',
|
||||
preparing: '#3b82f6',
|
||||
ready: '#10b981',
|
||||
served: '#8b5cf6',
|
||||
completed: '#6b7280',
|
||||
cancelled: '#ef4444',
|
||||
};
|
||||
return colors[status] || '#6b7280';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>🍽️ Orders Dashboard</h1>
|
||||
<p>Real-time order tracking and management</p>
|
||||
</header>
|
||||
|
||||
<div className="filter-bar">
|
||||
{['all', 'pending', 'preparing', 'ready', 'served'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={`filter-btn ${filter === status ? 'active' : ''}`}
|
||||
onClick={() => setFilter(status)}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="orders-grid">
|
||||
{loading ? (
|
||||
<div className="loading">Loading orders...</div>
|
||||
) : (
|
||||
orders.map((order) => (
|
||||
<div key={order.id} className="order-card">
|
||||
<div className="order-header">
|
||||
<span className="order-number">{order.orderNumber}</span>
|
||||
<span
|
||||
className="order-status"
|
||||
style={{ backgroundColor: getStatusColor(order.status) }}
|
||||
>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="order-details">
|
||||
<div className="order-total">${order.total.toFixed(2)}</div>
|
||||
<div className="order-time">
|
||||
{new Date(order.createdAt).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="order-actions">
|
||||
<button className="action-btn">View Details</button>
|
||||
<button className="action-btn primary">Update Status</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
servers/touchbistro/src/ui/orders-app/index.html
Normal file
12
servers/touchbistro/src/ui/orders-app/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TouchBistro Orders Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
servers/touchbistro/src/ui/orders-app/main.tsx
Normal file
9
servers/touchbistro/src/ui/orders-app/main.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
10
servers/touchbistro/src/ui/orders-app/vite.config.ts
Normal file
10
servers/touchbistro/src/ui/orders-app/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
156
servers/touchbistro/src/ui/react-app/customer-directory/App.tsx
Normal file
156
servers/touchbistro/src/ui/react-app/customer-directory/App.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
visitCount: number;
|
||||
totalSpent: number;
|
||||
avgSpend: number;
|
||||
lastVisit: string;
|
||||
loyaltyPoints: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default function CustomerDirectory() {
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'visits' | 'spent'>('name');
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setCustomers([
|
||||
{ id: '1', name: 'Sarah Johnson', email: 'sarah.j@email.com', phone: '555-0101', visitCount: 23, totalSpent: 1847.50, avgSpend: 80.33, lastVisit: '2024-02-14', loyaltyPoints: 2340, tags: ['VIP', 'Regular'] },
|
||||
{ id: '2', name: 'Mike Chen', email: 'mike.chen@email.com', phone: '555-0102', visitCount: 45, totalSpent: 3245.75, avgSpend: 72.13, lastVisit: '2024-02-15', loyaltyPoints: 4890, tags: ['VIP', 'Regular', 'Anniversary'] },
|
||||
{ id: '3', name: 'Emma Davis', email: 'emma.d@email.com', phone: '555-0103', visitCount: 8, totalSpent: 567.20, avgSpend: 70.90, lastVisit: '2024-02-12', loyaltyPoints: 850, tags: [] },
|
||||
{ id: '4', name: 'John Smith', email: 'john.smith@email.com', phone: '555-0104', visitCount: 12, totalSpent: 982.40, avgSpend: 81.87, lastVisit: '2024-02-10', loyaltyPoints: 1470, tags: ['Regular'] },
|
||||
{ id: '5', name: 'Lisa Park', email: 'lisa.park@email.com', phone: '555-0105', visitCount: 34, totalSpent: 2456.90, avgSpend: 72.26, lastVisit: '2024-02-15', loyaltyPoints: 3685, tags: ['VIP'] },
|
||||
{ id: '6', name: 'David Wilson', email: 'david.w@email.com', phone: '555-0106', visitCount: 5, totalSpent: 342.50, avgSpend: 68.50, lastVisit: '2024-02-08', loyaltyPoints: 515, tags: [] },
|
||||
{ id: '7', name: 'Amy Chen', email: 'amy.chen@email.com', phone: '555-0107', visitCount: 18, totalSpent: 1456.80, avgSpend: 80.93, lastVisit: '2024-02-13', loyaltyPoints: 2185, tags: ['Regular'] },
|
||||
{ id: '8', name: 'Robert Taylor', email: 'robert.t@email.com', phone: '555-0108', visitCount: 29, totalSpent: 2123.60, avgSpend: 73.23, lastVisit: '2024-02-14', loyaltyPoints: 3185, tags: ['VIP', 'Regular'] },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const filteredCustomers = customers
|
||||
.filter((customer) =>
|
||||
customer.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
customer.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
customer.phone.includes(searchQuery)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
||||
if (sortBy === 'visits') return b.visitCount - a.visitCount;
|
||||
if (sortBy === 'spent') return b.totalSpent - a.totalSpent;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const totalCustomers = customers.length;
|
||||
const totalRevenue = customers.reduce((sum, c) => sum + c.totalSpent, 0);
|
||||
const avgVisits = customers.reduce((sum, c) => sum + c.visitCount, 0) / customers.length;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>👥 Customer Directory</h1>
|
||||
<p>Searchable customer database</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-bar">
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">Total Customers</span>
|
||||
<span className="stat-value">{totalCustomers}</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">Total Revenue</span>
|
||||
<span className="stat-value revenue">${totalRevenue.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">Avg Visits</span>
|
||||
<span className="stat-value">{avgVisits.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search by name, email, or phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="sort-select"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="visits">Sort by Visits</option>
|
||||
<option value="spent">Sort by Spending</option>
|
||||
</select>
|
||||
<button className="btn-primary">+ Add Customer</button>
|
||||
</div>
|
||||
|
||||
<div className="customer-grid">
|
||||
{filteredCustomers.map((customer) => (
|
||||
<div key={customer.id} className="customer-card">
|
||||
<div className="customer-header">
|
||||
<h3>{customer.name}</h3>
|
||||
{customer.tags.length > 0 && (
|
||||
<div className="tags">
|
||||
{customer.tags.map((tag) => (
|
||||
<span key={tag} className={`tag ${tag.toLowerCase()}`}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="customer-contact">
|
||||
<div className="contact-item">
|
||||
<span className="icon">📧</span>
|
||||
<span>{customer.email}</span>
|
||||
</div>
|
||||
<div className="contact-item">
|
||||
<span className="icon">📱</span>
|
||||
<span>{customer.phone}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="customer-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">{customer.visitCount}</span>
|
||||
<span className="stat-label">Visits</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">${customer.totalSpent.toFixed(0)}</span>
|
||||
<span className="stat-label">Total Spent</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">${customer.avgSpend.toFixed(0)}</span>
|
||||
<span className="stat-label">Avg Spend</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">{customer.loyaltyPoints}</span>
|
||||
<span className="stat-label">Points</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="customer-footer">
|
||||
<span className="last-visit">
|
||||
Last visit: {new Date(customer.lastVisit).toLocaleDateString()}
|
||||
</span>
|
||||
<button className="view-btn">View Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredCustomers.length === 0 && (
|
||||
<div className="no-results">No customers found</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Customer Directory - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
@ -0,0 +1,274 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-value.revenue {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.customer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.customer-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.customer-card:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.customer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.customer-header h3 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tag.vip {
|
||||
background: #fbbf24;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.tag.regular {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tag.anniversary {
|
||||
background: #ec4899;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.customer-contact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.customer-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #0f172a;
|
||||
padding: 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item .stat-value {
|
||||
display: block;
|
||||
color: #10b981;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-item .stat-label {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.customer-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.last-visit {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #64748b;
|
||||
font-size: 1.125rem;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
170
servers/touchbistro/src/ui/react-app/menu-item-detail/App.tsx
Normal file
170
servers/touchbistro/src/ui/react-app/menu-item-detail/App.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface Modifier {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface ModifierGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
required: boolean;
|
||||
modifiers: Modifier[];
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
cost: number;
|
||||
category: string;
|
||||
available: boolean;
|
||||
prepTime: number;
|
||||
allergens: string[];
|
||||
modifierGroups: ModifierGroup[];
|
||||
}
|
||||
|
||||
export default function MenuItemDetail() {
|
||||
const [item, setItem] = useState<MenuItem | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setItem({
|
||||
id: '1',
|
||||
name: 'Classic Burger',
|
||||
description: 'Half-pound beef patty with fresh lettuce, tomato, red onion, pickles, and our signature sauce on a toasted brioche bun',
|
||||
price: 14.99,
|
||||
cost: 4.25,
|
||||
category: 'Burgers & Sandwiches',
|
||||
available: true,
|
||||
prepTime: 12,
|
||||
allergens: ['Gluten', 'Dairy', 'Soy'],
|
||||
modifierGroups: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Cheese Options',
|
||||
required: false,
|
||||
modifiers: [
|
||||
{ id: '1', name: 'Cheddar', price: 1.50 },
|
||||
{ id: '2', name: 'Swiss', price: 1.50 },
|
||||
{ id: '3', name: 'Blue Cheese', price: 2.00 },
|
||||
{ id: '4', name: 'Pepper Jack', price: 1.50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Toppings',
|
||||
required: false,
|
||||
modifiers: [
|
||||
{ id: '5', name: 'Bacon', price: 2.50 },
|
||||
{ id: '6', name: 'Avocado', price: 2.00 },
|
||||
{ id: '7', name: 'Fried Egg', price: 2.00 },
|
||||
{ id: '8', name: 'Mushrooms', price: 1.50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Doneness',
|
||||
required: true,
|
||||
modifiers: [
|
||||
{ id: '9', name: 'Rare', price: 0 },
|
||||
{ id: '10', name: 'Medium Rare', price: 0 },
|
||||
{ id: '11', name: 'Medium', price: 0 },
|
||||
{ id: '12', name: 'Medium Well', price: 0 },
|
||||
{ id: '13', name: 'Well Done', price: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!item) {
|
||||
return <div className="loading">Loading item...</div>;
|
||||
}
|
||||
|
||||
const margin = ((item.price - item.cost) / item.price) * 100;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<div>
|
||||
<h1>🍔 {item.name}</h1>
|
||||
<p className="category">{item.category}</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<span className={`availability ${item.available ? 'available' : 'unavailable'}`}>
|
||||
{item.available ? '✓ Available' : '✗ Unavailable'}
|
||||
</span>
|
||||
<button className="btn-primary">Edit Item</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="content-grid">
|
||||
<div className="main-info">
|
||||
<div className="info-card">
|
||||
<h2>Description</h2>
|
||||
<p className="description">{item.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Pricing & Cost</h2>
|
||||
<div className="pricing-grid">
|
||||
<div className="pricing-item">
|
||||
<span className="pricing-label">Menu Price</span>
|
||||
<span className="pricing-value price">${item.price.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="pricing-item">
|
||||
<span className="pricing-label">Food Cost</span>
|
||||
<span className="pricing-value cost">${item.cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="pricing-item">
|
||||
<span className="pricing-label">Margin</span>
|
||||
<span className="pricing-value margin">{margin.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="pricing-item">
|
||||
<span className="pricing-label">Prep Time</span>
|
||||
<span className="pricing-value">{item.prepTime} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Allergens</h2>
|
||||
<div className="allergens">
|
||||
{item.allergens.map((allergen) => (
|
||||
<span key={allergen} className="allergen-badge">
|
||||
⚠️ {allergen}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modifiers-panel">
|
||||
<h2>Modifier Groups</h2>
|
||||
{item.modifierGroups.map((group) => (
|
||||
<div key={group.id} className="modifier-group">
|
||||
<div className="group-header">
|
||||
<h3>{group.name}</h3>
|
||||
{group.required && <span className="required-badge">Required</span>}
|
||||
</div>
|
||||
<div className="modifiers-list">
|
||||
{group.modifiers.map((mod) => (
|
||||
<div key={mod.id} className="modifier-row">
|
||||
<span className="modifier-name">{mod.name}</span>
|
||||
<span className="modifier-price">
|
||||
{mod.price === 0 ? 'Free' : `+$${mod.price.toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Menu Item Detail - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
247
servers/touchbistro/src/ui/react-app/menu-item-detail/styles.css
Normal file
247
servers/touchbistro/src/ui/react-app/menu-item-detail/styles.css
Normal file
@ -0,0 +1,247 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.availability {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.availability.available {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.availability.unavailable {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.main-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #cbd5e1;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pricing-item {
|
||||
background: #0f172a;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pricing-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pricing-value {
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pricing-value.price {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.pricing-value.cost {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pricing-value.margin {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.allergens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.allergen-badge {
|
||||
padding: 0.625rem 1rem;
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.modifiers-panel {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.modifiers-panel h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modifier-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modifier-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.group-header h3 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: #fbbf24;
|
||||
color: #78350f;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.modifiers-list {
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modifier-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.modifier-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modifier-name {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.modifier-price {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
140
servers/touchbistro/src/ui/react-app/menu-manager/App.tsx
Normal file
140
servers/touchbistro/src/ui/react-app/menu-manager/App.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
category: string;
|
||||
available: boolean;
|
||||
modifiers: number;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export default function MenuManager() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [items, setItems] = useState<MenuItem[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setCategories([
|
||||
{ id: 'appetizers', name: 'Appetizers', itemCount: 8 },
|
||||
{ id: 'entrees', name: 'Entrees', itemCount: 12 },
|
||||
{ id: 'burgers', name: 'Burgers & Sandwiches', itemCount: 9 },
|
||||
{ id: 'salads', name: 'Salads', itemCount: 6 },
|
||||
{ id: 'desserts', name: 'Desserts', itemCount: 7 },
|
||||
{ id: 'beverages', name: 'Beverages', itemCount: 15 },
|
||||
]);
|
||||
|
||||
setItems([
|
||||
{ id: '1', name: 'Classic Burger', description: 'Beef patty, lettuce, tomato, onion, pickles', price: 14.99, category: 'burgers', available: true, modifiers: 8 },
|
||||
{ id: '2', name: 'Caesar Salad', description: 'Romaine, parmesan, croutons, Caesar dressing', price: 9.99, category: 'salads', available: true, modifiers: 4 },
|
||||
{ id: '3', name: 'Chicken Wings', description: '8 pieces with choice of sauce', price: 12.99, category: 'appetizers', available: true, modifiers: 6 },
|
||||
{ id: '4', name: 'Grilled Salmon', description: 'Atlantic salmon with vegetables and rice', price: 24.99, category: 'entrees', available: true, modifiers: 3 },
|
||||
{ id: '5', name: 'Chocolate Cake', description: 'Rich chocolate layer cake with ganache', price: 7.99, category: 'desserts', available: true, modifiers: 2 },
|
||||
{ id: '6', name: 'Craft Beer', description: 'Rotating selection of local craft beers', price: 6.99, category: 'beverages', available: true, modifiers: 0 },
|
||||
{ id: '7', name: 'Margherita Pizza', description: 'Fresh mozzarella, basil, tomato sauce', price: 16.99, category: 'entrees', available: false, modifiers: 5 },
|
||||
{ id: '8', name: 'French Fries', description: 'Crispy golden fries with sea salt', price: 4.99, category: 'appetizers', available: true, modifiers: 4 },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
const matchesCategory = selectedCategory === 'all' || item.category === selectedCategory;
|
||||
const matchesSearch = item.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>📖 Menu Manager</h1>
|
||||
<p>Browse and edit menu items</p>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search menu items..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary">+ Add Item</button>
|
||||
</div>
|
||||
|
||||
<div className="content-grid">
|
||||
<div className="categories-panel">
|
||||
<h2>Categories</h2>
|
||||
<div className="category-list">
|
||||
<button
|
||||
className={`category-btn ${selectedCategory === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
>
|
||||
All Items
|
||||
<span className="count">{items.length}</span>
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`category-btn ${selectedCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(cat.id)}
|
||||
>
|
||||
{cat.name}
|
||||
<span className="count">{cat.itemCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-secondary">+ Add Category</button>
|
||||
</div>
|
||||
|
||||
<div className="items-panel">
|
||||
<div className="panel-header">
|
||||
<h2>
|
||||
{selectedCategory === 'all'
|
||||
? 'All Items'
|
||||
: categories.find((c) => c.id === selectedCategory)?.name}
|
||||
</h2>
|
||||
<span className="results-count">
|
||||
{filteredItems.length} {filteredItems.length === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="items-grid">
|
||||
{filteredItems.map((item) => (
|
||||
<div key={item.id} className="item-card">
|
||||
<div className="item-header">
|
||||
<h3>{item.name}</h3>
|
||||
<span className={`availability ${item.available ? 'available' : 'unavailable'}`}>
|
||||
{item.available ? '✓ Available' : '✗ Unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="item-description">{item.description}</p>
|
||||
<div className="item-meta">
|
||||
<span className="price">${item.price.toFixed(2)}</span>
|
||||
<span className="modifiers">{item.modifiers} modifiers</span>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<button className="action-btn">Edit</button>
|
||||
<button className="action-btn">Duplicate</button>
|
||||
<button className="action-btn delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="no-results">No items found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
servers/touchbistro/src/ui/react-app/menu-manager/index.html
Normal file
12
servers/touchbistro/src/ui/react-app/menu-manager/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Menu Manager - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
298
servers/touchbistro/src/ui/react-app/menu-manager/styles.css
Normal file
298
servers/touchbistro/src/ui/react-app/menu-manager/styles.css
Normal file
@ -0,0 +1,298 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.875rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #334155;
|
||||
color: #cbd5e1;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.categories-panel {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.categories-panel h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.875rem 1rem;
|
||||
background: #0f172a;
|
||||
border: 2px solid #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: #475569;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.category-btn .count {
|
||||
background: #334155;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.category-btn.active .count {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.items-panel {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.item-header h3 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.125rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.availability {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.availability.available {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.availability.unavailable {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.item-description {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #10b981;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modifiers {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 0.625rem;
|
||||
background: #334155;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #64748b;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
152
servers/touchbistro/src/ui/react-app/order-dashboard/App.tsx
Normal file
152
servers/touchbistro/src/ui/react-app/order-dashboard/App.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface OrderStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
revenue: number;
|
||||
averageValue: number;
|
||||
}
|
||||
|
||||
interface RecentOrder {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
total: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export default function OrderDashboard() {
|
||||
const [stats, setStats] = useState<OrderStats>({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
inProgress: 0,
|
||||
completed: 0,
|
||||
revenue: 0,
|
||||
averageValue: 0,
|
||||
});
|
||||
const [recentOrders, setRecentOrders] = useState<RecentOrder[]>([]);
|
||||
const [hourlyVolume, setHourlyVolume] = useState<{ hour: string; count: number }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setStats({
|
||||
total: 142,
|
||||
pending: 8,
|
||||
inProgress: 12,
|
||||
completed: 122,
|
||||
revenue: 8547.32,
|
||||
averageValue: 60.19,
|
||||
});
|
||||
|
||||
setRecentOrders([
|
||||
{ id: '1', orderNumber: 'ORD-142', status: 'in_progress', total: 68.50, time: '2 min ago' },
|
||||
{ id: '2', orderNumber: 'ORD-141', status: 'pending', total: 45.00, time: '5 min ago' },
|
||||
{ id: '3', orderNumber: 'ORD-140', status: 'completed', total: 92.75, time: '8 min ago' },
|
||||
{ id: '4', orderNumber: 'ORD-139', status: 'in_progress', total: 54.25, time: '12 min ago' },
|
||||
{ id: '5', orderNumber: 'ORD-138', status: 'completed', total: 78.90, time: '15 min ago' },
|
||||
]);
|
||||
|
||||
setHourlyVolume([
|
||||
{ hour: '11am', count: 15 },
|
||||
{ hour: '12pm', count: 28 },
|
||||
{ hour: '1pm', count: 32 },
|
||||
{ hour: '2pm', count: 18 },
|
||||
{ hour: '3pm', count: 12 },
|
||||
{ hour: '4pm', count: 8 },
|
||||
{ hour: '5pm', count: 22 },
|
||||
{ hour: '6pm', count: 35 },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: '#fbbf24',
|
||||
in_progress: '#3b82f6',
|
||||
completed: '#10b981',
|
||||
};
|
||||
return colors[status] || '#6b7280';
|
||||
};
|
||||
|
||||
const maxVolume = Math.max(...hourlyVolume.map(h => h.count));
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>📊 Order Dashboard</h1>
|
||||
<p>Live order statistics and activity</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Orders Today</div>
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Pending</div>
|
||||
<div className="stat-value pending">{stats.pending}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">In Progress</div>
|
||||
<div className="stat-value in-progress">{stats.inProgress}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Completed</div>
|
||||
<div className="stat-value completed">{stats.completed}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Revenue</div>
|
||||
<div className="stat-value revenue">${stats.revenue.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Average Order Value</div>
|
||||
<div className="stat-value">${stats.averageValue.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-content">
|
||||
<div className="section">
|
||||
<h2>Hourly Order Volume</h2>
|
||||
<div className="chart">
|
||||
{hourlyVolume.map((item) => (
|
||||
<div key={item.hour} className="chart-bar">
|
||||
<div
|
||||
className="bar-fill"
|
||||
style={{ height: `${(item.count / maxVolume) * 100}%` }}
|
||||
>
|
||||
<span className="bar-label">{item.count}</span>
|
||||
</div>
|
||||
<div className="bar-hour">{item.hour}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h2>Recent Orders</h2>
|
||||
<div className="recent-orders">
|
||||
{recentOrders.map((order) => (
|
||||
<div key={order.id} className="order-row">
|
||||
<div className="order-info">
|
||||
<span className="order-number">{order.orderNumber}</span>
|
||||
<span className="order-time">{order.time}</span>
|
||||
</div>
|
||||
<div className="order-details">
|
||||
<span
|
||||
className="order-status"
|
||||
style={{ backgroundColor: getStatusColor(order.status) }}
|
||||
>
|
||||
{order.status.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="order-total">${order.total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Order Dashboard - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
197
servers/touchbistro/src/ui/react-app/order-dashboard/styles.css
Normal file
197
servers/touchbistro/src/ui/react-app/order-dashboard/styles.css
Normal file
@ -0,0 +1,197 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-value.pending {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.stat-value.in-progress {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-value.completed {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-value.revenue {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
height: 200px;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, #3b82f6, #60a5fa);
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 0.5rem;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar-hour {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.recent-orders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.order-row {
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.order-number {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.order-time {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.order-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.order-total {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
211
servers/touchbistro/src/ui/react-app/order-detail/App.tsx
Normal file
211
servers/touchbistro/src/ui/react-app/order-detail/App.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
modifiers: { name: string; price: number }[];
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
type: string;
|
||||
customer: string;
|
||||
server: string;
|
||||
table?: string;
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
tip: number;
|
||||
total: number;
|
||||
paymentMethod: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export default function OrderDetail() {
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setOrder({
|
||||
id: '1',
|
||||
orderNumber: 'ORD-142',
|
||||
status: 'in_progress',
|
||||
type: 'dine_in',
|
||||
customer: 'Sarah Johnson',
|
||||
server: 'Mike Chen',
|
||||
table: 'Table 12',
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Classic Burger',
|
||||
quantity: 2,
|
||||
price: 14.99,
|
||||
modifiers: [
|
||||
{ name: 'Extra Cheese', price: 1.50 },
|
||||
{ name: 'No Onions', price: 0 },
|
||||
],
|
||||
instructions: 'Medium rare',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Caesar Salad',
|
||||
quantity: 1,
|
||||
price: 9.99,
|
||||
modifiers: [
|
||||
{ name: 'Add Chicken', price: 4.00 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'French Fries',
|
||||
quantity: 2,
|
||||
price: 4.99,
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Soft Drink',
|
||||
quantity: 2,
|
||||
price: 2.99,
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
subtotal: 60.43,
|
||||
tax: 5.44,
|
||||
tip: 12.00,
|
||||
total: 77.87,
|
||||
paymentMethod: 'Credit Card',
|
||||
createdAt: new Date(Date.now() - 15 * 60000).toISOString(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!order) {
|
||||
return <div className="loading">Loading order...</div>;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
draft: '#6b7280',
|
||||
pending: '#fbbf24',
|
||||
in_progress: '#3b82f6',
|
||||
ready: '#10b981',
|
||||
completed: '#10b981',
|
||||
voided: '#ef4444',
|
||||
};
|
||||
return colors[status] || '#6b7280';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<div>
|
||||
<h1>🧾 Order Detail</h1>
|
||||
<p>{order.orderNumber}</p>
|
||||
</div>
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ backgroundColor: getStatusColor(order.status) }}
|
||||
>
|
||||
{order.status.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="order-grid">
|
||||
<div className="info-section">
|
||||
<h2>Order Information</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<span className="info-label">Order Type</span>
|
||||
<span className="info-value">{order.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Customer</span>
|
||||
<span className="info-value">{order.customer}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Server</span>
|
||||
<span className="info-value">{order.server}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Table</span>
|
||||
<span className="info-value">{order.table || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Created</span>
|
||||
<span className="info-value">
|
||||
{new Date(order.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Payment</span>
|
||||
<span className="info-value">{order.paymentMethod}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="items-section">
|
||||
<h2>Order Items</h2>
|
||||
<div className="items-list">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="item-card">
|
||||
<div className="item-header">
|
||||
<span className="item-name">
|
||||
{item.quantity}× {item.name}
|
||||
</span>
|
||||
<span className="item-price">
|
||||
${(item.price * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{item.modifiers.length > 0 && (
|
||||
<div className="modifiers">
|
||||
{item.modifiers.map((mod, idx) => (
|
||||
<div key={idx} className="modifier">
|
||||
• {mod.name}
|
||||
{mod.price > 0 && ` (+$${mod.price.toFixed(2)})`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.instructions && (
|
||||
<div className="instructions">📝 {item.instructions}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="totals">
|
||||
<div className="total-row">
|
||||
<span>Subtotal</span>
|
||||
<span>${order.subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="total-row">
|
||||
<span>Tax</span>
|
||||
<span>${order.tax.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="total-row">
|
||||
<span>Tip</span>
|
||||
<span>${order.tip.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="total-row total">
|
||||
<span>Total</span>
|
||||
<span>${order.total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary">Print Receipt</button>
|
||||
<button className="btn btn-secondary">Refund</button>
|
||||
<button className="btn btn-primary">Update Status</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
servers/touchbistro/src/ui/react-app/order-detail/index.html
Normal file
12
servers/touchbistro/src/ui/react-app/order-detail/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Order Detail - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
221
servers/touchbistro/src/ui/react-app/order-detail/styles.css
Normal file
221
servers/touchbistro/src/ui/react-app/order-detail/styles.css
Normal file
@ -0,0 +1,221 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.order-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.order-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.info-section,
|
||||
.items-section {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #f8fafc;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.modifiers {
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.modifier {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #1e293b;
|
||||
border-left: 3px solid #fbbf24;
|
||||
border-radius: 0.25rem;
|
||||
color: #fbbf24;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.totals {
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
color: #cbd5e1;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.total-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.total-row.total {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #10b981;
|
||||
padding-top: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
border-top: 2px solid #334155;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #334155;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
154
servers/touchbistro/src/ui/react-app/order-grid/App.tsx
Normal file
154
servers/touchbistro/src/ui/react-app/order-grid/App.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
type: string;
|
||||
customer: string;
|
||||
server: string;
|
||||
total: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function OrderGrid() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
const mockOrders: Order[] = [
|
||||
{ id: '1', orderNumber: 'ORD-142', status: 'in_progress', type: 'dine_in', customer: 'Sarah Johnson', server: 'Mike Chen', total: 77.87, createdAt: '2024-01-15T14:30:00Z' },
|
||||
{ id: '2', orderNumber: 'ORD-141', status: 'completed', type: 'takeout', customer: 'John Smith', server: 'Lisa Park', total: 45.50, createdAt: '2024-01-15T14:15:00Z' },
|
||||
{ id: '3', orderNumber: 'ORD-140', status: 'pending', type: 'delivery', customer: 'Emma Davis', server: 'Tom Wilson', total: 92.30, createdAt: '2024-01-15T14:00:00Z' },
|
||||
{ id: '4', orderNumber: 'ORD-139', status: 'completed', type: 'dine_in', customer: 'Michael Brown', server: 'Mike Chen', total: 156.75, createdAt: '2024-01-15T13:45:00Z' },
|
||||
{ id: '5', orderNumber: 'ORD-138', status: 'voided', type: 'takeout', customer: 'Jessica Lee', server: 'Lisa Park', total: 34.20, createdAt: '2024-01-15T13:30:00Z' },
|
||||
{ id: '6', orderNumber: 'ORD-137', status: 'completed', type: 'dine_in', customer: 'David Wilson', server: 'Tom Wilson', total: 68.90, createdAt: '2024-01-15T13:15:00Z' },
|
||||
{ id: '7', orderNumber: 'ORD-136', status: 'completed', type: 'curbside', customer: 'Amy Chen', server: 'Mike Chen', total: 52.40, createdAt: '2024-01-15T13:00:00Z' },
|
||||
{ id: '8', orderNumber: 'ORD-135', status: 'completed', type: 'dine_in', customer: 'Robert Taylor', server: 'Lisa Park', total: 89.25, createdAt: '2024-01-15T12:45:00Z' },
|
||||
];
|
||||
setOrders(mockOrders);
|
||||
}, []);
|
||||
|
||||
const filteredOrders = orders.filter((order) => {
|
||||
const matchesSearch =
|
||||
order.orderNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
order.customer.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || order.status === statusFilter;
|
||||
const matchesType = typeFilter === 'all' || order.type === typeFilter;
|
||||
return matchesSearch && matchesStatus && matchesType;
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
draft: '#6b7280',
|
||||
pending: '#fbbf24',
|
||||
in_progress: '#3b82f6',
|
||||
ready: '#10b981',
|
||||
completed: '#10b981',
|
||||
voided: '#ef4444',
|
||||
};
|
||||
return colors[status] || '#6b7280';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>📋 Order History</h1>
|
||||
<p>Search and filter all orders</p>
|
||||
</header>
|
||||
|
||||
<div className="filters">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search by order number or customer..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="filter-select"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="voided">Voided</option>
|
||||
</select>
|
||||
<select
|
||||
className="filter-select"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="dine_in">Dine In</option>
|
||||
<option value="takeout">Takeout</option>
|
||||
<option value="delivery">Delivery</option>
|
||||
<option value="curbside">Curbside</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="results-info">
|
||||
Showing {filteredOrders.length} of {orders.length} orders
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="orders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order #</th>
|
||||
<th>Customer</th>
|
||||
<th>Type</th>
|
||||
<th>Server</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th>Time</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredOrders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td className="order-number">{order.orderNumber}</td>
|
||||
<td>{order.customer}</td>
|
||||
<td>
|
||||
<span className="type-badge">
|
||||
{order.type.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td>{order.server}</td>
|
||||
<td>
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ backgroundColor: getStatusColor(order.status) }}
|
||||
>
|
||||
{order.status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="total">${order.total.toFixed(2)}</td>
|
||||
<td className="time">
|
||||
{new Date(order.createdAt).toLocaleTimeString()}
|
||||
</td>
|
||||
<td>
|
||||
<button className="action-btn">View</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredOrders.length === 0 && (
|
||||
<div className="no-results">
|
||||
No orders found matching your filters
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
servers/touchbistro/src/ui/react-app/order-grid/index.html
Normal file
12
servers/touchbistro/src/ui/react-app/order-grid/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Order Grid - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
servers/touchbistro/src/ui/react-app/order-grid/main.tsx
Normal file
9
servers/touchbistro/src/ui/react-app/order-grid/main.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
197
servers/touchbistro/src/ui/react-app/order-grid/styles.css
Normal file
197
servers/touchbistro/src/ui/react-app/order-grid/styles.css
Normal file
@ -0,0 +1,197 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.results-info {
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orders-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.orders-table thead {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.orders-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
.orders-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.orders-table tbody tr:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.orders-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.order-number {
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-weight: 700;
|
||||
color: #10b981;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #64748b;
|
||||
font-size: 1.125rem;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.orders-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface Reservation {
|
||||
id: string;
|
||||
time: string;
|
||||
name: string;
|
||||
partySize: number;
|
||||
status: 'confirmed' | 'pending' | 'seated' | 'completed';
|
||||
table?: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export default function ReservationCalendar() {
|
||||
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [view, setView] = useState<'day' | 'week'>('day');
|
||||
const [reservations, setReservations] = useState<Reservation[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setReservations([
|
||||
{ id: '1', time: '11:00', name: 'Sarah Johnson', partySize: 4, status: 'completed', table: 'Table 5', phone: '555-0101' },
|
||||
{ id: '2', time: '11:30', name: 'Mike Chen', partySize: 2, status: 'completed', table: 'Table 2', phone: '555-0102' },
|
||||
{ id: '3', time: '12:00', name: 'Emma Davis', partySize: 6, status: 'seated', table: 'Table 8', phone: '555-0103' },
|
||||
{ id: '4', time: '12:30', name: 'John Smith', partySize: 3, status: 'seated', table: 'Table 4', phone: '555-0104' },
|
||||
{ id: '5', time: '13:00', name: 'Lisa Park', partySize: 2, status: 'confirmed', phone: '555-0105' },
|
||||
{ id: '6', time: '13:30', name: 'David Wilson', partySize: 5, status: 'confirmed', phone: '555-0106' },
|
||||
{ id: '7', time: '14:00', name: 'Amy Chen', partySize: 4, status: 'pending', phone: '555-0107' },
|
||||
{ id: '8', time: '18:00', name: 'Robert Taylor', partySize: 8, status: 'confirmed', phone: '555-0108' },
|
||||
{ id: '9', time: '18:30', name: 'Jessica Lee', partySize: 2, status: 'confirmed', phone: '555-0109' },
|
||||
{ id: '10', time: '19:00', name: 'Michael Brown', partySize: 6, status: 'confirmed', phone: '555-0110' },
|
||||
{ id: '11', time: '19:30', name: 'Emily White', partySize: 4, status: 'confirmed', phone: '555-0111' },
|
||||
{ id: '12', time: '20:00', name: 'Tom Anderson', partySize: 2, status: 'pending', phone: '555-0112' },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const timeSlots = [
|
||||
'11:00', '11:30', '12:00', '12:30', '13:00', '13:30', '14:00', '14:30',
|
||||
'15:00', '15:30', '16:00', '16:30', '17:00', '17:30', '18:00', '18:30',
|
||||
'19:00', '19:30', '20:00', '20:30', '21:00', '21:30', '22:00'
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors = {
|
||||
pending: '#fbbf24',
|
||||
confirmed: '#3b82f6',
|
||||
seated: '#10b981',
|
||||
completed: '#6b7280',
|
||||
};
|
||||
return colors[status as keyof typeof colors] || '#6b7280';
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: reservations.length,
|
||||
pending: reservations.filter(r => r.status === 'pending').length,
|
||||
confirmed: reservations.filter(r => r.status === 'confirmed').length,
|
||||
seated: reservations.filter(r => r.status === 'seated').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>📅 Reservation Calendar</h1>
|
||||
<p>Manage reservations by day or week</p>
|
||||
</header>
|
||||
|
||||
<div className="controls">
|
||||
<input
|
||||
type="date"
|
||||
className="date-input"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${view === 'day' ? 'active' : ''}`}
|
||||
onClick={() => setView('day')}
|
||||
>
|
||||
Day View
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${view === 'week' ? 'active' : ''}`}
|
||||
onClick={() => setView('week')}
|
||||
>
|
||||
Week View
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn-primary">+ New Reservation</button>
|
||||
</div>
|
||||
|
||||
<div className="stats-bar">
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">Total Today</span>
|
||||
<span className="stat-value">{stats.total}</span>
|
||||
</div>
|
||||
<div className="stat-card pending">
|
||||
<span className="stat-label">Pending</span>
|
||||
<span className="stat-value">{stats.pending}</span>
|
||||
</div>
|
||||
<div className="stat-card confirmed">
|
||||
<span className="stat-label">Confirmed</span>
|
||||
<span className="stat-value">{stats.confirmed}</span>
|
||||
</div>
|
||||
<div className="stat-card seated">
|
||||
<span className="stat-label">Seated</span>
|
||||
<span className="stat-value">{stats.seated}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="calendar-container">
|
||||
<div className="time-column">
|
||||
{timeSlots.map((time) => (
|
||||
<div key={time} className="time-slot">
|
||||
{time}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="reservations-column">
|
||||
{timeSlots.map((time) => {
|
||||
const slotReservations = reservations.filter(r => r.time === time);
|
||||
return (
|
||||
<div key={time} className="reservation-slot">
|
||||
{slotReservations.map((res) => (
|
||||
<div
|
||||
key={res.id}
|
||||
className="reservation-card"
|
||||
style={{ borderLeftColor: getStatusColor(res.status) }}
|
||||
>
|
||||
<div className="res-header">
|
||||
<span className="res-name">{res.name}</span>
|
||||
<span className="res-party">👥 {res.partySize}</span>
|
||||
</div>
|
||||
<div className="res-details">
|
||||
<span
|
||||
className="res-status"
|
||||
style={{ backgroundColor: getStatusColor(res.status) }}
|
||||
>
|
||||
{res.status}
|
||||
</span>
|
||||
{res.table && <span className="res-table">🪑 {res.table}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reservation Calendar - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
@ -0,0 +1,242 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.pending {
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
.stat-card.confirmed {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-card.seated {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-column {
|
||||
background: #0f172a;
|
||||
border-right: 2px solid #334155;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.time-slot:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.reservations-column {
|
||||
overflow-y: auto;
|
||||
max-height: 700px;
|
||||
}
|
||||
|
||||
.reservation-slot {
|
||||
min-height: 60px;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.reservation-slot:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.reservation-card {
|
||||
background: #0f172a;
|
||||
border-left: 4px solid;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.reservation-card:hover {
|
||||
background: #1e293b;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.res-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.res-name {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.res-party {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.res-details {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.res-status {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.res-table {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
197
servers/touchbistro/src/ui/react-app/reservation-detail/App.tsx
Normal file
197
servers/touchbistro/src/ui/react-app/reservation-detail/App.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface Reservation {
|
||||
id: string;
|
||||
confirmationNumber: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
partySize: number;
|
||||
date: string;
|
||||
time: string;
|
||||
duration: number;
|
||||
status: 'pending' | 'confirmed' | 'seated' | 'completed' | 'cancelled' | 'no_show';
|
||||
table?: string;
|
||||
notes?: string;
|
||||
specialRequests?: string;
|
||||
createdAt: string;
|
||||
seatedAt?: string;
|
||||
}
|
||||
|
||||
export default function ReservationDetail() {
|
||||
const [reservation, setReservation] = useState<Reservation | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setReservation({
|
||||
id: '1',
|
||||
confirmationNumber: 'RES-2024-001',
|
||||
customerName: 'Sarah Johnson',
|
||||
customerEmail: 'sarah.johnson@email.com',
|
||||
customerPhone: '(555) 123-4567',
|
||||
partySize: 6,
|
||||
date: '2024-02-15',
|
||||
time: '19:00',
|
||||
duration: 90,
|
||||
status: 'confirmed',
|
||||
table: 'Table 12',
|
||||
notes: 'Anniversary celebration - please prepare champagne',
|
||||
specialRequests: 'Window seating preferred, quiet area',
|
||||
createdAt: '2024-02-10T14:30:00Z',
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!reservation) {
|
||||
return <div className="loading">Loading reservation...</div>;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors = {
|
||||
pending: '#fbbf24',
|
||||
confirmed: '#3b82f6',
|
||||
seated: '#10b981',
|
||||
completed: '#6b7280',
|
||||
cancelled: '#ef4444',
|
||||
no_show: '#ef4444',
|
||||
};
|
||||
return colors[status as keyof typeof colors] || '#6b7280';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<div>
|
||||
<h1>📋 Reservation Detail</h1>
|
||||
<p>{reservation.confirmationNumber}</p>
|
||||
</div>
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ backgroundColor: getStatusColor(reservation.status) }}
|
||||
>
|
||||
{reservation.status.toUpperCase().replace('_', ' ')}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="content-grid">
|
||||
<div className="main-panel">
|
||||
<div className="info-card">
|
||||
<h2>Guest Information</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<span className="label">Guest Name</span>
|
||||
<span className="value">{reservation.customerName}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Email</span>
|
||||
<span className="value">{reservation.customerEmail}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Phone</span>
|
||||
<span className="value">{reservation.customerPhone}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Party Size</span>
|
||||
<span className="value party-size">
|
||||
👥 {reservation.partySize} guests
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Reservation Details</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<span className="label">Date</span>
|
||||
<span className="value">
|
||||
{new Date(reservation.date).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Time</span>
|
||||
<span className="value">{reservation.time}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Duration</span>
|
||||
<span className="value">{reservation.duration} minutes</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Table Assignment</span>
|
||||
<span className="value">{reservation.table || 'Not assigned'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reservation.specialRequests && (
|
||||
<div className="info-card">
|
||||
<h2>Special Requests</h2>
|
||||
<p className="special-requests">{reservation.specialRequests}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reservation.notes && (
|
||||
<div className="info-card">
|
||||
<h2>Internal Notes</h2>
|
||||
<p className="notes">{reservation.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="side-panel">
|
||||
<div className="timeline-card">
|
||||
<h2>Timeline</h2>
|
||||
<div className="timeline">
|
||||
<div className="timeline-item">
|
||||
<div className="timeline-dot"></div>
|
||||
<div className="timeline-content">
|
||||
<div className="timeline-title">Reservation Created</div>
|
||||
<div className="timeline-time">
|
||||
{new Date(reservation.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{reservation.status !== 'pending' && (
|
||||
<div className="timeline-item active">
|
||||
<div className="timeline-dot"></div>
|
||||
<div className="timeline-content">
|
||||
<div className="timeline-title">Confirmed</div>
|
||||
<div className="timeline-time">Confirmed by guest</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reservation.seatedAt && (
|
||||
<div className="timeline-item active">
|
||||
<div className="timeline-dot"></div>
|
||||
<div className="timeline-content">
|
||||
<div className="timeline-title">Seated</div>
|
||||
<div className="timeline-time">
|
||||
{new Date(reservation.seatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions-card">
|
||||
<h2>Actions</h2>
|
||||
<div className="actions">
|
||||
<button className="action-btn primary">Seat Guest</button>
|
||||
<button className="action-btn">Send Reminder</button>
|
||||
<button className="action-btn">Edit Details</button>
|
||||
<button className="action-btn">Assign Table</button>
|
||||
<button className="action-btn danger">Cancel Reservation</button>
|
||||
<button className="action-btn danger">Mark No-Show</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reservation Detail - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
@ -0,0 +1,233 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.timeline-card,
|
||||
.actions-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #f8fafc;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.party-size {
|
||||
color: #10b981;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.special-requests,
|
||||
.notes {
|
||||
color: #cbd5e1;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.notes {
|
||||
border-left-color: #fbbf24;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 30px;
|
||||
width: 2px;
|
||||
height: calc(100% + 1.5rem);
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #334155;
|
||||
border: 3px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.timeline-item.active .timeline-dot {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.875rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #334155;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
138
servers/touchbistro/src/ui/react-app/staff-dashboard/App.tsx
Normal file
138
servers/touchbistro/src/ui/react-app/staff-dashboard/App.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
active: boolean;
|
||||
shift: 'morning' | 'evening' | 'off';
|
||||
sales: number;
|
||||
orders: number;
|
||||
avgCheck: number;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export default function StaffDashboard() {
|
||||
const [staff, setStaff] = useState<Staff[]>([]);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setStaff([
|
||||
{ id: '1', name: 'Mike Chen', role: 'Server', active: true, shift: 'morning', sales: 2847.50, orders: 32, avgCheck: 89.00, hours: 38.5 },
|
||||
{ id: '2', name: 'Lisa Park', role: 'Server', active: true, shift: 'evening', sales: 3125.75, orders: 45, avgCheck: 69.46, hours: 42.0 },
|
||||
{ id: '3', name: 'Tom Wilson', role: 'Bartender', active: true, shift: 'evening', sales: 1834.25, orders: 28, avgCheck: 65.51, hours: 40.0 },
|
||||
{ id: '4', name: 'Sarah Johnson', role: 'Server', active: false, shift: 'off', sales: 1245.00, orders: 18, avgCheck: 69.17, hours: 24.0 },
|
||||
{ id: '5', name: 'David Kim', role: 'Host', active: true, shift: 'morning', sales: 0, orders: 0, avgCheck: 0, hours: 35.0 },
|
||||
{ id: '6', name: 'Emma Davis', role: 'Chef', active: true, shift: 'morning', sales: 0, orders: 156, avgCheck: 0, hours: 44.0 },
|
||||
{ id: '7', name: 'John Smith', role: 'Server', active: true, shift: 'evening', sales: 2567.80, orders: 38, avgCheck: 67.57, hours: 36.5 },
|
||||
{ id: '8', name: 'Amy Chen', role: 'Busser', active: true, shift: 'evening', sales: 0, orders: 0, avgCheck: 0, hours: 32.0 },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const filteredStaff = filter === 'all' ? staff : staff.filter(s => s.shift === filter || (filter === 'off-duty' && !s.active));
|
||||
|
||||
const totalSales = staff.reduce((sum, s) => sum + s.sales, 0);
|
||||
const activeStaff = staff.filter(s => s.active).length;
|
||||
const totalOrders = staff.reduce((sum, s) => sum + s.orders, 0);
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
Server: '#3b82f6',
|
||||
Bartender: '#8b5cf6',
|
||||
Chef: '#ef4444',
|
||||
Host: '#10b981',
|
||||
Busser: '#fbbf24',
|
||||
Manager: '#ec4899',
|
||||
};
|
||||
return colors[role] || '#6b7280';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>👥 Staff Dashboard</h1>
|
||||
<p>Team performance and shifts</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Active Staff</div>
|
||||
<div className="stat-value">{activeStaff}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Sales</div>
|
||||
<div className="stat-value sales">${totalSales.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Orders</div>
|
||||
<div className="stat-value">{totalOrders}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Avg Check</div>
|
||||
<div className="stat-value">${(totalSales / totalOrders).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filters">
|
||||
{['all', 'morning', 'evening', 'off-duty'].map((shift) => (
|
||||
<button
|
||||
key={shift}
|
||||
className={`filter-btn ${filter === shift ? 'active' : ''}`}
|
||||
onClick={() => setFilter(shift)}
|
||||
>
|
||||
{shift.charAt(0).toUpperCase() + shift.slice(1).replace('-', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="staff-grid">
|
||||
{filteredStaff.map((member) => (
|
||||
<div key={member.id} className={`staff-card ${!member.active ? 'inactive' : ''}`}>
|
||||
<div className="staff-header">
|
||||
<div>
|
||||
<h3>{member.name}</h3>
|
||||
<span
|
||||
className="role-badge"
|
||||
style={{ backgroundColor: getRoleBadgeColor(member.role) }}
|
||||
>
|
||||
{member.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`shift-indicator ${member.shift}`}>
|
||||
{member.active ? member.shift : 'Off Duty'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="staff-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Sales</span>
|
||||
<span className="stat-value">${member.sales.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Orders</span>
|
||||
<span className="stat-value">{member.orders}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Avg Check</span>
|
||||
<span className="stat-value">
|
||||
{member.avgCheck > 0 ? `$${member.avgCheck.toFixed(2)}` : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Hours</span>
|
||||
<span className="stat-value">{member.hours}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="staff-actions">
|
||||
<button className="action-btn">View Profile</button>
|
||||
<button className="action-btn">Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Staff Dashboard - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
214
servers/touchbistro/src/ui/react-app/staff-dashboard/styles.css
Normal file
214
servers/touchbistro/src/ui/react-app/staff-dashboard/styles.css
Normal file
@ -0,0 +1,214 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-value.sales {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.staff-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.staff-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.staff-card.inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.staff-card:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.staff-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.staff-header h3 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.shift-indicator {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.shift-indicator.morning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.shift-indicator.evening {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.shift-indicator.off {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.staff-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: #0f172a;
|
||||
padding: 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat .stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat .stat-value {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.staff-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: #334155;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
144
servers/touchbistro/src/ui/react-app/staff-schedule/App.tsx
Normal file
144
servers/touchbistro/src/ui/react-app/staff-schedule/App.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface Shift {
|
||||
staffId: string;
|
||||
staffName: string;
|
||||
day: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export default function StaffSchedule() {
|
||||
const [shifts, setShifts] = useState<Shift[]>([]);
|
||||
const [weekStart, setWeekStart] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data
|
||||
setShifts([
|
||||
{ staffId: '1', staffName: 'Mike Chen', day: 'Monday', startTime: '09:00', endTime: '17:00', role: 'Server' },
|
||||
{ staffId: '1', staffName: 'Mike Chen', day: 'Tuesday', startTime: '09:00', endTime: '17:00', role: 'Server' },
|
||||
{ staffId: '1', staffName: 'Mike Chen', day: 'Thursday', startTime: '09:00', endTime: '17:00', role: 'Server' },
|
||||
{ staffId: '2', staffName: 'Lisa Park', day: 'Monday', startTime: '17:00', endTime: '23:00', role: 'Server' },
|
||||
{ staffId: '2', staffName: 'Lisa Park', day: 'Wednesday', startTime: '17:00', endTime: '23:00', role: 'Server' },
|
||||
{ staffId: '2', staffName: 'Lisa Park', day: 'Friday', startTime: '17:00', endTime: '23:00', role: 'Server' },
|
||||
{ staffId: '2', staffName: 'Lisa Park', day: 'Saturday', startTime: '17:00', endTime: '23:00', role: 'Server' },
|
||||
{ staffId: '3', staffName: 'Tom Wilson', day: 'Tuesday', startTime: '18:00', endTime: '02:00', role: 'Bartender' },
|
||||
{ staffId: '3', staffName: 'Tom Wilson', day: 'Friday', startTime: '18:00', endTime: '02:00', role: 'Bartender' },
|
||||
{ staffId: '3', staffName: 'Tom Wilson', day: 'Saturday', startTime: '18:00', endTime: '02:00', role: 'Bartender' },
|
||||
{ staffId: '4', staffName: 'Emma Davis', day: 'Monday', startTime: '06:00', endTime: '14:00', role: 'Chef' },
|
||||
{ staffId: '4', staffName: 'Emma Davis', day: 'Tuesday', startTime: '06:00', endTime: '14:00', role: 'Chef' },
|
||||
{ staffId: '4', staffName: 'Emma Davis', day: 'Wednesday', startTime: '06:00', endTime: '14:00', role: 'Chef' },
|
||||
{ staffId: '4', staffName: 'Emma Davis', day: 'Thursday', startTime: '06:00', endTime: '14:00', role: 'Chef' },
|
||||
{ staffId: '4', staffName: 'Emma Davis', day: 'Friday', startTime: '06:00', endTime: '14:00', role: 'Chef' },
|
||||
{ staffId: '5', staffName: 'David Kim', day: 'Wednesday', startTime: '11:00', endTime: '19:00', role: 'Host' },
|
||||
{ staffId: '5', staffName: 'David Kim', day: 'Thursday', startTime: '11:00', endTime: '19:00', role: 'Host' },
|
||||
{ staffId: '5', staffName: 'David Kim', day: 'Friday', startTime: '11:00', endTime: '19:00', role: 'Host' },
|
||||
{ staffId: '5', staffName: 'David Kim', day: 'Saturday', startTime: '11:00', endTime: '19:00', role: 'Host' },
|
||||
{ staffId: '5', staffName: 'David Kim', day: 'Sunday', startTime: '11:00', endTime: '19:00', role: 'Host' },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const staff = Array.from(new Set(shifts.map(s => s.staffName))).sort();
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
Server: '#3b82f6',
|
||||
Bartender: '#8b5cf6',
|
||||
Chef: '#ef4444',
|
||||
Host: '#10b981',
|
||||
Busser: '#fbbf24',
|
||||
};
|
||||
return colors[role] || '#6b7280';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>📋 Staff Schedule</h1>
|
||||
<p>Weekly schedule grid</p>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="date"
|
||||
className="date-input"
|
||||
value={weekStart}
|
||||
onChange={(e) => setWeekStart(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary">+ Add Shift</button>
|
||||
</div>
|
||||
|
||||
<div className="schedule-container">
|
||||
<table className="schedule-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="staff-column">Staff Member</th>
|
||||
{days.map((day) => (
|
||||
<th key={day}>{day}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map((staffName) => {
|
||||
const staffShifts = shifts.filter(s => s.staffName === staffName);
|
||||
const role = staffShifts[0]?.role || '';
|
||||
|
||||
return (
|
||||
<tr key={staffName}>
|
||||
<td className="staff-column">
|
||||
<div className="staff-info">
|
||||
<span className="staff-name">{staffName}</span>
|
||||
<span
|
||||
className="role-badge"
|
||||
style={{ backgroundColor: getRoleBadgeColor(role) }}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{days.map((day) => {
|
||||
const shift = staffShifts.find(s => s.day === day);
|
||||
return (
|
||||
<td key={day} className="shift-cell">
|
||||
{shift ? (
|
||||
<div className="shift-block">
|
||||
<div className="shift-time">
|
||||
{shift.startTime} - {shift.endTime}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-shift">—</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="summary">
|
||||
<h3>Weekly Summary</h3>
|
||||
<div className="summary-stats">
|
||||
<div className="summary-stat">
|
||||
<span className="stat-label">Total Shifts</span>
|
||||
<span className="stat-value">{shifts.length}</span>
|
||||
</div>
|
||||
<div className="summary-stat">
|
||||
<span className="stat-label">Staff Members</span>
|
||||
<span className="stat-value">{staff.length}</span>
|
||||
</div>
|
||||
<div className="summary-stat">
|
||||
<span className="stat-label">Avg Shifts/Person</span>
|
||||
<span className="stat-value">{(shifts.length / staff.length).toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Staff Schedule - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
230
servers/touchbistro/src/ui/react-app/staff-schedule/styles.css
Normal file
230
servers/touchbistro/src/ui/react-app/staff-schedule/styles.css
Normal file
@ -0,0 +1,230 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.schedule-container {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.schedule-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.schedule-table thead {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.schedule-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
.schedule-table th.staff-column {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.schedule-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
border-right: 1px solid #334155;
|
||||
}
|
||||
|
||||
.schedule-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.schedule-table tbody tr:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.schedule-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.staff-column {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.staff-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.staff-name {
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.shift-cell {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.shift-block {
|
||||
background: #3b82f6;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.shift-block:hover {
|
||||
background: #2563eb;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.shift-time {
|
||||
color: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.no-shift {
|
||||
color: #475569;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.summary h3 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
background: #0f172a;
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-stat .stat-label {
|
||||
display: block;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-stat .stat-value {
|
||||
display: block;
|
||||
color: #f8fafc;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.schedule-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.schedule-table {
|
||||
min-width: 1200px;
|
||||
}
|
||||
}
|
||||
135
servers/touchbistro/src/ui/react-app/table-map/App.tsx
Normal file
135
servers/touchbistro/src/ui/react-app/table-map/App.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
interface Table {
|
||||
id: string;
|
||||
number: string;
|
||||
status: 'available' | 'occupied' | 'reserved' | 'cleaning';
|
||||
seats: number;
|
||||
server?: string;
|
||||
guestCount?: number;
|
||||
seatedAt?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export default function TableMap() {
|
||||
const [tables, setTables] = useState<Table[]>([]);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data - positioned in grid layout
|
||||
setTables([
|
||||
{ id: '1', number: '1', status: 'occupied', seats: 4, server: 'Mike', guestCount: 4, seatedAt: '12:30 PM', x: 50, y: 50 },
|
||||
{ id: '2', number: '2', status: 'occupied', seats: 2, server: 'Lisa', guestCount: 2, seatedAt: '12:45 PM', x: 200, y: 50 },
|
||||
{ id: '3', number: '3', status: 'available', seats: 6, x: 350, y: 50 },
|
||||
{ id: '4', number: '4', status: 'reserved', seats: 4, x: 500, y: 50 },
|
||||
{ id: '5', number: '5', status: 'occupied', seats: 2, server: 'Tom', guestCount: 2, seatedAt: '1:00 PM', x: 50, y: 200 },
|
||||
{ id: '6', number: '6', status: 'available', seats: 4, x: 200, y: 200 },
|
||||
{ id: '7', number: '7', status: 'cleaning', seats: 4, x: 350, y: 200 },
|
||||
{ id: '8', number: '8', status: 'occupied', seats: 8, server: 'Mike', guestCount: 6, seatedAt: '12:15 PM', x: 500, y: 200 },
|
||||
{ id: '9', number: '9', status: 'available', seats: 2, x: 50, y: 350 },
|
||||
{ id: '10', number: '10', status: 'reserved', seats: 6, x: 200, y: 350 },
|
||||
{ id: '11', number: '11', status: 'occupied', seats: 4, server: 'Lisa', guestCount: 3, seatedAt: '1:15 PM', x: 350, y: 350 },
|
||||
{ id: '12', number: '12', status: 'available', seats: 2, x: 500, y: 350 },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const statusColors = {
|
||||
available: '#10b981',
|
||||
occupied: '#ef4444',
|
||||
reserved: '#fbbf24',
|
||||
cleaning: '#6b7280',
|
||||
};
|
||||
|
||||
const statusCounts = {
|
||||
available: tables.filter(t => t.status === 'available').length,
|
||||
occupied: tables.filter(t => t.status === 'occupied').length,
|
||||
reserved: tables.filter(t => t.status === 'reserved').length,
|
||||
cleaning: tables.filter(t => t.status === 'cleaning').length,
|
||||
};
|
||||
|
||||
const filteredTables = filter === 'all' ? tables : tables.filter(t => t.status === filter);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>🗺️ Table Map</h1>
|
||||
<p>Floor plan with live table status</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-bar">
|
||||
<div className="stat-item available">
|
||||
<span className="stat-value">{statusCounts.available}</span>
|
||||
<span className="stat-label">Available</span>
|
||||
</div>
|
||||
<div className="stat-item occupied">
|
||||
<span className="stat-value">{statusCounts.occupied}</span>
|
||||
<span className="stat-label">Occupied</span>
|
||||
</div>
|
||||
<div className="stat-item reserved">
|
||||
<span className="stat-value">{statusCounts.reserved}</span>
|
||||
<span className="stat-label">Reserved</span>
|
||||
</div>
|
||||
<div className="stat-item cleaning">
|
||||
<span className="stat-value">{statusCounts.cleaning}</span>
|
||||
<span className="stat-label">Cleaning</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
{['all', 'available', 'occupied', 'reserved', 'cleaning'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={`filter-btn ${filter === status ? 'active' : ''}`}
|
||||
onClick={() => setFilter(status)}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="floor-plan">
|
||||
{filteredTables.map((table) => (
|
||||
<div
|
||||
key={table.id}
|
||||
className="table"
|
||||
style={{
|
||||
left: `${table.x}px`,
|
||||
top: `${table.y}px`,
|
||||
borderColor: statusColors[table.status],
|
||||
}}
|
||||
>
|
||||
<div className="table-number">{table.number}</div>
|
||||
<div className="table-seats">👥 {table.seats}</div>
|
||||
<div
|
||||
className="table-status"
|
||||
style={{ backgroundColor: statusColors[table.status] }}
|
||||
>
|
||||
{table.status}
|
||||
</div>
|
||||
{table.status === 'occupied' && (
|
||||
<div className="table-info">
|
||||
<div>Server: {table.server}</div>
|
||||
<div>Guests: {table.guestCount}</div>
|
||||
<div>{table.seatedAt}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="legend">
|
||||
<h3>Legend</h3>
|
||||
<div className="legend-items">
|
||||
{Object.entries(statusColors).map(([status, color]) => (
|
||||
<div key={status} className="legend-item">
|
||||
<div className="legend-color" style={{ backgroundColor: color }}></div>
|
||||
<span>{status.charAt(0).toUpperCase() + status.slice(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
servers/touchbistro/src/ui/react-app/table-map/index.html
Normal file
12
servers/touchbistro/src/ui/react-app/table-map/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Table Map - TouchBistro MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
servers/touchbistro/src/ui/react-app/table-map/main.tsx
Normal file
9
servers/touchbistro/src/ui/react-app/table-map/main.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
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>
|
||||
);
|
||||
208
servers/touchbistro/src/ui/react-app/table-map/styles.css
Normal file
208
servers/touchbistro/src/ui/react-app/table-map/styles.css
Normal file
@ -0,0 +1,208 @@
|
||||
* {
|
||||
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;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item.available {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.stat-item.occupied {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-item.reserved {
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
.stat-item.cleaning {
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.floor-plan {
|
||||
position: relative;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
min-height: 500px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
background: #0f172a;
|
||||
border: 3px solid;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.table:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.table-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.table-seats {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.table-status {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.legend {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.legend h3 {
|
||||
color: #f8fafc;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.legend-items {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.legend-item span {
|
||||
color: #cbd5e1;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
21
servers/touchbistro/tsconfig.json
Normal file
21
servers/touchbistro/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist", "src/ui/*/dist", "src/ui/*/node_modules"]
|
||||
}
|
||||
245
servers/wave/src/apps/CustomerManager.tsx
Normal file
245
servers/wave/src/apps/CustomerManager.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface CustomerManagerProps {
|
||||
onToolCall: (tool: string, args: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export function CustomerManager({ onToolCall }: CustomerManagerProps) {
|
||||
const [customers, setCustomers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
website: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomers();
|
||||
}, []);
|
||||
|
||||
async function loadCustomers() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await onToolCall('wave_list_customers', { pageSize: 100 });
|
||||
setCustomers(data.customers || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load customers:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await onToolCall('wave_create_customer', formData);
|
||||
setFormData({ name: '', email: '', phone: '', mobile: '', website: '' });
|
||||
setShowForm(false);
|
||||
await loadCustomers();
|
||||
} catch (error) {
|
||||
console.error('Failed to create customer:', error);
|
||||
alert('Failed to create customer');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#e0e0e0',
|
||||
minHeight: '100vh',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '700', margin: '0 0 4px 0' }}>
|
||||
Customers
|
||||
</h1>
|
||||
<p style={{ color: '#888', margin: 0 }}>
|
||||
Manage your customer database
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4a9eff',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{showForm ? 'Cancel' : '+ New Customer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div style={{
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
border: '1px solid #404040',
|
||||
}}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', marginBottom: '20px' }}>
|
||||
Create New Customer
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<InputField
|
||||
label="Customer Name"
|
||||
value={formData.name}
|
||||
onChange={(v) => setFormData({ ...formData, name: v })}
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(v) => setFormData({ ...formData, email: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Phone"
|
||||
value={formData.phone}
|
||||
onChange={(v) => setFormData({ ...formData, phone: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Mobile"
|
||||
value={formData.mobile}
|
||||
onChange={(v) => setFormData({ ...formData, mobile: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Website"
|
||||
value={formData.website}
|
||||
onChange={(v) => setFormData({ ...formData, website: v })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
padding: '10px 24px',
|
||||
backgroundColor: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Create Customer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
border: '1px solid #404040',
|
||||
}}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>Loading...</div>
|
||||
) : customers.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
|
||||
No customers found. Create your first customer!
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #404040' }}>
|
||||
<th style={headerStyle}>Name</th>
|
||||
<th style={headerStyle}>Email</th>
|
||||
<th style={headerStyle}>Phone</th>
|
||||
<th style={headerStyle}>Currency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map(customer => (
|
||||
<tr key={customer.id} style={{ borderBottom: '1px solid #333' }}>
|
||||
<td style={cellStyle}>{customer.name}</td>
|
||||
<td style={cellStyle}>{customer.email || '-'}</td>
|
||||
<td style={cellStyle}>{customer.phone || customer.mobile || '-'}</td>
|
||||
<td style={cellStyle}>{customer.currency?.code || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
required = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{label} {required && <span style={{ color: '#ef4444' }}>*</span>}
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={required}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #404040',
|
||||
borderRadius: '6px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '12px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#888',
|
||||
textTransform: 'uppercase',
|
||||
};
|
||||
|
||||
const cellStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
color: '#e0e0e0',
|
||||
};
|
||||
302
servers/wave/src/apps/Dashboard.tsx
Normal file
302
servers/wave/src/apps/Dashboard.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface DashboardProps {
|
||||
onToolCall: (tool: string, args: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export function Dashboard({ onToolCall }: DashboardProps) {
|
||||
const [businesses, setBusinesses] = useState<any[]>([]);
|
||||
const [selectedBusiness, setSelectedBusiness] = useState<any>(null);
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({
|
||||
totalInvoices: 0,
|
||||
paidInvoices: 0,
|
||||
overdueInvoices: 0,
|
||||
totalRevenue: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const bizData = await onToolCall('wave_list_businesses', {});
|
||||
setBusinesses(bizData);
|
||||
if (bizData.length > 0) {
|
||||
setSelectedBusiness(bizData[0]);
|
||||
await loadBusinessData(bizData[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function loadBusinessData(businessId: string) {
|
||||
try {
|
||||
const invoiceData = await onToolCall('wave_list_invoices', {
|
||||
businessId,
|
||||
pageSize: 100
|
||||
});
|
||||
setInvoices(invoiceData.invoices || []);
|
||||
|
||||
// Calculate stats
|
||||
const total = invoiceData.invoices?.length || 0;
|
||||
const paid = invoiceData.invoices?.filter((i: any) => i.status === 'PAID').length || 0;
|
||||
const overdue = invoiceData.invoices?.filter((i: any) => i.status === 'OVERDUE').length || 0;
|
||||
const revenue = invoiceData.invoices
|
||||
?.filter((i: any) => i.status === 'PAID')
|
||||
.reduce((sum: number, i: any) => sum + parseFloat(i.total?.value || 0), 0) || 0;
|
||||
|
||||
setStats({
|
||||
totalInvoices: total,
|
||||
paidInvoices: paid,
|
||||
overdueInvoices: overdue,
|
||||
totalRevenue: revenue,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load business data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#e0e0e0',
|
||||
minHeight: '100vh',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<div style={{ fontSize: '18px' }}>Loading Wave dashboard...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#e0e0e0',
|
||||
minHeight: '100vh',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '700',
|
||||
margin: '0 0 8px 0',
|
||||
color: '#ffffff',
|
||||
}}>
|
||||
Wave Accounting Dashboard
|
||||
</h1>
|
||||
<p style={{ color: '#888', margin: 0 }}>
|
||||
Overview of your business financials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Business Selector */}
|
||||
{businesses.length > 1 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
Select Business
|
||||
</label>
|
||||
<select
|
||||
value={selectedBusiness?.id || ''}
|
||||
onChange={(e) => {
|
||||
const biz = businesses.find(b => b.id === e.target.value);
|
||||
setSelectedBusiness(biz);
|
||||
if (biz) loadBusinessData(biz.id);
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
border: '1px solid #404040',
|
||||
borderRadius: '6px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '14px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
}}
|
||||
>
|
||||
{businesses.map(biz => (
|
||||
<option key={biz.id} value={biz.id}>
|
||||
{biz.name} ({biz.currency.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedBusiness && (
|
||||
<>
|
||||
{/* Stats Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px',
|
||||
}}>
|
||||
<StatCard
|
||||
title="Total Invoices"
|
||||
value={stats.totalInvoices.toString()}
|
||||
color="#4a9eff"
|
||||
/>
|
||||
<StatCard
|
||||
title="Paid Invoices"
|
||||
value={stats.paidInvoices.toString()}
|
||||
color="#22c55e"
|
||||
/>
|
||||
<StatCard
|
||||
title="Overdue"
|
||||
value={stats.overdueInvoices.toString()}
|
||||
color="#ef4444"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Revenue"
|
||||
value={`${selectedBusiness.currency.symbol}${stats.totalRevenue.toLocaleString()}`}
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Invoices */}
|
||||
<div style={{
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
border: '1px solid #404040',
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
margin: '0 0 20px 0',
|
||||
color: '#ffffff',
|
||||
}}>
|
||||
Recent Invoices
|
||||
</h2>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<p style={{ color: '#888', margin: 0 }}>No invoices found</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #404040' }}>
|
||||
<th style={tableHeaderStyle}>Invoice #</th>
|
||||
<th style={tableHeaderStyle}>Customer</th>
|
||||
<th style={tableHeaderStyle}>Date</th>
|
||||
<th style={tableHeaderStyle}>Status</th>
|
||||
<th style={tableHeaderStyle}>Amount</th>
|
||||
<th style={tableHeaderStyle}>Amount Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.slice(0, 10).map(invoice => (
|
||||
<tr key={invoice.id} style={{ borderBottom: '1px solid #333' }}>
|
||||
<td style={tableCellStyle}>{invoice.invoiceNumber}</td>
|
||||
<td style={tableCellStyle}>{invoice.customer.name}</td>
|
||||
<td style={tableCellStyle}>
|
||||
{new Date(invoice.invoiceDate).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={tableCellStyle}>
|
||||
<StatusBadge status={invoice.status} />
|
||||
</td>
|
||||
<td style={tableCellStyle}>
|
||||
{invoice.total.currency.code} {parseFloat(invoice.total.value).toFixed(2)}
|
||||
</td>
|
||||
<td style={tableCellStyle}>
|
||||
{invoice.amountDue.currency.code} {parseFloat(invoice.amountDue.value).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ title, value, color }: { title: string; value: string; color: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
border: '1px solid #404040',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#888',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: color,
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
DRAFT: '#6b7280',
|
||||
SAVED: '#3b82f6',
|
||||
SENT: '#8b5cf6',
|
||||
VIEWED: '#06b6d4',
|
||||
PAID: '#22c55e',
|
||||
PARTIAL: '#f59e0b',
|
||||
OVERDUE: '#ef4444',
|
||||
UNPAID: '#f59e0b',
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
backgroundColor: `${colors[status] || '#6b7280'}20`,
|
||||
color: colors[status] || '#6b7280',
|
||||
}}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const tableHeaderStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '12px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#888',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
};
|
||||
|
||||
const tableCellStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
color: '#e0e0e0',
|
||||
};
|
||||
190
servers/wave/src/apps/InvoiceManager.tsx
Normal file
190
servers/wave/src/apps/InvoiceManager.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface InvoiceManagerProps {
|
||||
onToolCall: (tool: string, args: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export function InvoiceManager({ onToolCall }: InvoiceManagerProps) {
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>('ALL');
|
||||
|
||||
useEffect(() => {
|
||||
loadInvoices();
|
||||
}, []);
|
||||
|
||||
async function loadInvoices() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await onToolCall('wave_list_invoices', { pageSize: 100 });
|
||||
setInvoices(data.invoices || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load invoices:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const filteredInvoices = filter === 'ALL'
|
||||
? invoices
|
||||
: invoices.filter(inv => inv.status === filter);
|
||||
|
||||
const statusCounts = {
|
||||
ALL: invoices.length,
|
||||
DRAFT: invoices.filter(i => i.status === 'DRAFT').length,
|
||||
SENT: invoices.filter(i => i.status === 'SENT').length,
|
||||
PAID: invoices.filter(i => i.status === 'PAID').length,
|
||||
OVERDUE: invoices.filter(i => i.status === 'OVERDUE').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#e0e0e0',
|
||||
minHeight: '100vh',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: '700', marginBottom: '24px' }}>
|
||||
Invoices
|
||||
</h1>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
marginBottom: '24px',
|
||||
borderBottom: '1px solid #404040',
|
||||
}}>
|
||||
{(['ALL', 'DRAFT', 'SENT', 'PAID', 'OVERDUE'] as const).map(status => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: filter === status ? '2px solid #4a9eff' : '2px solid transparent',
|
||||
color: filter === status ? '#4a9eff' : '#888',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{status} ({statusCounts[status]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
border: '1px solid #404040',
|
||||
}}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>Loading...</div>
|
||||
) : filteredInvoices.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
|
||||
No {filter.toLowerCase()} invoices found
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #404040' }}>
|
||||
<th style={headerStyle}>Invoice #</th>
|
||||
<th style={headerStyle}>Customer</th>
|
||||
<th style={headerStyle}>Date</th>
|
||||
<th style={headerStyle}>Due Date</th>
|
||||
<th style={headerStyle}>Status</th>
|
||||
<th style={headerStyle}>Total</th>
|
||||
<th style={headerStyle}>Amount Due</th>
|
||||
<th style={headerStyle}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredInvoices.map(invoice => (
|
||||
<tr key={invoice.id} style={{ borderBottom: '1px solid #333' }}>
|
||||
<td style={cellStyle}>{invoice.invoiceNumber}</td>
|
||||
<td style={cellStyle}>{invoice.customer.name}</td>
|
||||
<td style={cellStyle}>
|
||||
{new Date(invoice.invoiceDate).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={cellStyle}>
|
||||
{invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : '-'}
|
||||
</td>
|
||||
<td style={cellStyle}>
|
||||
<StatusBadge status={invoice.status} />
|
||||
</td>
|
||||
<td style={cellStyle}>
|
||||
{invoice.total.currency.code} {parseFloat(invoice.total.value).toFixed(2)}
|
||||
</td>
|
||||
<td style={cellStyle}>
|
||||
{invoice.amountDue.currency.code} {parseFloat(invoice.amountDue.value).toFixed(2)}
|
||||
</td>
|
||||
<td style={cellStyle}>
|
||||
{invoice.viewUrl && (
|
||||
<a
|
||||
href={invoice.viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#4a9eff', textDecoration: 'none' }}
|
||||
>
|
||||
View
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
DRAFT: '#6b7280',
|
||||
SAVED: '#3b82f6',
|
||||
SENT: '#8b5cf6',
|
||||
VIEWED: '#06b6d4',
|
||||
PAID: '#22c55e',
|
||||
PARTIAL: '#f59e0b',
|
||||
OVERDUE: '#ef4444',
|
||||
UNPAID: '#f59e0b',
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
backgroundColor: `${colors[status] || '#6b7280'}20`,
|
||||
color: colors[status] || '#6b7280',
|
||||
}}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '12px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#888',
|
||||
textTransform: 'uppercase',
|
||||
};
|
||||
|
||||
const cellStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
color: '#e0e0e0',
|
||||
};
|
||||
44
servers/wave/src/client/wave-client.ts
Normal file
44
servers/wave/src/client/wave-client.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { GraphQLClient } from 'graphql-request';
|
||||
import type { WaveConfig } from '../types/index.js';
|
||||
|
||||
export class WaveClient {
|
||||
private client: GraphQLClient;
|
||||
private config: WaveConfig;
|
||||
|
||||
constructor(config: WaveConfig) {
|
||||
this.config = config;
|
||||
this.client = new GraphQLClient('https://gql.waveapps.com/graphql/public', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async query<T = any>(query: string, variables?: any): Promise<T> {
|
||||
try {
|
||||
return await this.client.request<T>(query, variables);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Wave API Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async mutate<T = any>(mutation: string, variables?: any): Promise<T> {
|
||||
try {
|
||||
return await this.client.request<T>(mutation, variables);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Wave API Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
getBusinessId(): string {
|
||||
if (!this.config.businessId) {
|
||||
throw new Error('Business ID is required but not configured');
|
||||
}
|
||||
return this.config.businessId;
|
||||
}
|
||||
|
||||
setBusinessId(businessId: string) {
|
||||
this.config.businessId = businessId;
|
||||
}
|
||||
}
|
||||
1833
servers/wave/src/tools/index.ts
Normal file
1833
servers/wave/src/tools/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user