added prompter, theme for canvas api, added endpoint to delete existing routes

This commit is contained in:
DarrenT~ 2025-05-08 13:39:31 +02:00
parent 5e201cc033
commit f75be32123
19 changed files with 1474 additions and 27 deletions

@ -21,7 +21,15 @@ fusero-app-boilerplate/
## Development Setup
### Option 1: Running with Docker (Recommended for Development)
### Important Note: Database Must Run in Docker
The PostgreSQL database must always run in Docker, regardless of your development setup choice. This ensures consistent database behavior across all environments.
To start the database:
```bash
docker-compose -f docker-compose.dev.yml up db
```
### Option 1: Running Everything in Docker (Recommended for Development)
1. **Start the Development Environment**
```bash
@ -34,7 +42,14 @@ fusero-app-boilerplate/
### Option 2: Running Services Separately (Recommended for Debugging)
For better debugging experience, you can run the frontend and backend in separate terminal windows:
For better debugging experience, you can run the frontend and backend in separate terminal windows, while keeping the database in Docker:
1. **First, ensure the database is running in Docker**
```bash
docker-compose -f docker-compose.dev.yml up db
```
2. **Then, in separate terminal windows:**
#### Terminal 1: Backend Service
```bash
@ -87,17 +102,22 @@ The frontend will be available at http://localhost:3000
## Development Best Practices
1. **Running Services Separately**
1. **Database Management**
- Always run the database in Docker
- Use `docker-compose.dev.yml` for development
- Never run PostgreSQL directly on your host machine
2. **Running Services Separately**
- For development, it's recommended to run frontend and backend in separate terminal windows
- This allows for better debugging and hot-reloading
- You can see logs from each service clearly
2. **Code Organization**
3. **Code Organization**
- Frontend code should be in the `frontend/` directory
- Backend code should be in the `backend/` directory
- Shared types and utilities should be in their respective directories
3. **Version Control**
4. **Version Control**
- Commit `package-lock.json` files
- Don't commit `.env` files
- Use meaningful commit messages
@ -122,9 +142,14 @@ The backend API is documented using Swagger/OpenAPI. After starting the backend
```
2. **Database Issues**
- Ensure PostgreSQL is running and accessible
- Ensure PostgreSQL is running in Docker
- Check database connection settings in `.env`
- Verify database migrations are up to date
- If database issues persist, try:
```bash
docker-compose -f docker-compose.dev.yml down
docker-compose -f docker-compose.dev.yml up db
```
3. **CORS Issues**
- If you see CORS errors, verify the frontend's API base URL

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@ -0,0 +1,427 @@
import { useEffect, useState, useRef } from 'react';
import { Column } from 'primereact/column';
import { FilterMatchMode } from 'primereact/api';
import TableGroupHeader from '../shared/components/_V1/TableGroupHeader';
import TableGroup from '../shared/components/_V1/TableGroup';
import { TSizeOptionValue } from '../types/DataTable.types';
import { api } from '../services/api';
import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { ProgressSpinner } from 'primereact/progressspinner';
import ChatGptModal from './ChatGPTModal';
import { Toast } from 'primereact/toast';
export default function CanvasEndpoints() {
const [endpoints, setEndpoints] = useState<any[]>([]);
const [filters, setFilters] = useState<any>(null);
const [globalFilterValue, setGlobalFilterValue] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '' });
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
{ label: 'Small', value: 'small' },
{ label: 'Normal', value: 'normal' },
{ label: 'Large', value: 'large' },
];
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
const [showCallModal, setShowCallModal] = useState(false);
const [callModalData, setCallModalData] = useState<any[]>([]);
const [callModalColumns, setCallModalColumns] = useState<string[]>([]);
const [callModalLoading, setCallModalLoading] = useState(false);
const [callEndpoint, setCallEndpoint] = useState<any>(null);
const [callModalFilters, setCallModalFilters] = useState<any>(null);
const [callModalGlobalFilterValue, setCallModalGlobalFilterValue] = useState('');
const [callModalSize, setCallModalSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
const [callModalFirst, setCallModalFirst] = useState(0);
const [callModalRows, setCallModalRows] = useState(10);
const [showChatGptModal, setShowChatGptModal] = useState(false);
const [chatPrompt, setChatPrompt] = useState('');
const [chatMessages, setChatMessages] = useState<{ role: 'user' | 'assistant'; content: string }[]>([]);
const [chatLoading, setChatLoading] = useState(false);
const [chatError, setChatError] = useState<string | null>(null);
const toast = useRef(null);
useEffect(() => {
initFilters();
fetchEndpoints();
}, []);
const fetchEndpoints = async () => {
try {
const response = await api('get', '/api/v1/canvas-api/endpoints');
setEndpoints(response.data);
} catch (error) {
console.error('Failed to fetch endpoints:', error);
}
};
const initFilters = () => {
setFilters({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
});
setGlobalFilterValue('');
};
const clearFilter = () => {
initFilters();
};
const onGlobalFilterChange = (e: { target: { value: any } }) => {
const value = e.target.value;
let _filters = { ...filters };
_filters['global'].value = value;
setFilters(_filters);
setGlobalFilterValue(value);
};
// Utility to ensure path never starts with /api/v1
function sanitizeCanvasPath(path: string) {
return path.replace(/^\/api\/v1/, '');
}
const handleCreateEndpoint = async () => {
try {
const sanitizedEndpoint = { ...newEndpoint, path: sanitizeCanvasPath(newEndpoint.path) };
await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint);
setShowCreateModal(false);
setNewEndpoint({ name: '', method: '', path: '', description: '' });
fetchEndpoints();
} catch (error) {
console.error('Failed to create endpoint:', error);
}
};
const handleCallEndpoint = async (endpoint: any) => {
setShowCallModal(true);
setCallModalLoading(true);
setCallEndpoint(endpoint);
try {
const res = await api('post', '/api/v1/canvas-api/proxy-external', {
path: endpoint.path,
method: endpoint.method || 'GET',
});
let data = res.data;
if (!Array.isArray(data)) data = [data];
setCallModalData(data);
setCallModalColumns(data.length > 0 ? Object.keys(data[0]) : []);
} catch (error) {
setCallModalData([]);
setCallModalColumns([]);
} finally {
setCallModalLoading(false);
}
};
const initCallModalFilters = () => {
setCallModalFilters({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
});
setCallModalGlobalFilterValue('');
};
const clearCallModalFilter = () => {
initCallModalFilters();
};
const onCallModalGlobalFilterChange = (e: { target: { value: any } }) => {
const value = e.target.value;
let _filters = { ...callModalFilters };
_filters['global'].value = value;
setCallModalFilters(_filters);
setCallModalGlobalFilterValue(value);
};
const handleChatPromptSend = async () => {
if (!chatPrompt.trim()) return;
setChatLoading(true);
setChatError(null);
setChatMessages(prev => [...prev, { role: 'user', content: chatPrompt }]);
try {
const formattedRequest = {
data: chatPrompt,
};
const response = await api(
'post',
'/api/v1/canvas-api/chatgpt/completions',
formattedRequest,
undefined,
'json',
60,
true
);
const data = response.data;
let isEndpointJson = false;
if (data && data.responseText) {
let parsed;
try {
parsed = typeof data.responseText === 'string' ? JSON.parse(data.responseText) : data.responseText;
} catch (e) {
parsed = null;
}
if (parsed && parsed.name && parsed.method && parsed.path) {
// Auto-create endpoint
const sanitizedParsed = { ...parsed, path: sanitizeCanvasPath(parsed.path) };
await api('post', '/api/v1/canvas-api/endpoints', sanitizedParsed);
fetchEndpoints();
isEndpointJson = true;
if (toast.current) {
toast.current.show({ severity: 'success', summary: 'Endpoint Created', detail: `Endpoint "${parsed.name}" created successfully!` });
}
}
}
if (!isEndpointJson) {
setChatMessages(prev => [...prev, { role: 'assistant', content: typeof data.responseText === 'string' ? data.responseText : JSON.stringify(data.responseText, null, 2) }]);
}
} catch (err) {
setChatError('Failed to get response from server.');
setChatMessages(prev => [...prev, { role: 'assistant', content: 'Failed to get response from server.' }]);
} finally {
setChatLoading(false);
setChatPrompt('');
}
};
const handleChatPromptKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleChatPromptSend();
}
};
const handleDeleteEndpoint = async (endpoint: any) => {
try {
await api('delete', `/api/v1/canvas-api/endpoints/${endpoint.id}`);
fetchEndpoints();
if (toast.current) {
toast.current.show({ severity: 'success', summary: 'Endpoint Deleted', detail: `Endpoint "${endpoint.name}" deleted successfully!` });
}
} catch (error) {
if (toast.current) {
toast.current.show({ severity: 'error', summary: 'Delete Failed', detail: 'Failed to delete endpoint.' });
}
}
};
const renderHeader = () => (
<TableGroupHeader
size={size}
setSize={setSize}
sizeOptions={sizeOptions}
globalFilterValue={globalFilterValue}
onGlobalFilterChange={onGlobalFilterChange}
onRefresh={fetchEndpoints}
clearFilter={clearFilter}
extraButtonsTemplate={() => (
<div className="flex gap-2">
<Button
label="Create Endpoint"
icon="pi pi-plus"
onClick={() => setShowCreateModal(true)}
/>
<Button
label="Ask ChatGPT"
icon="pi pi-comments"
onClick={() => setShowChatGptModal(true)}
className="p-button-primary"
/>
</div>
)}
/>
);
const renderCallButton = (rowData: any) => (
<div className="flex gap-2">
<Button label="Call" icon="pi pi-play" onClick={() => handleCallEndpoint(rowData)} />
<Button label="Delete" icon="pi pi-trash" className="p-button-danger" onClick={() => handleDeleteEndpoint(rowData)} />
</div>
);
const renderCallModalHeader = () => (
<TableGroupHeader
size={callModalSize}
setSize={setCallModalSize}
sizeOptions={sizeOptions}
globalFilterValue={callModalGlobalFilterValue}
onGlobalFilterChange={onCallModalGlobalFilterChange}
onRefresh={() => handleCallEndpoint(callEndpoint)}
clearFilter={clearCallModalFilter}
/>
);
return (
<div className='w-full h-full p-6'>
<h2 className='text-xl font-semibold mb-4'>Canvas Endpoints</h2>
<Toast ref={toast} position="bottom-right" />
<TableGroup
body={endpoints}
size={size}
header={renderHeader}
filters={filters}
rows={10}
paginator
showGridlines={true}
removableSort={true}
dragSelection={false}
emptyMessage='No endpoints found.'
>
<Column field='id' header='ID' sortable filter style={{ width: '10%' }} />
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
<Column field='method' header='Method' sortable filter style={{ width: '10%' }} />
<Column field='path' header='Path' sortable filter style={{ width: '40%' }} />
<Column header='Action' body={renderCallButton} style={{ width: '20%' }} />
</TableGroup>
<Dialog
visible={showCreateModal}
onHide={() => setShowCreateModal(false)}
header="Create New Endpoint"
footer={
<div>
<Button label="Cancel" icon="pi pi-times" onClick={() => setShowCreateModal(false)} className="p-button-text" />
<Button label="Create" icon="pi pi-check" onClick={handleCreateEndpoint} autoFocus disabled={!(newEndpoint.name && newEndpoint.method && newEndpoint.path)} />
</div>
}
>
<div className="p-fluid">
<div className="p-field">
<label htmlFor="name">Name</label>
<InputText id="name" value={newEndpoint.name} onChange={(e) => setNewEndpoint({ ...newEndpoint, name: e.target.value })} />
</div>
<div className="p-field">
<label htmlFor="method">Method</label>
<Dropdown
id="method"
value={newEndpoint.method}
options={[
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' }
]}
onChange={(e) => setNewEndpoint({ ...newEndpoint, method: e.value })}
placeholder="Select a method"
/>
</div>
<div className="p-field">
<label htmlFor="path">Path</label>
<InputText id="path" value={newEndpoint.path} onChange={(e) => setNewEndpoint({ ...newEndpoint, path: e.target.value })} />
</div>
<div className="p-field">
<label htmlFor="description">Description</label>
<InputText id="description" value={newEndpoint.description} onChange={(e) => setNewEndpoint({ ...newEndpoint, description: e.target.value })} />
</div>
</div>
</Dialog>
<Dialog
visible={showCallModal}
onHide={() => setShowCallModal(false)}
header={callEndpoint ? `Response for ${callEndpoint.name}` : 'Endpoint Response'}
style={{ width: '96vw', maxWidth: 1800, minHeight: 600 }}
modal
>
{callModalLoading ? (
<div className="flex justify-center items-center" style={{ minHeight: 200 }}>
<ProgressSpinner />
</div>
) : callModalData.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', height: '70vh' }}>
<div style={{ flex: 1, overflow: 'auto', maxWidth: '100%' }}>
<TableGroup
body={callModalData.slice(callModalFirst, callModalFirst + callModalRows)}
size={callModalSize}
header={renderCallModalHeader}
filters={callModalFilters}
rows={callModalRows}
paginator={false}
showGridlines={true}
removableSort={true}
dragSelection={false}
emptyMessage='No data found.'
>
{callModalColumns.map((col) => (
<Column
key={col}
field={col}
header={col}
sortable
filter
body={rowData => {
const value = rowData[col];
if (typeof value === 'object' && value !== null) {
return <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{JSON.stringify(value)}</span>;
}
return value;
}}
/>
))}
</TableGroup>
</div>
<div style={{ position: 'sticky', bottom: 0, background: '#fff', padding: '8px 0 0 0', zIndex: 2, borderTop: '1px solid #eee' }}>
<div style={{ width: '100%' }}>
<span style={{ float: 'right' }}>
<span style={{ marginRight: 16 }}>
Showing {callModalFirst + 1} to {Math.min(callModalFirst + callModalRows, callModalData.length)} of {callModalData.length} entries
</span>
<select
value={callModalRows}
onChange={e => setCallModalRows(Number(e.target.value))}
style={{ marginRight: 8 }}
>
{[10, 25, 50].map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
<button onClick={() => setCallModalFirst(0)} disabled={callModalFirst === 0}>{'<<'}</button>
<button onClick={() => setCallModalFirst(Math.max(0, callModalFirst - callModalRows))} disabled={callModalFirst === 0}>{'<'}</button>
<span style={{ margin: '0 8px' }}>{Math.floor(callModalFirst / callModalRows) + 1}</span>
<button onClick={() => setCallModalFirst(Math.min(callModalFirst + callModalRows, callModalData.length - callModalRows))} disabled={callModalFirst + callModalRows >= callModalData.length}>{'>'}</button>
<button onClick={() => setCallModalFirst(Math.max(0, callModalData.length - callModalRows))} disabled={callModalFirst + callModalRows >= callModalData.length}>{'>>'}</button>
</span>
</div>
</div>
</div>
) : (
<div>No data found or error.</div>
)}
</Dialog>
<Dialog
visible={showChatGptModal}
onHide={() => setShowChatGptModal(false)}
header="ChatGPT Assistant"
style={{ width: '50vw' }}
>
<div className="flex flex-col h-[60vh]">
<div className="flex-1 overflow-auto mb-4 p-2 bg-gray-50 rounded">
{chatMessages.length === 0 && <div className="text-gray-400 text-center mt-8">Start a conversation with ChatGPT...</div>}
{chatMessages.map((msg, idx) => (
<div key={idx} className={`my-2 p-2 rounded-lg max-w-[80%] ${msg.role === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-200'}`}>
<span>{msg.content}</span>
</div>
))}
</div>
<div className="flex gap-2 mt-auto">
<input
type="text"
className="flex-1 p-2 border rounded"
placeholder="Type your message..."
value={chatPrompt}
onChange={e => setChatPrompt(e.target.value)}
onKeyDown={handleChatPromptKeyDown}
disabled={chatLoading}
/>
<Button
icon="pi pi-send"
onClick={handleChatPromptSend}
loading={chatLoading}
disabled={!chatPrompt.trim() || chatLoading}
/>
</div>
{chatError && <div className="text-red-500 mt-2">{chatError}</div>}
</div>
</Dialog>
</div>
);
}

