diff --git a/README.md b/README.md index c6536bc..0a9bc33 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/src/assets/roc_logo.png b/frontend/src/assets/roc_logo.png new file mode 100644 index 0000000..c11486f Binary files /dev/null and b/frontend/src/assets/roc_logo.png differ diff --git a/frontend/src/components/CanvasEndpoints.tsx b/frontend/src/components/CanvasEndpoints.tsx new file mode 100644 index 0000000..785022c --- /dev/null +++ b/frontend/src/components/CanvasEndpoints.tsx @@ -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([]); + const [filters, setFilters] = useState(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(sizeOptions[0].value); + const [showCallModal, setShowCallModal] = useState(false); + const [callModalData, setCallModalData] = useState([]); + const [callModalColumns, setCallModalColumns] = useState([]); + const [callModalLoading, setCallModalLoading] = useState(false); + const [callEndpoint, setCallEndpoint] = useState(null); + const [callModalFilters, setCallModalFilters] = useState(null); + const [callModalGlobalFilterValue, setCallModalGlobalFilterValue] = useState(''); + const [callModalSize, setCallModalSize] = useState(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(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) => { + 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 = () => ( + ( +
+
+ )} + /> + ); + + const renderCallButton = (rowData: any) => ( +
+
+ ); + + const renderCallModalHeader = () => ( + handleCallEndpoint(callEndpoint)} + clearFilter={clearCallModalFilter} + /> + ); + + return ( +
+

Canvas Endpoints

+ + + + + + + + + + setShowCreateModal(false)} + header="Create New Endpoint" + footer={ +
+
+ } + > +
+
+ + setNewEndpoint({ ...newEndpoint, name: e.target.value })} /> +
+
+ + setNewEndpoint({ ...newEndpoint, method: e.value })} + placeholder="Select a method" + /> +
+
+ + setNewEndpoint({ ...newEndpoint, path: e.target.value })} /> +
+
+ + setNewEndpoint({ ...newEndpoint, description: e.target.value })} /> +
+
+
+ + setShowCallModal(false)} + header={callEndpoint ? `Response for ${callEndpoint.name}` : 'Endpoint Response'} + style={{ width: '96vw', maxWidth: 1800, minHeight: 600 }} + modal + > + {callModalLoading ? ( +
+ +
+ ) : callModalData.length > 0 ? ( +
+
+ + {callModalColumns.map((col) => ( + { + const value = rowData[col]; + if (typeof value === 'object' && value !== null) { + return {JSON.stringify(value)}; + } + return value; + }} + /> + ))} + +
+
+
+ + + Showing {callModalFirst + 1} to {Math.min(callModalFirst + callModalRows, callModalData.length)} of {callModalData.length} entries + + + + + {Math.floor(callModalFirst / callModalRows) + 1} + + + +
+
+
+ ) : ( +
No data found or error.
+ )} +
+ + setShowChatGptModal(false)} + header="ChatGPT Assistant" + style={{ width: '50vw' }} + > +
+
+ {chatMessages.length === 0 &&
Start a conversation with ChatGPT...
} + {chatMessages.map((msg, idx) => ( +
+ {msg.content} +
+ ))} +
+
+ setChatPrompt(e.target.value)} + onKeyDown={handleChatPromptKeyDown} + disabled={chatLoading} + /> +
+ {chatError &&
{chatError}
} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/CanvasEndpoints/CanvasDataTable.tsx b/frontend/src/components/CanvasEndpoints/CanvasDataTable.tsx new file mode 100644 index 0000000..31f9908 --- /dev/null +++ b/frontend/src/components/CanvasEndpoints/CanvasDataTable.tsx @@ -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 = ({ + 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(null); + + const toast = useRef(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(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 ( +
+ + {/* */} +
+ ); + }; + + const editButton = (rowData: Voucher) => { + return ; + }; + + 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 ( +