@ -0,0 +1,372 @@
import React from 'react';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Card } from 'primereact/card';
interface CanvasData {
[key: string]: any;
}
interface CanvasDataTableProps {
data: CanvasData[];
loading: boolean;
error: string | null;
}
const CanvasDataTable: React.FC<CanvasDataTableProps> = ({
data,
loading,
errorimport { Column } from 'primereact/column';
import { FilterMatchMode, FilterOperator } from 'primereact/api';
import { useEffect, useRef, useState } from 'react';
import { api } from '../../services/api.ts';
import TableGroupHeader from '../../shared/components/_V1/TableGroupHeader.tsx';
import TableGroup from '../../shared/components/_V1/TableGroup.tsx';
import { Toast } from 'primereact/toast';
import ConfirmModal from '../../shared/components/modals/ConfirmModal.tsx';
import CreateVoucherModal from './CreateVoucherModal.tsx';
// import EditVoucherModal from './AddVoucherCodeModal.tsx';
import LoadingPage from '../../shared/components/_V1/LoadingPage.tsx';
import DetailModal from './DetailModal.tsx';
import AddVoucherCodeModal, { AddVoucherCodeModalProps } from './AddVoucherCodeModal.tsx';
import { Voucher, useVoucherStore } from '../../state/stores/Mews/useMewsVoucherStore.ts';
import { TSizeOptionValue } from '../../types/DataTable.types.ts';
import Button from '../../shared/components/_V2/Button.tsx';
export default function VoucherTableGroup() {
const { createVoucher, fetchVouchers, updateVoucher, vouchers, addVoucherCode } =
useVoucherStore();
const [filters, setFilters] = useState(null);
const [globalFilterValue, setGlobalFilterValue] = useState('');
const [showCreateVoucherModal, setShowCreateVoucherModal] = useState(false);
const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false);
const [showAddCodeModal, setShowAddCodeModal] = useState(false);
const [currentVoucherForCode, setCurrentVoucherForCode] = useState(null);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [detailContent, setDetailContent] = useState([]);
const [detailTitle, setDetailTitle] = useState('');
const [selectedVouchers, setSelectedVouchers] = useState<Voucher[]>(null);
const toast = useRef<Toast>(null);
const [isLoading, setIsLoading] = useState(false);
// Change table row size
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
{ label: 'Small', value: 'small' },
{ label: 'Normal', value: 'normal' },
{ label: 'Large', value: 'large' },
];
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
useEffect(() => {
initFilters();
// fetchVouchers;
handleRefresh();
}, []);
const showToastMessage = (options) => {
toast.current?.show(options);
};
const initFilters = () => {
setFilters({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
representative: { value: null, matchMode: FilterMatchMode.IN },
createdAt: {
operator: FilterOperator.AND,
constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }],
},
});
setGlobalFilterValue('');
};
const clearFilter = () => {
initFilters();
};
const onGlobalFilterChange = (e: { target: { value: any } }) => {
const value = e.target.value;
// @ts-ignore
let _filters = { ...filters };
_filters['global'].value = value;
setFilters(_filters);
setGlobalFilterValue(value);
};
// TODO: refresh data
const handleRefresh = async () => {
setIsLoading(true);
await fetchVouchers();
setIsLoading(false);
};
const formatDate = (value: string | number | Date) => {
const date = new Date(value);
return date.toLocaleDateString('en-US', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const dateBodyTemplate = (rowData: { createdAt: string | number | Date }) => {
return formatDate(rowData.createdAt);
};
const actionButtons = () => {
return (
<div className={'flex gap-3'}>
<Button text={true} onClick={() => setShowCreateVoucherModal(true)}>
Add Voucher
</Button>
{/* <Button
onClick={() => setShowConfirmDeleteModal(true)}
severity={'danger'}
disabled={selectedVouchers == null || selectedVouchers.length === 0}
>
Delete selected
</Button> */}
</div>
);
};
const editButton = (rowData: Voucher) => {
return <Button>action</Button>;
};
const openDetailModal = (content, title) => {
setDetailContent(content);
setDetailTitle(title);
setDetailModalVisible(true);
};
const closeDetailModal = () => {
setDetailModalVisible(false);
};
const handleCreateVoucher = async (newVoucher: Voucher) => {
await createVoucher(newVoucher);
handleRefresh();
setShowCreateVoucherModal(false);
};
const handleDeleteSelectedVouchers = () => {
const URL = '/neppeURL';
const body = selectedVouchers.map((voucher) => {
return {
id: voucher.id,
};
});
api('delete', URL, body);
};
const voucherRatesBodyTemplate = (rowData) => {
if (rowData?.voucherCodes.length < 1) {
return '';
}
const details = rowData.rates.map((rate) => `Name: ${rate.name}, Type: ${rate.type}`);
return (
<Button
label='View Rates'
onClick={() => openDetailModal(details, 'Voucher Rates')}
disabled={isLoading}
/>
);
};
const voucherCodesBodyTemplate = (rowData) => {
const details = rowData.voucherCodes.join(', ');
return (
<div className='flex gap-2'>
<Button
label='View Codes'
onClick={() => openDetailModal([details], 'Voucher Codes')}
disabled={isLoading || rowData.voucherCodes.length === 0}
/>
<Button
label='Add Code'
onClick={() => {
setCurrentVoucherForCode(rowData);
setShowAddCodeModal(true);
}}
disabled={isLoading}
/>
</div>
);
};
const handleAddVoucherCode: AddVoucherCodeModalProps['addVoucherCode'] = async (
voucherId,
code
) => {
try {
await addVoucherCode(voucherId, code);
showToastMessage({
severity: 'success',
summary: 'Code Added',
detail: 'Voucher code has been successfully added.',
});
setShowAddCodeModal(false);
handleRefresh();
} catch (error) {
showToastMessage({
severity: 'error',
summary: 'Error',
detail: 'Failed to add voucher code.',
});
console.error('Failed to add voucher code:', error);
}
};
const renderHeader = () => {
return (
<TableGroupHeader
size={size}
setSize={setSize}
sizeOptions={sizeOptions}
globalFilterValue={globalFilterValue}
onGlobalFilterChange={(e: any) => onGlobalFilterChange(e)}
onRefresh={handleRefresh}
clearFilter={clearFilter}
/>
);
};
const voucherCodeSortFunction = (e) => {
const result = e.data.sort((data1, data2) => {
const codes1 = data1.voucherCodes.length;
const codes2 = data2.voucherCodes.length;
return e.order * (codes1 - codes2);
});
return result;
};
return (
<div className='w-full h-full flex flex-col'>
<CreateVoucherModal
visible={showCreateVoucherModal}
onHide={() => setShowCreateVoucherModal(false)}
// @ts-ignore
onConfirm={handleCreateVoucher}
/>
<AddVoucherCodeModal
visible={showAddCodeModal}
onHide={() => setShowAddCodeModal(false)}
addVoucherCode={handleAddVoucherCode} // Pass the function directly
voucherId={currentVoucherForCode?.id || ''}
/>
<ConfirmModal
visible={showConfirmDeleteModal}
header={`Delete ${selectedVouchers?.length} voucher(s)`}
message={`Are you sure?`}
onHide={() => setShowConfirmDeleteModal(false)}
onConfirm={handleDeleteSelectedVouchers}
confirmLabel={'Confirm'}
cancelLabel={'Cancel'}
onCancel={() => setShowConfirmDeleteModal(false)}
/>
<DetailModal
visible={detailModalVisible}
onHide={closeDetailModal}
title={detailTitle}
details={detailContent}
/>
{isLoading ? (
<LoadingPage />
) : (
<TableGroup
body={vouchers}
size={size}
header={renderHeader}
filters={filters}
rows={10}
paginator
// stripedRows={true}
showGridlines={true}
removableSort={true}
dragSelection={false}
selectionMode={'multiple'}
selection={selectedVouchers}
onSelectionChange={(e: any) => setSelectedVouchers(e.value)}
emptyMessage='No vouchers found.'
>
<Column selectionMode='multiple' headerStyle={{ width: '3rem' }}></Column>
<Column field='id' header={actionButtons} sortable filter style={{ width: '40%' }} />
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
<Column field='type' header='Type' sortable filter style={{ width: '20%' }} />
<Column
field='voucherCodes'
header='Voucher Codes'
body={voucherCodesBodyTemplate}
sortable
filter
sortFunction={voucherCodeSortFunction}
className='h-full align-top bg-red'
style={{ width: '20%', textAlign: 'center', lineHeight: '20px' }}
/>
<Column
field='rates'
header='Voucher Rates'
body={voucherRatesBodyTemplate}
sortable
filter
style={{ width: '20%' }}
/>
{/* <Column
field='actions'
header='Actions'
body={editButton}
style={{ width: '15%', textAlign: 'center' }}
/> */}
</TableGroup>
)}
</div>
);
}
}) => {
// Dynamically generate columns based on the first data item
const columns = data.length > 0
? Object.keys(data[0]).map(key => (
<Column
key={key}
field={key}
header={key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ')}
sortable
/>
))
: [];
return (
<Card className="flex-grow mb-4">
<DataTable
value={data}
loading={loading}
emptyMessage={error || "No data found. Try using the prompt below."}
className="p-datatable-sm"
paginator
rows={10}
rowsPerPageOptions={[10, 25, 50]}
>
{columns}
</DataTable>
</Card>
);
};
export default CanvasDataTable;

@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { Button } from 'primereact/button';
import { ScrollPanel } from 'primereact/scrollpanel';
interface Message {
role: 'user' | 'assistant';
content: string;
}
interface ChatGptModalProps {
visible: boolean;
onHide: () => void;
}
const ChatGptModal: React.FC<ChatGptModalProps> = ({ visible, onHide }) => {
const [prompt, setPrompt] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!prompt.trim()) return;
setLoading(true);
const userMessage: Message = { role: 'user', content: prompt };
setMessages(prev => [...prev, userMessage]);
try {
const response = await fetch('/canvas-api/chatgpt/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: prompt,
}),
});
const data = await response.json();
if (data.errorMessage) {
throw new Error(data.errorMessage);
}
const assistantMessage: Message = {
role: 'assistant',
content: typeof data.responseText === 'string'
? data.responseText
: JSON.stringify(data.responseText, null, 2)
};
setMessages(prev => [...prev, assistantMessage]);
} catch (error) {
console.error('Error:', error);
const errorMessage: Message = {
role: 'assistant',
content: `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setLoading(false);
setPrompt('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<Dialog
visible={visible}
onHide={onHide}
header="ChatGPT Assistant"
style={{ width: '50vw' }}
className="chatgpt-modal"
>
<div className="flex flex-col h-[60vh]">
<ScrollPanel style={{ width: '100%', height: 'calc(100% - 60px)' }} className="mb-4">
<div className="flex flex-col gap-4 p-4">
{messages.map((message, index) => (
<div
key={index}
className={`p-3 rounded-lg max-w-[80%] ${message.role === 'user'
? 'bg-blue-100 ml-auto'
: 'bg-gray-100'
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
))}
</div>
</ScrollPanel>
<div className="flex gap-2 mt-auto">
<InputText
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
className="flex-1"
disabled={loading}
/>
<Button
icon="pi pi-send"
onClick={handleSubmit}
loading={loading}
disabled={!prompt.trim() || loading}
/>
</div>
</div>
</Dialog>
);
};
export default ChatGptModal;

@ -0,0 +1,37 @@
import React from 'react';
import { InputText } from 'primereact/inputtext';
import { Button } from 'primereact/button';
interface PromptInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
}
const PromptInput: React.FC<PromptInputProps> = ({
value,
onChange,
onSubmit,
placeholder = "Type your prompt here"
}) => {
return (
<div className="flex items-center gap-2 p-4 border-t">
<InputText
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-grow"
onKeyPress={(e) => e.key === 'Enter' && onSubmit()}
/>
<Button
label="Send"
icon="pi pi-send"
onClick={onSubmit}
disabled={!value.trim()}
/>
</div>
);
};
export default PromptInput;

@ -0,0 +1,33 @@
import React from 'react';
import { Dropdown } from 'primereact/dropdown';
export type SystemType = 'council' | 'fusemind' | 'canvas';
interface SystemSelectorProps {
value: SystemType | null;
onChange: (value: SystemType | null) => void;
}
const systemOptions = [
{ label: 'CouncilAI', value: 'council' },
{ label: 'FuseMind (AI)', value: 'fusemind' },
{ label: 'Canvas API', value: 'canvas' }
];
const SystemSelector: React.FC<SystemSelectorProps> = ({ value, onChange }) => {
return (
<div className="flex flex-col gap-2 p-4 border-b w-80">
<span className="font-semibold mb-1">Apps & Flows</span>
<Dropdown
value={value}
options={systemOptions}
onChange={(e) => onChange(e.value)}
placeholder="Select an integration"
className="w-full"
showClear
/>
</div>
);
};
export default SystemSelector;

@ -0,0 +1,83 @@
import { SystemType } from './SystemSelector';
interface CommandResult {
system: SystemType;
endpoint: string;
params?: Record<string, any>;
courseId?: number;
}
export const parseCommand = (prompt: string, system: SystemType): CommandResult | null => {
const lowerPrompt = prompt.toLowerCase().trim();
if (system === 'canvas') {
// Extract course ID if present (e.g., "get students in course 123")
const courseIdMatch = lowerPrompt.match(/course\s+(\d+)/);
const courseId = courseIdMatch ? parseInt(courseIdMatch[1], 10) : 1; // Default to 1 if not specified
if (lowerPrompt.includes('get all students')) {
return {
system: 'canvas',
endpoint: '/users',
params: {
enrollment_type: ['student'],
per_page: 100
},
courseId
};
}
if (lowerPrompt.includes('get courses')) {
return {
system: 'canvas',
endpoint: '/courses',
params: {
per_page: 100
}
};
}
if (lowerPrompt.includes('get assignments')) {
return {
system: 'canvas',
endpoint: '/assignments',
params: {
per_page: 100
},
courseId
};
}
} else if (system === 'council') {
if (lowerPrompt.includes('get all students')) {
return {
system: 'council',
endpoint: '/students',
params: {
limit: 100
}
};
}
if (lowerPrompt.includes('get courses')) {
return {
system: 'council',
endpoint: '/courses',
params: {
limit: 100
}
};
}
if (lowerPrompt.includes('get assignments')) {
return {
system: 'council',
endpoint: '/assignments',
params: {
limit: 100
}
};
}
}
return null;
};

@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { Button } from 'primereact/button';
import { Column } from 'primereact/column';
import { FilterMatchMode } from 'primereact/api';
import { TSizeOptionValue } from '../../types/DataTable.types';
import { api } from '../../services/api';
import ChatGptModal from './ChatGptModal';
import { DataTable } from 'primereact/datatable';
import { Card } from 'primereact/card';
const CanvasEndpoints: React.FC = () => {
const [showChatGptModal, setShowChatGptModal] = useState(false);
const [endpoints, setEndpoints] = useState<any[]>([]);
const [filters, setFilters] = useState<any>(null);
const [globalFilterValue, setGlobalFilterValue] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '' });
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
{ label: 'Small', value: 'small' },
{ label: 'Normal', value: 'normal' },
{ label: 'Large', value: 'large' },
];
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
const fetchEndpoints = async () => {
try {
const response = await api('get', '/api/v1/canvas-api/endpoints');
setEndpoints(response.data);
} catch (error) {
console.error('Failed to fetch endpoints:', error);
}
};
const initFilters = () => {
setFilters({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
});
setGlobalFilterValue('');
};
const clearFilter = () => {
initFilters();
};
const onGlobalFilterChange = (e: { target: { value: any } }) => {
const value = e.target.value;
let _filters = { ...filters };
_filters['global'].value = value;
setFilters(_filters);
setGlobalFilterValue(value);
};
return (
<div className="p-4">
<Card className="mb-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold mb-2">Canvas Endpoints</h1>
<p className="text-gray-400">Manage and test your Canvas API endpoints</p>
</div>
<div className="flex gap-2">
<Button
label="Create Endpoint"
icon="pi pi-plus"
onClick={() => setShowCreateModal(true)}
/>
<Button
label="Ask ChatGPT"
icon="pi pi-comments"
onClick={() => setShowChatGptModal(true)}
className="p-button-primary"
/>
</div>
</div>
</Card>
<DataTable value={endpoints} paginator rows={10} showGridlines responsiveLayout="scroll" emptyMessage="No endpoints found.">
<Column field="id" header="ID" sortable filter style={{ width: '10%' }} />
<Column field="name" header="Name" sortable filter style={{ width: '20%' }} />
<Column field="method" header="Method" sortable filter style={{ width: '10%' }} />
<Column field="path" header="Path" sortable filter style={{ width: '40%' }} />
</DataTable>
<ChatGptModal
visible={showChatGptModal}
onHide={() => setShowChatGptModal(false)}
/>
</div>
);
};
export default CanvasEndpoints;

@ -81,3 +81,5 @@ Rules:
</div>
);
};
export default ChatGPTModal;

@ -6,6 +6,7 @@ import Dropdown from '../shared/components/_V2/Dropdown';
import Divider from '../shared/components/_V1/Divider';
import fusero_logo from '../assets/fusero_logo.svg';
import roc_logo from '../assets/roc_logo.png';
import { useSystemStore } from '../state/stores/useSystemStore';
export type SystemDropdownOption = {
@ -47,6 +48,14 @@ const mockedSystems = [
logo: fusero_logo,
category: 'Apps',
},
{
id: 5,
label: 'Canvas API',
businessLabel: 'Canvas API',
urlSlug: 'canvas',
logo: roc_logo,
category: 'Apps',
},
];
// Add system default routes configuration

@ -0,0 +1,13 @@
interface CanvasConfig {
accessToken: string;
baseUrl: string;
defaultCourseId: number;
}
const canvasConfig: CanvasConfig = {
accessToken: process.env.VITE_CANVAS_ACCESS_TOKEN || '',
baseUrl: process.env.VITE_CANVAS_API_BASE_URL || 'https://canvas.instructure.com/api/v1',
defaultCourseId: parseInt(process.env.VITE_CANVAS_DEFAULT_COURSE_ID || '1', 10),
};
export default canvasConfig;

@ -1,6 +1,7 @@
@import 'primereact/resources/themes/saga-blue/theme.css';
@import 'primereact/resources/primereact.min.css';
@import 'primeicons/primeicons.css';
@import './styles/canvas-theme.css';
@layer tailwind-base, primereact, tailwind-utilities;
@ -62,6 +63,7 @@
.p-datatable.p-datatable-gridlines .p-datatable-header {
/* padding: 10px 8px 10px 8px !important; */
z-index: 100;
}
.p-datatable-header {
@ -78,7 +80,13 @@
padding: 0 0 18px 0;
}
.p-datatable-header > div > div > div > button {
width: 100px;
/* width: 100px; */
}
.p-datatable > .p-datatable-wrapper {
overflow: initial !important;
margin: 0 0 80px 0;
width: 100%;
}
.p-datatable .p-datatable-header {
@ -118,12 +126,6 @@ thead tr th {
margin-top: 32px;
}
.p-datatable > .p-datatable-wrapper {
overflow: initial !important;
margin: 0 0 80px 0;
width: 100%;
}
/* search bar data table */
.p-datatable-header .p-inputtext.p-component {
padding: 6px 64px;
@ -183,3 +185,11 @@ thead tr th {
text-decoration: underline;
color: blue;
}
/* Ensure custom header in .p-datatable-header fills the width */
.p-datatable-header > div {
width: 100% !important;
min-width: 0 !important;
background: #fff !important;
display: flex !important;
}

@ -57,10 +57,9 @@ const DualModalComponent = () => {
}
return (
<div className='flex items-center justify-center h-screen bg-gradient-to-br from-gray-700 to-teal-900'>
<div className='flex items-center justify-center h-screen bg-gradient-to-br from-gray-700 to-teal-900' data-system={systemId}>
<div
className={`transition-all ease-in-out duration-500 rounded ${
isExpanded
className={`transition-all ease-in-out duration-500 rounded ${isExpanded
? 'w-1/5 h-screen px-4'
: `${isLoggedIn ? 'w-1/2 h-3/4' : 'w-1/3 h-2/4'} my-12 mx-6 px-4`
} m-2 bg-white shadow-xl flex justify-center items-center px-4`}
@ -74,8 +73,7 @@ const DualModalComponent = () => {
/>
) : (
<p
className={`h-full w-full transition-opacity duration-50 ${
isExpanded ? 'opacity-0' : 'opacity-100'
className={`h-full w-full transition-opacity duration-50 ${isExpanded ? 'opacity-0' : 'opacity-100'
}`}
>
<TenantProfile />
@ -87,8 +85,7 @@ const DualModalComponent = () => {
</div>
<div
className={`relative flex-1 w-full overflow-auto transition-all ease-in-out duration-500 flex rounded ${
isExpanded ? 'w-1/3 max-w-none h-full' : 'w-1/3 h-1/2 mx-6'
className={`relative flex-1 w-full overflow-auto transition-all ease-in-out duration-500 flex rounded ${isExpanded ? 'w-1/3 max-w-none h-full' : 'w-1/3 h-1/2 mx-6'
} bg-white shadow-xl justify-center items-center ${isLoggedIn ? '' : 'hidden'}`}
>
{isLoggedIn && showOutlet && systemId ? (

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import fusero_logo from '../assets/fusero_logo.svg';
import roc_logo from '../assets/roc_logo.png';
import 'primeicons/primeicons.css';
@ -19,6 +20,12 @@ const systemConfig = {
],
logo: fusero_logo,
},
canvas: {
routes: [
{ path: 'canvas-endpoints', label: 'Canvas Endpoints', icon: 'pi pi-table' }
],
logo: roc_logo,
},
};
const Sidebar = ({ systemId, isCollapsed, setIsCollapsed }) => {

@ -4,6 +4,7 @@ import DualModalComponent from './layouts/DualModal';
import CouncilAI from './components/CouncilAI/CouncilAI';
import FuseMindHome from './components/FuseMind/FuseMindHome';
import { useParams } from 'react-router-dom';
import CanvasEndpoints from './components/CanvasEndpoints';
const FuseMindHomeWrapper = () => {
const { systemId } = useParams();
@ -30,6 +31,14 @@ const router = createBrowserRouter([
path: 'home',
element: <FuseMindHomeWrapper />,
},
{
path: 'endpoints',
element: <CanvasEndpoints />,
},
{
path: 'canvas-endpoints',
element: <CanvasEndpoints />,
},
],
},
],

@ -0,0 +1,78 @@
import { api } from './api';
interface CanvasApiConfig {
baseUrl: string;
accessToken: string;
courseId?: number;
}
const DEFAULT_CANVAS_CONFIG: CanvasApiConfig = {
baseUrl: import.meta.env.VITE_CANVAS_API_BASE_URL || 'https://canvas.instructure.com',
accessToken: import.meta.env.VITE_CANVAS_ACCESS_TOKEN || '',
};
/**
* Constructs a Canvas API endpoint URL with optional parameters
*/
export const constructCanvasEndpoint = (path: string, params?: Record<string, any>) => {
const queryString = params ? `?${new URLSearchParams(params).toString()}` : '';
return `${path}${queryString}`;
};
/**
* Makes a request to the Canvas API
*/
export const getCanvasData = async <T = any>(
endpoint: string,
params?: Record<string, any>,
config: Partial<CanvasApiConfig> = {}
): Promise<T> => {
const finalConfig = { ...DEFAULT_CANVAS_CONFIG, ...config };
const url = constructCanvasEndpoint(endpoint, params);
try {
const response = await api<T>('get', url, undefined, undefined, 'json', 60, true);
return response.data;
} catch (error) {
console.error('Canvas API Error:', error);
throw error;
}
};
// Example usage:
// const students = await getCanvasData('/courses/123/users', { enrollment_type: ['student'] });
// const courses = await getCanvasData('/courses', { per_page: 100 });
class CanvasApi {
private accessToken: string;
private baseUrl: string;
constructor(config: CanvasApiConfig) {
this.accessToken = config.accessToken;
this.baseUrl = config.baseUrl || DEFAULT_CANVAS_CONFIG.baseUrl;
}
private getHeaders() {
return {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
};
}
async getStudents(courseId: number) {
try {
const response = await getCanvasData('/courses/' + courseId + '/users', {
enrollment_type: ['student'],
per_page: 100,
});
return response;
} catch (error) {
console.error('Error fetching students:', error);
throw error;
}
}
// Add more Canvas API methods here as needed
}
export default CanvasApi;

@ -0,0 +1,35 @@
import { create } from 'zustand';
import { api } from '../../services/api';
interface CanvasRequestData {
endpoint: string;
params?: Record<string, any>;
courseId?: number;
}
type CanvasStore = {
sendCanvasRequest: (data: CanvasRequestData) => Promise<any>;
};
export const useCanvasStore = create<CanvasStore>(() => ({
sendCanvasRequest: async (requestData) => {
try {
if (!requestData.endpoint) {
throw new Error("Invalid input: 'endpoint' is required.");
}
// Construct the full URL with course ID if provided
const url = requestData.courseId
? `/courses/${requestData.courseId}${requestData.endpoint}`
: requestData.endpoint;
console.log('Sending Canvas request:', { url, params: requestData.params });
const response = await api('get', url, undefined, requestData.params, 'json', 60, true);
console.log('Got Canvas response:', response);
return response.data;
} catch (error) {
console.error('Failed to send Canvas request:', error);
throw error;
}
},
}));

@ -0,0 +1,100 @@
/* Canvas API specific theme overrides */
[data-system="canvas"] {
/* Override all teal colors with #e70022 (red) */
--canvas-primary: #e70022;
--canvas-primary-light: #ff1a3d;
--canvas-primary-dark: #cc001f;
--canvas-button-text: #f3f4f6; /* light gray for button text */
}
/* Override teal colors in Canvas API system */
[data-system="canvas"] .p-button {
background-color: var(--canvas-primary) !important;
border-color: var(--canvas-primary) !important;
color: var(--canvas-button-text) !important;
}
[data-system="canvas"] .p-button:hover {
background-color: var(--canvas-primary-dark) !important;
border-color: var(--canvas-primary-dark) !important;
color: var(--canvas-button-text) !important;
}
[data-system="canvas"] .p-button:hover:not(.p-disabled) {
background-color: var(--canvas-primary-dark) !important;
border-color: var(--canvas-primary-dark) !important;
}
[data-system="canvas"] .p-button.p-button-outlined {
color: var(--canvas-primary) !important;
border-color: var(--canvas-primary) !important;
background: transparent !important;
}
[data-system="canvas"] .p-button.p-button-outlined:hover {
background-color: var(--canvas-primary) !important;
color: var(--canvas-button-text) !important;
}
/* Override teal in tabview */
[data-system="canvas"] .p-tabview .p-tabview-nav li.p-highlight .p-tabview-nav-link {
color: var(--canvas-primary) !important;
border-color: var(--canvas-primary) !important;
}
[data-system="canvas"] .p-tabview .p-tabview-nav li .p-tabview-nav-link:hover {
color: var(--canvas-primary) !important;
}
/* Override teal in dropdowns */
[data-system="canvas"] .p-dropdown:not(.p-disabled).p-focus {
border-color: var(--canvas-primary) !important;
box-shadow: 0 0 0 1px var(--canvas-primary) !important;
}
[data-system="canvas"] .p-dropdown-panel .p-dropdown-items .p-dropdown-item.p-highlight {
background-color: var(--canvas-primary) !important;
}
/* Override teal in checkboxes */
[data-system="canvas"] .p-checkbox .p-checkbox-box.p-highlight {
background-color: var(--canvas-primary) !important;
border-color: var(--canvas-primary) !important;
}
/* Override teal in links */
[data-system="canvas"] .link-style {
color: var(--canvas-primary) !important;
}
[data-system="canvas"] .link-style:hover {
color: var(--canvas-primary-dark) !important;
}
/* Override teal in borders */
[data-system="canvas"] .border-teal-500 {
border-color: var(--canvas-primary) !important;
}
/* Override teal in text */
[data-system="canvas"] .text-teal-500,
[data-system="canvas"] .text-teal-600,
[data-system="canvas"] .text-teal-700 {
color: var(--canvas-primary) !important;
}
/* Override teal in backgrounds */
[data-system="canvas"] .bg-teal-500,
[data-system="canvas"] .bg-teal-600,
[data-system="canvas"] .bg-teal-700 {
background-color: var(--canvas-primary) !important;
}
/* Override teal in focus states */
[data-system="canvas"] .focus\:border-teal-500:focus {
border-color: var(--canvas-primary) !important;
}
[data-system="canvas"] .focus\:ring-teal-500:focus {
--tw-ring-color: var(--canvas-primary) !important;
}