added PEK pages
This commit is contained in:
parent
8a0e5f88a1
commit
101b3063c0
207 changed files with 11488 additions and 11968 deletions
623
docs/API_ROUTES_DOCUMENTATION.md
Normal file
623
docs/API_ROUTES_DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,623 @@
|
||||||
|
# API Routes Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This documentation covers the Chat Playground and Workflow management API endpoints. These routes provide functionality for managing AI workflows, chat interactions, messages, logs, and related data.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All endpoints require authentication via the `getCurrentUser` dependency, which validates the user's session/token. Rate limiting is applied at 120 requests per minute for all endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chat Playground Routes
|
||||||
|
|
||||||
|
Base path: `/api/chat/playground`
|
||||||
|
|
||||||
|
### 1. Start Workflow
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/chat/playground/start`
|
||||||
|
|
||||||
|
**Description:** Starts a new workflow or continues an existing one based on the provided input.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `workflowId` (optional, string): ID of an existing workflow to continue
|
||||||
|
- `workflowMode` (string, default: "Actionplan"): Workflow execution mode
|
||||||
|
- `"Actionplan"`: Traditional task planning workflow
|
||||||
|
- `"React"`: Iterative react-style processing
|
||||||
|
|
||||||
|
**Request Body:** `UserInputRequest` object containing:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": "user's input text",
|
||||||
|
"files": [...], // optional file attachments
|
||||||
|
"metadata": {...} // optional metadata
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `ChatWorkflow` object representing the created or continued workflow
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
POST /api/chat/playground/start?workflowMode=Actionplan
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"input": "Analyze the quarterly sales data",
|
||||||
|
"files": [...],
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wf_123456",
|
||||||
|
"userId": "user_abc",
|
||||||
|
"status": "running",
|
||||||
|
"mode": "Actionplan",
|
||||||
|
"messageIds": [...],
|
||||||
|
"logIds": [...],
|
||||||
|
"createdAt": 1234567890.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Initiating a new AI conversation or task
|
||||||
|
- **AI:** Starting automated workflow processing for scheduled tasks
|
||||||
|
- **Integration:** Continuing an interrupted workflow from a previous session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Stop Workflow
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/chat/playground/{workflowId}/stop`
|
||||||
|
|
||||||
|
**Description:** Stops a running workflow, allowing graceful termination of ongoing processes.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow to stop
|
||||||
|
|
||||||
|
**Response:** `ChatWorkflow` object with updated status
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
POST /api/chat/playground/wf_123456/stop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wf_123456",
|
||||||
|
"status": "stopped",
|
||||||
|
"stoppedAt": 1234567890.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Manually stopping a long-running task
|
||||||
|
- **AI:** Implementing timeout mechanisms
|
||||||
|
- **System:** Cleanup of abandoned workflows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Get Unified Chat Data
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/chat/playground/{workflowId}/chatData`
|
||||||
|
|
||||||
|
**Description:** Retrieves unified chat data including messages, logs, and statistics in chronological order. Supports incremental data fetching.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `afterTimestamp` (optional, float): Unix timestamp to fetch only data created after this time
|
||||||
|
|
||||||
|
**Response:** Dictionary containing:
|
||||||
|
- `messages`: Array of chat messages
|
||||||
|
- `logs`: Array of log entries
|
||||||
|
- `stats`: Array of statistics
|
||||||
|
- `documents`: Array of attached documents
|
||||||
|
- All sorted by `_createdAt` timestamp
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
GET /api/chat/playground/wf_123456/chatData?afterTimestamp=1234567800.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "msg_001",
|
||||||
|
"content": "Hello",
|
||||||
|
"_createdAt": 1234567890.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": "log_001",
|
||||||
|
"level": "info",
|
||||||
|
"message": "Processing started",
|
||||||
|
"_createdAt": 1234567891.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": [...],
|
||||||
|
"documents": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Real-time chat UI updates and polling for new data
|
||||||
|
- **AI:** Monitoring workflow progress and synchronizing state
|
||||||
|
- **Mobile App:** Efficient incremental data synchronization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Routes
|
||||||
|
|
||||||
|
Base path: `/api/workflows`
|
||||||
|
|
||||||
|
### 1. List All Workflows
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/workflows/`
|
||||||
|
|
||||||
|
**Description:** Retrieves all workflows for the currently authenticated user.
|
||||||
|
|
||||||
|
**Response:** Array of `ChatWorkflow` objects
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
GET /api/workflows/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "wf_001",
|
||||||
|
"userId": "user_abc",
|
||||||
|
"status": "completed",
|
||||||
|
"createdAt": 1234567890.0,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wf_002",
|
||||||
|
"userId": "user_abc",
|
||||||
|
"status": "running",
|
||||||
|
"createdAt": 1234567990.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Dashboard showing all their workflows
|
||||||
|
- **AI:** Analyzing user's workflow history and patterns
|
||||||
|
- **Admin:** User activity monitoring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Get Workflow by ID
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/workflows/{workflowId}`
|
||||||
|
|
||||||
|
**Description:** Retrieves detailed information about a specific workflow.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow
|
||||||
|
|
||||||
|
**Response:** `ChatWorkflow` object
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
GET /api/workflows/wf_123456
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wf_123456",
|
||||||
|
"userId": "user_abc",
|
||||||
|
"status": "running",
|
||||||
|
"mode": "Actionplan",
|
||||||
|
"messageIds": ["msg_001", "msg_002"],
|
||||||
|
"logIds": ["log_001", "log_002"],
|
||||||
|
"createdAt": 1234567890.0,
|
||||||
|
"updatedAt": 1234567990.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Viewing specific workflow details
|
||||||
|
- **AI:** Loading workflow state for processing
|
||||||
|
- **Debugging:** Investigating workflow behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Update Workflow
|
||||||
|
|
||||||
|
**Endpoint:** `PUT /api/workflows/{workflowId}`
|
||||||
|
|
||||||
|
**Description:** Updates workflow properties. Requires ownership or modification permissions.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow to update
|
||||||
|
|
||||||
|
**Request Body:** Dictionary with fields to update:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Updated workflow name",
|
||||||
|
"description": "Updated description",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Updated `ChatWorkflow` object
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
PUT /api/workflows/wf_123456
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Sales Analysis Q4",
|
||||||
|
"tags": ["sales", "analysis"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wf_123456",
|
||||||
|
"name": "Sales Analysis Q4",
|
||||||
|
"tags": ["sales", "analysis"],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Renaming workflows for better organization
|
||||||
|
- **User:** Adding tags and metadata
|
||||||
|
- **AI:** Programmatically updating workflow metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Get Workflow Status
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/workflows/{workflowId}/status`
|
||||||
|
|
||||||
|
**Description:** Gets the current status of a workflow without loading all associated data.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow
|
||||||
|
|
||||||
|
**Response:** `ChatWorkflow` object (lightweight status check)
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
GET /api/workflows/wf_123456/status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wf_123456",
|
||||||
|
"status": "running",
|
||||||
|
"currentStep": "analyzing",
|
||||||
|
"progress": 45,
|
||||||
|
"updatedAt": 1234567990.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Quick status checks without loading full data
|
||||||
|
- **AI:** Monitoring workflow progress
|
||||||
|
- **Mobile:** Efficient polling for status updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Get Workflow Logs
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/workflows/{workflowId}/logs`
|
||||||
|
|
||||||
|
**Description:** Retrieves logs for a workflow with support for selective data transfer.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `logId` (optional, string): Log ID to fetch only newer logs (selective data transfer)
|
||||||
|
|
||||||
|
**Response:** Array of `ChatLog` objects
|
||||||
|
|
||||||
|
**Example Request (All Logs):**
|
||||||
|
```bash
|
||||||
|
GET /api/workflows/wf_123456/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Request (Incremental):**
|
||||||
|
```bash
|
||||||
|
GET /api/workflows/wf_123456/logs?logId=log_050
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "log_001",
|
||||||
|
"workflowId": "wf_123456",
|
||||||
|
"level": "info",
|
||||||
|
"message": "Workflow started",
|
||||||
|
"timestamp": 1234567890.0,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "log_002",
|
||||||
|
"workflowId": "wf_123456",
|
||||||
|
"level": "warning",
|
||||||
|
"message": "Slow response detected",
|
||||||
|
"timestamp": 1234567900.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Viewing detailed execution logs for debugging
|
||||||
|
- **AI:** Error analysis and troubleshooting
|
||||||
|
- **System:** Audit trail and compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Get Workflow Messages
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/workflows/{workflowId}/messages`
|
||||||
|
|
||||||
|
**Description:** Retrieves messages for a workflow with support for selective data transfer.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `messageId` (optional, string): Message ID to fetch only newer messages
|
||||||
|
|
||||||
|
**Response:** Array of `ChatMessage` objects
|
||||||
|
|
||||||
|
**Example Request (All Messages):**
|
||||||
|
```bash
|
||||||
|
GET /api/workflows/wf_123456/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Request (Incremental):**
|
||||||
|
```bash
|
||||||
|
GET /api/workflows/wf_123456/messages?messageId=msg_100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "msg_001",
|
||||||
|
"workflowId": "wf_123456",
|
||||||
|
"role": "user",
|
||||||
|
"content": "Analyze the data",
|
||||||
|
"timestamp": 1234567890.0,
|
||||||
|
"files": [...],
|
||||||
|
...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg_002",
|
||||||
|
"workflowId": "wf_123456",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Processing your request...",
|
||||||
|
"timestamp": 1234567900.0,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Viewing conversation history
|
||||||
|
- **AI:** Context building for responses
|
||||||
|
- **UI:** Chat interface message display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Delete Workflow
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/workflows/{workflowId}`
|
||||||
|
|
||||||
|
**Description:** Deletes a workflow and all its associated data (messages, logs, etc.). Requires ownership or deletion permissions.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow to delete
|
||||||
|
|
||||||
|
**Response:** Dictionary with deletion confirmation
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
DELETE /api/workflows/wf_123456
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wf_123456",
|
||||||
|
"message": "Workflow and associated data deleted successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Cleanup of old or unnecessary workflows
|
||||||
|
- **AI:** Automated cleanup of expired workflows
|
||||||
|
- **Admin:** Data management and compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Delete Workflow Message
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/workflows/{workflowId}/messages/{messageId}`
|
||||||
|
|
||||||
|
**Description:** Deletes a specific message from a workflow.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow
|
||||||
|
- `messageId` (required, string): ID of the message to delete
|
||||||
|
|
||||||
|
**Response:** Dictionary with deletion confirmation
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
DELETE /api/workflows/wf_123456/messages/msg_050
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workflowId": "wf_123456",
|
||||||
|
"messageId": "msg_050",
|
||||||
|
"message": "Message deleted successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Removing sensitive or incorrect messages
|
||||||
|
- **AI:** Content moderation
|
||||||
|
- **User:** Editing conversation history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Delete File from Message
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/workflows/{workflowId}/messages/{messageId}/files/{fileId}`
|
||||||
|
|
||||||
|
**Description:** Deletes a file reference from a message within a workflow.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `workflowId` (required, string): ID of the workflow
|
||||||
|
- `messageId` (required, string): ID of the message
|
||||||
|
- `fileId` (required, string): ID of the file to delete
|
||||||
|
|
||||||
|
**Response:** Dictionary with deletion confirmation
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
DELETE /api/workflows/wf_123456/messages/msg_050/files/file_123
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workflowId": "wf_123456",
|
||||||
|
"messageId": "msg_050",
|
||||||
|
"fileId": "file_123",
|
||||||
|
"message": "File reference deleted successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **User:** Removing attached files for privacy
|
||||||
|
- **User:** Fixing incorrect file uploads
|
||||||
|
- **System:** Managing storage quotas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All endpoints may return the following HTTP status codes:
|
||||||
|
|
||||||
|
- `200 OK`: Successful request
|
||||||
|
- `400 Bad Request`: Invalid request parameters
|
||||||
|
- `401 Unauthorized`: Authentication required
|
||||||
|
- `403 Forbidden`: Insufficient permissions
|
||||||
|
- `404 Not Found`: Resource not found
|
||||||
|
- `500 Internal Server Error`: Server error
|
||||||
|
|
||||||
|
**Example Error Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Error message describing what went wrong"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### For Users:
|
||||||
|
|
||||||
|
1. **Polling Strategy**: Use the `afterTimestamp` or `logId`/`messageId` parameters to implement efficient polling without redundant data transfer
|
||||||
|
2. **Error Handling**: Always implement proper error handling and user feedback
|
||||||
|
3. **Rate Limiting**: Be aware of the 120 requests/minute limit and implement exponential backoff
|
||||||
|
4. **Cleanup**: Regularly delete old workflows to manage storage
|
||||||
|
|
||||||
|
### For AI/Automation:
|
||||||
|
|
||||||
|
1. **Incremental Updates**: Always use selective data transfer parameters to minimize bandwidth
|
||||||
|
2. **Status Checks**: Use lightweight endpoints like `/status` for monitoring
|
||||||
|
3. **Timeout Handling**: Implement proper stop logic for long-running workflows
|
||||||
|
4. **Logging**: Monitor logs for debugging and performance analysis
|
||||||
|
|
||||||
|
### For Developers:
|
||||||
|
|
||||||
|
1. **Caching**: Cache workflow status to reduce API calls
|
||||||
|
2. **Webhooks**: Consider implementing webhooks for real-time updates instead of polling
|
||||||
|
3. **Batch Operations**: Group related operations to reduce network overhead
|
||||||
|
4. **Error Recovery**: Implement retry logic with exponential backoff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### Starting a Workflow with File Upload
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start workflow
|
||||||
|
POST /api/chat/playground/start?workflowMode=Actionplan
|
||||||
|
{
|
||||||
|
"input": "Analyze this report",
|
||||||
|
"files": [{"id": "file_001", "name": "report.pdf"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Poll for updates
|
||||||
|
GET /api/chat/playground/wf_123456/chatData
|
||||||
|
|
||||||
|
# Continue polling with incremental updates
|
||||||
|
GET /api/chat/playground/wf_123456/chatData?afterTimestamp=1234567890.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring Workflow Progress
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start workflow
|
||||||
|
POST /api/chat/playground/start
|
||||||
|
|
||||||
|
# Check status periodically
|
||||||
|
GET /api/workflows/wf_123456/status
|
||||||
|
|
||||||
|
# Get detailed logs if status indicates issues
|
||||||
|
GET /api/workflows/wf_123456/logs
|
||||||
|
|
||||||
|
# Stop if needed
|
||||||
|
POST /api/chat/playground/wf_123456/stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup Old Workflows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all workflows
|
||||||
|
GET /api/workflows/
|
||||||
|
|
||||||
|
# Filter for old/completed workflows and delete
|
||||||
|
DELETE /api/workflows/wf_old_123
|
||||||
|
DELETE /api/workflows/wf_old_124
|
||||||
|
```
|
||||||
|
|
||||||
83
package-lock.json
generated
83
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
"@azure/msal-browser": "^4.12.0",
|
||||||
"@azure/msal-react": "^3.0.12",
|
"@azure/msal-react": "^3.0.12",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
|
|
@ -19,18 +20,22 @@
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"motion": "^12.7.3",
|
"motion": "^12.7.3",
|
||||||
"pg": "^8.8.0",
|
"pg": "^8.8.0",
|
||||||
|
"proj4": "^2.20.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.7.1",
|
"react-router-dom": "^7.7.1",
|
||||||
"xstate": "^5.20.1"
|
"xstate": "^5.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
|
"@types/proj4": "^2.5.6",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
|
@ -1073,6 +1078,17 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -1426,6 +1442,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
|
|
@ -1433,6 +1455,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.7.2",
|
"version": "24.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
|
||||||
|
|
@ -1443,6 +1474,13 @@
|
||||||
"undici-types": "~7.14.0"
|
"undici-types": "~7.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/proj4": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/proj4/-/proj4-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-zfMrPy9fx+8DchqM0kIUGeu2tTVB5ApO1KGAYcSGFS8GoqRIkyL41xq2yCx/iV3sOLzo7v4hEgViSLTiPI1L0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.8",
|
"version": "19.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
||||||
|
|
@ -3683,6 +3721,12 @@
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -3840,6 +3884,12 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mgrs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
|
@ -4371,6 +4421,19 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proj4": {
|
||||||
|
"version": "2.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.2.tgz",
|
||||||
|
"integrity": "sha512-ipfBRfQly0HhHTO7hnC1GfaX8bvroO7VV4KH889ehmADSE8C/qzp2j+Jj6783S9Tj6c2qX/hhYm7oH0kgXzBAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mgrs": "1.0.0",
|
||||||
|
"wkt-parser": "^1.5.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ahocevar"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
|
@ -4524,6 +4587,20 @@
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|
@ -5302,6 +5379,12 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wkt-parser": {
|
||||||
|
"version": "1.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.2.tgz",
|
||||||
|
"integrity": "sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
"@azure/msal-browser": "^4.12.0",
|
||||||
"@azure/msal-react": "^3.0.12",
|
"@azure/msal-react": "^3.0.12",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
|
|
@ -25,18 +26,22 @@
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"motion": "^12.7.3",
|
"motion": "^12.7.3",
|
||||||
"pg": "^8.8.0",
|
"pg": "^8.8.0",
|
||||||
|
"proj4": "^2.20.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.7.1",
|
"react-router-dom": "^7.7.1",
|
||||||
"xstate": "^5.20.1"
|
"xstate": "^5.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
|
"@types/proj4": "^2.5.6",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
|
|
||||||
42
src/App.tsx
42
src/App.tsx
|
|
@ -7,9 +7,11 @@ import './index.css';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
|
|
||||||
import { AuthProvider } from './auth/authProvider';
|
import { AuthProvider } from './providers/auth/AuthProvider';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
|
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||||
|
import { FileProvider } from './contexts/FileContext';
|
||||||
import Home from './pages/Home/Home';
|
import Home from './pages/Home/Home';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -37,21 +39,25 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<FileProvider>
|
||||||
<Routes>
|
<WorkflowSelectionProvider>
|
||||||
{/* Public route */}
|
<Router>
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route path="/register" element={<Register />} />
|
{/* Public route */}
|
||||||
<Route path="/" element={
|
<Route path="/login" element={<Login />} />
|
||||||
<ProtectedRoute>
|
<Route path="/register" element={<Register />} />
|
||||||
<Home />
|
<Route path="/" element={
|
||||||
</ProtectedRoute>
|
<ProtectedRoute>
|
||||||
}>
|
<Home />
|
||||||
{/* All page routing is now handled by the Page Loader in Home.tsx */}
|
</ProtectedRoute>
|
||||||
<Route path="*" element={null} />
|
}>
|
||||||
</Route>
|
{/* All page routing is now handled by the Page Loader in Home.tsx */}
|
||||||
</Routes>
|
<Route path="*" element={null} />
|
||||||
</Router>
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</WorkflowSelectionProvider>
|
||||||
|
</FileProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
34
src/api.ts
34
src/api.ts
|
|
@ -1,6 +1,7 @@
|
||||||
// api.ts
|
// api.ts
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { addCSRFTokenToHeaders } from './utils/csrfUtils';
|
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
|
||||||
|
import { clearUserDataCache } from './utils/userCache';
|
||||||
|
|
||||||
// Utility function to resolve hostname to IP address
|
// Utility function to resolve hostname to IP address
|
||||||
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
|
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
|
||||||
|
|
@ -52,12 +53,29 @@ api.interceptors.request.use(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication is now handled automatically via httpOnly cookies
|
// Check for auth token in localStorage and add to headers
|
||||||
// Browser will send cookies automatically with credentials: 'include'
|
const authToken = localStorage.getItem('authToken');
|
||||||
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
|
if (authToken && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
console.log('🔑 Using Bearer token for authentication');
|
||||||
|
} else {
|
||||||
|
// Fallback: httpOnly cookies
|
||||||
|
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
|
||||||
|
}
|
||||||
|
|
||||||
// Add CSRF token to all requests (except GET requests)
|
// Add CSRF token to all requests (including GET requests for certain endpoints)
|
||||||
if (config.method && ['post', 'put', 'patch', 'delete'].includes(config.method.toLowerCase())) {
|
// Some endpoints like /api/realestate/* require CSRF tokens even for GET requests
|
||||||
|
const method = config.method?.toLowerCase();
|
||||||
|
const url = config.url || '';
|
||||||
|
const requiresCSRF =
|
||||||
|
['post', 'put', 'patch', 'delete'].includes(method || '') ||
|
||||||
|
url.includes('/api/realestate/');
|
||||||
|
|
||||||
|
if (requiresCSRF) {
|
||||||
|
// Ensure CSRF token exists, generate one if missing
|
||||||
|
if (!getCSRFToken()) {
|
||||||
|
generateAndStoreCSRFToken();
|
||||||
|
}
|
||||||
addCSRFTokenToHeaders(config.headers as Record<string, string>);
|
addCSRFTokenToHeaders(config.headers as Record<string, string>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,8 +98,8 @@ api.interceptors.response.use(
|
||||||
|
|
||||||
if (!isLoginEndpoint) {
|
if (!isLoginEndpoint) {
|
||||||
// Clear local auth data (httpOnly cookies are cleared by backend)
|
// Clear local auth data (httpOnly cookies are cleared by backend)
|
||||||
localStorage.removeItem('auth_authority');
|
sessionStorage.removeItem('auth_authority');
|
||||||
localStorage.removeItem('currentUser');
|
clearUserDataCache();
|
||||||
// Redirect to login
|
// Redirect to login
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
309
src/api/workflowApi.ts
Normal file
309
src/api/workflowApi.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES & INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Workflow interfaces
|
||||||
|
export interface Workflow {
|
||||||
|
id: string;
|
||||||
|
mandateId: string;
|
||||||
|
status: string;
|
||||||
|
name?: string;
|
||||||
|
workflowMode?: string;
|
||||||
|
[key: string]: any; // Allow additional properties
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowMessage {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowStats {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowDocument {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowLog {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request/Response interfaces based on API documentation
|
||||||
|
export interface FileAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInputRequest {
|
||||||
|
input: string;
|
||||||
|
files?: FileAttachment[]; // optional file attachments - array of {id, name} objects
|
||||||
|
metadata?: Record<string, any>; // optional metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartWorkflowRequest {
|
||||||
|
prompt: string;
|
||||||
|
listFileId?: string[]; // Array of file ID strings (files must be uploaded first via /api/files/upload)
|
||||||
|
userLanguage?: string; // Optional, defaults to "en"
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartWorkflowResponse extends Workflow {
|
||||||
|
// Workflow object returned from start endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatDataResponse {
|
||||||
|
messages: WorkflowMessage[];
|
||||||
|
logs: WorkflowLog[];
|
||||||
|
stats: WorkflowStats[];
|
||||||
|
documents: WorkflowDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type for the request function passed to API functions
|
||||||
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API REQUEST FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all workflows for the current user
|
||||||
|
* Endpoint: GET /api/workflows/
|
||||||
|
*/
|
||||||
|
export async function fetchWorkflows(request: ApiRequestFunction): Promise<Workflow[]> {
|
||||||
|
const data = await request<Workflow[]>({
|
||||||
|
url: '/api/workflows/',
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single workflow by ID
|
||||||
|
* Endpoint: GET /api/workflows/{workflowId}
|
||||||
|
*/
|
||||||
|
export async function fetchWorkflow(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<Workflow> {
|
||||||
|
return await request<Workflow>({
|
||||||
|
url: `/api/workflows/${workflowId}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch workflow status (lightweight status check)
|
||||||
|
* Endpoint: GET /api/workflows/{workflowId}/status
|
||||||
|
*/
|
||||||
|
export async function fetchWorkflowStatus(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<Workflow> {
|
||||||
|
return await request<Workflow>({
|
||||||
|
url: `/api/workflows/${workflowId}/status`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch workflow messages
|
||||||
|
* Endpoint: GET /api/workflows/{workflowId}/messages
|
||||||
|
* Query params: messageId (optional) - fetch only newer messages
|
||||||
|
*/
|
||||||
|
export async function fetchWorkflowMessages(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string,
|
||||||
|
messageId?: string
|
||||||
|
): Promise<WorkflowMessage[]> {
|
||||||
|
const params = messageId ? { messageId } : undefined;
|
||||||
|
const data = await request<WorkflowMessage[]>({
|
||||||
|
url: `/api/workflows/${workflowId}/messages`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch workflow logs
|
||||||
|
* Endpoint: GET /api/workflows/{workflowId}/logs
|
||||||
|
* Query params: logId (optional) - fetch only newer logs
|
||||||
|
*/
|
||||||
|
export async function fetchWorkflowLogs(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string,
|
||||||
|
logId?: string
|
||||||
|
): Promise<WorkflowLog[]> {
|
||||||
|
const params = logId ? { logId } : undefined;
|
||||||
|
const data = await request<WorkflowLog[]>({
|
||||||
|
url: `/api/workflows/${workflowId}/logs`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch unified chat data (messages, logs, stats, documents)
|
||||||
|
* Endpoint: GET /api/chat/playground/{workflowId}/chatData
|
||||||
|
* Query params: afterTimestamp (optional) - fetch only data created after this time
|
||||||
|
*/
|
||||||
|
export async function fetchChatData(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string,
|
||||||
|
afterTimestamp?: number
|
||||||
|
): Promise<ChatDataResponse> {
|
||||||
|
const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined;
|
||||||
|
const requestConfig = {
|
||||||
|
url: `/api/chat/playground/${workflowId}/chatData`,
|
||||||
|
method: 'get' as const,
|
||||||
|
params
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 fetchChatData request:', requestConfig);
|
||||||
|
|
||||||
|
const data = await request<ChatDataResponse>(requestConfig);
|
||||||
|
|
||||||
|
console.log('📥 fetchChatData response:', data);
|
||||||
|
|
||||||
|
// Ensure all arrays exist
|
||||||
|
return {
|
||||||
|
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||||
|
logs: Array.isArray(data.logs) ? data.logs : [],
|
||||||
|
stats: Array.isArray(data.stats) ? data.stats : [],
|
||||||
|
documents: Array.isArray(data.documents) ? data.documents : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new workflow or continue an existing one
|
||||||
|
* Endpoint: POST /api/chat/playground/start
|
||||||
|
* Query params: workflowId (optional), workflowMode (default: "Actionplan")
|
||||||
|
*/
|
||||||
|
export async function startWorkflowApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowData: StartWorkflowRequest,
|
||||||
|
options?: { workflowId?: string; workflowMode?: 'Actionplan' | 'React' }
|
||||||
|
): Promise<StartWorkflowResponse> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (options?.workflowId) {
|
||||||
|
params.workflowId = options.workflowId;
|
||||||
|
}
|
||||||
|
if (options?.workflowMode) {
|
||||||
|
params.workflowMode = options.workflowMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
url: '/api/chat/playground/start',
|
||||||
|
method: 'post' as const,
|
||||||
|
data: workflowData,
|
||||||
|
params: Object.keys(params).length > 0 ? params : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 startWorkflow request:', requestConfig);
|
||||||
|
|
||||||
|
const response = await request<StartWorkflowResponse>(requestConfig);
|
||||||
|
|
||||||
|
console.log('📥 startWorkflow response:', response);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a running workflow
|
||||||
|
* Endpoint: POST /api/chat/playground/{workflowId}/stop
|
||||||
|
*/
|
||||||
|
export async function stopWorkflowApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await request({
|
||||||
|
url: `/api/chat/playground/${workflowId}/stop`,
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update workflow properties
|
||||||
|
* Endpoint: PUT /api/workflows/{workflowId}
|
||||||
|
*/
|
||||||
|
export async function updateWorkflowApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string,
|
||||||
|
updateData: Partial<{ name: string; description?: string; tags?: string[] }>
|
||||||
|
): Promise<Workflow> {
|
||||||
|
return await request<Workflow>({
|
||||||
|
url: `/api/workflows/${workflowId}`,
|
||||||
|
method: 'put',
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a workflow and all associated data
|
||||||
|
* Endpoint: DELETE /api/workflows/{workflowId}
|
||||||
|
*/
|
||||||
|
export async function deleteWorkflowApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await request({
|
||||||
|
url: `/api/workflows/${workflowId}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple workflows
|
||||||
|
*/
|
||||||
|
export async function deleteWorkflowsApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
// Delete workflows one by one since there's no bulk delete endpoint
|
||||||
|
const deletePromises = workflowIds.map(workflowId =>
|
||||||
|
request({
|
||||||
|
url: `/api/workflows/${workflowId}`,
|
||||||
|
method: 'delete'
|
||||||
|
}).catch(error => {
|
||||||
|
console.error(`Failed to delete workflow ${workflowId}:`, error);
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a message from a workflow
|
||||||
|
* Endpoint: DELETE /api/workflows/{workflowId}/messages/{messageId}
|
||||||
|
*/
|
||||||
|
export async function deleteMessageApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string,
|
||||||
|
messageId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await request({
|
||||||
|
url: `/api/workflows/${workflowId}/messages/${messageId}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file reference from a message
|
||||||
|
* Endpoint: DELETE /api/workflows/{workflowId}/messages/{messageId}/files/{fileId}
|
||||||
|
*/
|
||||||
|
export async function deleteFileFromMessageApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string,
|
||||||
|
messageId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await request({
|
||||||
|
url: `/api/workflows/${workflowId}/messages/${messageId}/files/${fileId}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
.editModal {
|
|
||||||
/* Custom styling for the edit modal */
|
|
||||||
}
|
|
||||||
|
|
||||||
.editForm {
|
|
||||||
/* Form-specific styling within the modal */
|
|
||||||
min-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure proper spacing for form elements */
|
|
||||||
.editForm :global(.fieldGroup) {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editForm :global(.floatingLabelInput) {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style the readonly fields to blend better with the modal */
|
|
||||||
.editForm :global(.readonlyField) {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
opacity: 0.8;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhance button styling within the modal */
|
|
||||||
.editForm :global(.buttonGroup) {
|
|
||||||
margin-top: 32px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editForm :global(.saveButton) {
|
|
||||||
background-color: var(--color-secondary);
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editForm :global(.saveButton:hover) {
|
|
||||||
background-color: var(--color-secondary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editForm :global(.cancelButton) {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.editForm {
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editForm :global(.buttonGroup) {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editForm :global(.saveButton),
|
|
||||||
.editForm :global(.cancelButton) {
|
|
||||||
flex: 1;
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
|
|
||||||
import { Popup, EditForm } from '../ui/Popup';
|
|
||||||
import styles from './ConnectionEditModal.module.css';
|
|
||||||
import { ConnectionEditModalProps } from './connectionsInterfaces';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
|
|
||||||
export function ConnectionEditModal({
|
|
||||||
isOpen,
|
|
||||||
connection,
|
|
||||||
fields,
|
|
||||||
onSave,
|
|
||||||
onCancel
|
|
||||||
}: ConnectionEditModalProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
if (!connection) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorityName = connection.authority?.charAt(0).toUpperCase() + connection.authority?.slice(1);
|
|
||||||
|
|
||||||
// Simplified approach for the title
|
|
||||||
const baseTitle = t('connections.edit_connection_title', `Edit {authority} Connection`);
|
|
||||||
const modalTitle = baseTitle.includes('{authority}')
|
|
||||||
? baseTitle.replace('{authority}', authorityName || '')
|
|
||||||
: `Edit ${authorityName} Connection`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popup
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={modalTitle}
|
|
||||||
onClose={onCancel}
|
|
||||||
size="medium"
|
|
||||||
className={styles.editModal}
|
|
||||||
>
|
|
||||||
<EditForm
|
|
||||||
data={connection}
|
|
||||||
fields={fields}
|
|
||||||
onSave={onSave}
|
|
||||||
onCancel={onCancel}
|
|
||||||
saveButtonText={t('connections.update_connection', 'Update Connection')}
|
|
||||||
cancelButtonText={t('common.cancel', 'Cancel')}
|
|
||||||
showButtons={true}
|
|
||||||
className={styles.editForm}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConnectionEditModal;
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
.errorContainer {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMessage {
|
|
||||||
color: var(--color-red, #ef4444);
|
|
||||||
padding: 12px 16px;
|
|
||||||
background-color: var(--color-red-hover, rgba(239, 68, 68, 0.1));
|
|
||||||
border: 1px solid var(--color-red, #ef4444);
|
|
||||||
border-radius: 25px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMessage:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMessage strong {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation for error appearance */
|
|
||||||
.errorMessage {
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.errorMessage {
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
|
|
||||||
import styles from './ConnectionsErrorDisplay.module.css';
|
|
||||||
import { ConnectionsErrorDisplayProps } from './connectionsInterfaces';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
|
|
||||||
export function ConnectionsErrorDisplay({
|
|
||||||
error,
|
|
||||||
connectError,
|
|
||||||
disconnectError
|
|
||||||
}: ConnectionsErrorDisplayProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
// Don't render anything if no errors
|
|
||||||
if (!error && !connectError && !disconnectError) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.errorContainer}>
|
|
||||||
{error && (
|
|
||||||
<div className={styles.errorMessage}>
|
|
||||||
<strong>{t('connections.error', 'Error')}:</strong> {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{connectError && (
|
|
||||||
<div className={styles.errorMessage}>
|
|
||||||
<strong>{t('connections.connection_error', 'Connection Error')}:</strong> {connectError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{disconnectError && (
|
|
||||||
<div className={styles.errorMessage}>
|
|
||||||
<strong>{t('connections.disconnect_error', 'Disconnect Error')}:</strong> {disconnectError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConnectionsErrorDisplay;
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
.tableContainer {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-bg);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connectionsTable {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override FormGenerator styles for connections-specific styling */
|
|
||||||
.connectionsTable :global(.table) {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connectionsTable :global(.th) {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
border-bottom: 2px solid var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connectionsTable :global(.td) {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
border-bottom: 1px solid var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connectionsTable :global(.tr:hover) {
|
|
||||||
background-color: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expired connection highlighting */
|
|
||||||
.connectionsTable :global(.expired-connection) {
|
|
||||||
background-color: rgba(255, 193, 7, 0.1) !important;
|
|
||||||
color: #ff6b35 !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connectionsTable :global(.tr:hover .expired-connection) {
|
|
||||||
background-color: rgba(255, 193, 7, 0.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tableContainer {
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 0 -8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.tableContainer {
|
|
||||||
margin: 0 -16px;
|
|
||||||
border-radius: 0;
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
// import React from 'react';
|
|
||||||
import { FormGenerator } from '../FormGenerator';
|
|
||||||
import styles from './ConnectionsTable.module.css';
|
|
||||||
import { ConnectionsTableProps } from './connectionsInterfaces';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
|
|
||||||
export function ConnectionsTable({
|
|
||||||
connections,
|
|
||||||
columns,
|
|
||||||
actions,
|
|
||||||
isLoading = false,
|
|
||||||
isConnecting = false,
|
|
||||||
isDisconnecting = false,
|
|
||||||
onRowSelect,
|
|
||||||
onDelete,
|
|
||||||
onDeleteMultiple
|
|
||||||
}: ConnectionsTableProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
const loading = isLoading || isConnecting || isDisconnecting;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.tableContainer}>
|
|
||||||
<FormGenerator
|
|
||||||
data={connections}
|
|
||||||
columns={columns}
|
|
||||||
title={t('connections.service_connections', 'Service Connections')}
|
|
||||||
loading={loading}
|
|
||||||
searchable={true}
|
|
||||||
filterable={true}
|
|
||||||
sortable={true}
|
|
||||||
resizable={true}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={10}
|
|
||||||
selectable={true}
|
|
||||||
onRowSelect={onRowSelect}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onDeleteMultiple={onDeleteMultiple}
|
|
||||||
actionButtons={actions}
|
|
||||||
className={styles.connectionsTable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConnectionsTable;
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { ColumnConfig } from '../FormGenerator';
|
|
||||||
import { EditFieldConfig } from '../ui/Popup';
|
|
||||||
|
|
||||||
// Re-export connection-related interfaces from hooks
|
|
||||||
export type { Connection, CreateConnectionData } from '../../hooks/useConnections';
|
|
||||||
import type { Connection } from '../../hooks/useConnections';
|
|
||||||
|
|
||||||
// Component Props Interfaces
|
|
||||||
export interface ConnectionsTableProps {
|
|
||||||
connections: Connection[];
|
|
||||||
columns: ColumnConfig[];
|
|
||||||
actions: TableAction[];
|
|
||||||
isLoading?: boolean;
|
|
||||||
isConnecting?: boolean;
|
|
||||||
isDisconnecting?: boolean;
|
|
||||||
onRowSelect?: (selectedRows: Connection[]) => void;
|
|
||||||
onDelete?: (connection: Connection) => Promise<void>;
|
|
||||||
onDeleteMultiple?: (connections: Connection[]) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionEditModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
connection: Connection | null;
|
|
||||||
fields: EditFieldConfig[];
|
|
||||||
onSave: (updatedConnection: Connection) => Promise<void>;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionsErrorDisplayProps {
|
|
||||||
error?: string | null;
|
|
||||||
connectError?: string | null;
|
|
||||||
disconnectError?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table Action Interface
|
|
||||||
export interface TableAction {
|
|
||||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy';
|
|
||||||
title?: string | ((connection: Connection) => string);
|
|
||||||
onAction?: (connection: Connection) => Promise<void> | void;
|
|
||||||
disabled?: (connection: Connection) => boolean | { disabled: boolean; message?: string };
|
|
||||||
loading?: (connection: Connection) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection Status Types
|
|
||||||
export type ConnectionStatus = 'active' | 'pending' | 'expired' | 'revoked';
|
|
||||||
export type ConnectionAuthority = 'local' | 'google' | 'msft';
|
|
||||||
|
|
||||||
// Handler Function Types
|
|
||||||
export interface ConnectionHandlers {
|
|
||||||
handleCreateConnection: (type: 'msft' | 'google') => Promise<void>;
|
|
||||||
handleConnect: (connection: Connection) => Promise<void>;
|
|
||||||
handleDisconnect: (connection: Connection) => Promise<void>;
|
|
||||||
handleDelete: (connection: Connection) => Promise<void>;
|
|
||||||
handleDeleteMultiple: (connections: Connection[]) => Promise<void>;
|
|
||||||
handleUpdateConnection: (connection: Connection) => Promise<void>;
|
|
||||||
handleEditConnection: (connection: Connection) => Promise<void>;
|
|
||||||
handleSaveConnection: (updatedConnection: Connection) => Promise<void>;
|
|
||||||
handleCancelEdit: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook Return Types
|
|
||||||
export interface ConnectionsLogicReturn extends ConnectionHandlers {
|
|
||||||
connections: Connection[];
|
|
||||||
isLoading: boolean;
|
|
||||||
isConnecting: boolean;
|
|
||||||
isDisconnecting: boolean;
|
|
||||||
error: string | null;
|
|
||||||
connectError: string | null;
|
|
||||||
disconnectError: string | null;
|
|
||||||
editPopupOpen: boolean;
|
|
||||||
editingConnection: Connection | null;
|
|
||||||
connectionColumns: ColumnConfig[];
|
|
||||||
connectionEditFields: EditFieldConfig[];
|
|
||||||
tableActions: TableAction[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useConnections, useOAuthConnect, useDisconnect } from '../../hooks/useConnections';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
import { ColumnConfig } from '../FormGenerator';
|
|
||||||
import { EditFieldConfig } from '../ui/Popup';
|
|
||||||
import {
|
|
||||||
Connection,
|
|
||||||
CreateConnectionData,
|
|
||||||
ConnectionsLogicReturn,
|
|
||||||
TableAction
|
|
||||||
} from './connectionsInterfaces';
|
|
||||||
|
|
||||||
export function useConnectionsLogic(): ConnectionsLogicReturn {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const {
|
|
||||||
connections,
|
|
||||||
fetchConnections,
|
|
||||||
createConnection,
|
|
||||||
updateConnection,
|
|
||||||
deleteConnection,
|
|
||||||
refreshMicrosoftToken,
|
|
||||||
refreshGoogleToken,
|
|
||||||
isLoading,
|
|
||||||
error
|
|
||||||
} = useConnections();
|
|
||||||
|
|
||||||
const {
|
|
||||||
connectWithPopup,
|
|
||||||
isConnecting,
|
|
||||||
error: connectError
|
|
||||||
} = useOAuthConnect();
|
|
||||||
|
|
||||||
const {
|
|
||||||
disconnect,
|
|
||||||
isDisconnecting,
|
|
||||||
error: disconnectError
|
|
||||||
} = useDisconnect();
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const [editPopupOpen, setEditPopupOpen] = useState(false);
|
|
||||||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
|
||||||
|
|
||||||
// Define field configuration for editing connections
|
|
||||||
const connectionEditFields: EditFieldConfig[] = [
|
|
||||||
{
|
|
||||||
key: 'authority',
|
|
||||||
label: t('connections.field.service', 'Service'),
|
|
||||||
type: 'readonly',
|
|
||||||
editable: false,
|
|
||||||
formatter: (value: string) => {
|
|
||||||
if (!value) return t('connections.unknown', 'Unknown');
|
|
||||||
const labels = {
|
|
||||||
'google': t('connections.service.google', 'Google'),
|
|
||||||
'msft': t('connections.service.microsoft', 'Microsoft'),
|
|
||||||
'local': t('connections.service.local', 'Local')
|
|
||||||
};
|
|
||||||
return labels[value as keyof typeof labels] || value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: t('connections.field.status', 'Status'),
|
|
||||||
type: 'readonly',
|
|
||||||
editable: false,
|
|
||||||
formatter: (value: string) => value ? value.charAt(0).toUpperCase() + value.slice(1) : t('connections.unknown', 'Unknown')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'externalUsername',
|
|
||||||
label: t('connections.field.external_username', 'External Username'),
|
|
||||||
type: 'string',
|
|
||||||
editable: true,
|
|
||||||
required: false,
|
|
||||||
placeholder: t('connections.placeholder.external_username', 'Enter external username')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'externalEmail',
|
|
||||||
label: t('connections.field.external_email', 'External Email'),
|
|
||||||
type: 'email',
|
|
||||||
editable: true,
|
|
||||||
required: false,
|
|
||||||
placeholder: t('connections.placeholder.external_email', 'Enter external email address')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'connectedAt',
|
|
||||||
label: t('connections.field.connected_at', 'Connected At'),
|
|
||||||
type: 'readonly',
|
|
||||||
editable: false,
|
|
||||||
formatter: (value: number) => {
|
|
||||||
if (!value) return t('connections.not_available', 'N/A');
|
|
||||||
try {
|
|
||||||
// Convert from seconds to milliseconds for Date constructor
|
|
||||||
const date = new Date(value * 1000);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
const timezoneOffset = date.getTimezoneOffset();
|
|
||||||
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
|
|
||||||
const offsetMinutes = Math.abs(timezoneOffset) % 60;
|
|
||||||
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
|
|
||||||
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
|
|
||||||
} catch {
|
|
||||||
return t('connections.invalid_date', 'Invalid Date');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'lastChecked',
|
|
||||||
label: t('connections.field.last_checked', 'Last Checked'),
|
|
||||||
type: 'readonly',
|
|
||||||
editable: false,
|
|
||||||
formatter: (value: number) => {
|
|
||||||
if (!value) return t('connections.not_available', 'N/A');
|
|
||||||
try {
|
|
||||||
// Convert from seconds to milliseconds for Date constructor
|
|
||||||
const date = new Date(value * 1000);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
const timezoneOffset = date.getTimezoneOffset();
|
|
||||||
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
|
|
||||||
const offsetMinutes = Math.abs(timezoneOffset) % 60;
|
|
||||||
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
|
|
||||||
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
|
|
||||||
} catch {
|
|
||||||
return t('connections.invalid_date', 'Invalid Date');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Define custom columns for the connections table
|
|
||||||
const connectionColumns: ColumnConfig[] = [
|
|
||||||
{
|
|
||||||
key: 'authority',
|
|
||||||
label: t('connections.field.service', 'Service'),
|
|
||||||
type: 'enum',
|
|
||||||
filterOptions: ['google', 'msft', 'local'],
|
|
||||||
formatter: (value: string) => {
|
|
||||||
if (!value) return t('connections.unknown', 'Unknown');
|
|
||||||
const labels = {
|
|
||||||
'google': t('connections.service.google', 'Google'),
|
|
||||||
'msft': t('connections.service.microsoft', 'Microsoft'),
|
|
||||||
'local': t('connections.service.local', 'Local')
|
|
||||||
};
|
|
||||||
return labels[value as keyof typeof labels] || value;
|
|
||||||
},
|
|
||||||
width: 150,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: t('connections.field.status', 'Status'),
|
|
||||||
type: 'enum',
|
|
||||||
filterOptions: ['active', 'pending', 'expired', 'revoked'],
|
|
||||||
formatter: (value: string) => {
|
|
||||||
const status = value?.charAt(0).toUpperCase() + value?.slice(1) || t('connections.unknown', 'Unknown');
|
|
||||||
return status;
|
|
||||||
},
|
|
||||||
width: 120,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
cellClassName: (value: string) => {
|
|
||||||
return value === 'expired' ? 'expired-connection' : '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'externalUsername',
|
|
||||||
label: t('connections.field.external_username', 'External Username'),
|
|
||||||
type: 'string',
|
|
||||||
width: 200,
|
|
||||||
sortable: true,
|
|
||||||
filterable: false,
|
|
||||||
searchable: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'externalEmail',
|
|
||||||
label: t('connections.field.external_email', 'External Email'),
|
|
||||||
type: 'string',
|
|
||||||
width: 250,
|
|
||||||
sortable: true,
|
|
||||||
filterable: false,
|
|
||||||
searchable: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'connectedAt',
|
|
||||||
label: t('connections.field.connected_at', 'Connected At'),
|
|
||||||
type: 'date',
|
|
||||||
width: 150,
|
|
||||||
sortable: true,
|
|
||||||
filterable: false,
|
|
||||||
searchable: true,
|
|
||||||
formatter: (value: number) => {
|
|
||||||
if (!value) return t('connections.not_available', 'N/A');
|
|
||||||
try {
|
|
||||||
// Convert from seconds to milliseconds for Date constructor
|
|
||||||
const date = new Date(value * 1000);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
const timezoneOffset = date.getTimezoneOffset();
|
|
||||||
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
|
|
||||||
const offsetMinutes = Math.abs(timezoneOffset) % 60;
|
|
||||||
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
|
|
||||||
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
|
|
||||||
} catch {
|
|
||||||
return t('connections.invalid_date', 'Invalid Date');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'lastChecked',
|
|
||||||
label: t('connections.field.last_checked', 'Last Checked'),
|
|
||||||
type: 'date',
|
|
||||||
width: 150,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
formatter: (value: number) => {
|
|
||||||
if (!value) return t('connections.not_available', 'N/A');
|
|
||||||
try {
|
|
||||||
// Convert from seconds to milliseconds for Date constructor
|
|
||||||
const date = new Date(value * 1000);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
const timezoneOffset = date.getTimezoneOffset();
|
|
||||||
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
|
|
||||||
const offsetMinutes = Math.abs(timezoneOffset) % 60;
|
|
||||||
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
|
|
||||||
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
|
|
||||||
} catch {
|
|
||||||
return t('connections.invalid_date', 'Invalid Date');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'expiresAt',
|
|
||||||
label: t('connections.field.expires_at', 'Expires At'),
|
|
||||||
type: 'date',
|
|
||||||
width: 150,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
formatter: (value: number) => {
|
|
||||||
if (!value) return t('connections.not_available', 'N/A');
|
|
||||||
try {
|
|
||||||
// Convert from seconds to milliseconds for Date constructor
|
|
||||||
const date = new Date(value * 1000);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
const timezoneOffset = date.getTimezoneOffset();
|
|
||||||
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
|
|
||||||
const offsetMinutes = Math.abs(timezoneOffset) % 60;
|
|
||||||
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
|
|
||||||
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
|
|
||||||
} catch {
|
|
||||||
return t('connections.invalid_date', 'Invalid Date');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Fetch connections on mount and auto-connect them
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeConnections = async () => {
|
|
||||||
try {
|
|
||||||
await fetchConnections();
|
|
||||||
// Auto-connect all connections that are not active
|
|
||||||
const connectionsToConnect = connections.filter(conn =>
|
|
||||||
conn.status !== 'active' && conn.authority !== 'local'
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const connection of connectionsToConnect) {
|
|
||||||
try {
|
|
||||||
await connectWithPopup(connection.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to auto-connect ${connection.authority} connection:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing connections:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeConnections();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handler functions
|
|
||||||
|
|
||||||
const handleConnect = async (connection: Connection) => {
|
|
||||||
console.log('Connecting to service:', connection);
|
|
||||||
try {
|
|
||||||
await connectWithPopup(connection.id);
|
|
||||||
await fetchConnections();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error connecting to service:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateConnection = async (type: 'msft' | 'google') => {
|
|
||||||
console.log('Creating connection for type:', type);
|
|
||||||
try {
|
|
||||||
// Get current UTC timestamp in seconds (float) to match backend
|
|
||||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const connectionData: CreateConnectionData = {
|
|
||||||
type: type,
|
|
||||||
status: 'pending',
|
|
||||||
connectedAt: currentTimestamp,
|
|
||||||
lastChecked: currentTimestamp
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Sending connection data to backend:', connectionData);
|
|
||||||
const newConnection = await createConnection(connectionData);
|
|
||||||
console.log('Connection created successfully:', newConnection);
|
|
||||||
handleConnect(newConnection);
|
|
||||||
await fetchConnections();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating connection:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleDisconnect = async (connection: Connection) => {
|
|
||||||
console.log('Disconnecting from service:', connection);
|
|
||||||
try {
|
|
||||||
await disconnect(connection.id);
|
|
||||||
await fetchConnections();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error disconnecting from service:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (connection: Connection) => {
|
|
||||||
const serviceName = connection.authority?.charAt(0).toUpperCase() + connection.authority?.slice(1) || t('connections.unknown', 'Unknown');
|
|
||||||
const confirmMessage = t('connections.confirm_delete', 'Are you sure you want to delete the {service} connection?').replace('{service}', serviceName);
|
|
||||||
|
|
||||||
if (window.confirm(confirmMessage)) {
|
|
||||||
try {
|
|
||||||
await deleteConnection(connection.id);
|
|
||||||
await fetchConnections();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting connection:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteMultiple = async (connections: Connection[]) => {
|
|
||||||
const confirmMessage = t('connections.confirm_delete_multiple', 'Are you sure you want to delete {count} connections?').replace('{count}', connections.length.toString());
|
|
||||||
|
|
||||||
if (window.confirm(confirmMessage)) {
|
|
||||||
try {
|
|
||||||
// Delete all connections in parallel
|
|
||||||
await Promise.all(connections.map(connection => deleteConnection(connection.id)));
|
|
||||||
await fetchConnections();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting multiple connections:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateConnection = async (connection: Connection) => {
|
|
||||||
console.log('Updating connection:', connection);
|
|
||||||
try {
|
|
||||||
if (connection.status === 'expired') {
|
|
||||||
// Refresh token based on connection type
|
|
||||||
if (connection.authority === 'msft') {
|
|
||||||
await refreshMicrosoftToken(connection.id);
|
|
||||||
} else if (connection.authority === 'google') {
|
|
||||||
await refreshGoogleToken(connection.id);
|
|
||||||
}
|
|
||||||
} else if (connection.status !== 'active') {
|
|
||||||
// If not active and not expired, try to connect
|
|
||||||
await handleConnect(connection);
|
|
||||||
}
|
|
||||||
await fetchConnections();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating connection:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditConnection = async (connection: Connection) => {
|
|
||||||
console.log('Editing connection:', connection);
|
|
||||||
setEditingConnection(connection);
|
|
||||||
setEditPopupOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveConnection = async (updatedConnection: Connection) => {
|
|
||||||
if (!editingConnection) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updateData = {
|
|
||||||
externalUsername: updatedConnection.externalUsername,
|
|
||||||
externalEmail: updatedConnection.externalEmail
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateConnection(editingConnection.id, updateData);
|
|
||||||
console.log('Connection updated successfully');
|
|
||||||
await fetchConnections();
|
|
||||||
setEditPopupOpen(false);
|
|
||||||
setEditingConnection(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating connection:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
setEditPopupOpen(false);
|
|
||||||
setEditingConnection(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table actions
|
|
||||||
const tableActions: TableAction[] = [
|
|
||||||
{
|
|
||||||
type: 'edit',
|
|
||||||
title: t('connections.action.edit', 'Edit'),
|
|
||||||
onAction: handleEditConnection
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'delete',
|
|
||||||
title: t('connections.action.delete', 'Delete')
|
|
||||||
// onAction is handled by FormGenerator for delete confirmation
|
|
||||||
}
|
|
||||||
] as any;
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Data
|
|
||||||
connections,
|
|
||||||
isLoading,
|
|
||||||
isConnecting,
|
|
||||||
isDisconnecting,
|
|
||||||
error,
|
|
||||||
connectError,
|
|
||||||
disconnectError,
|
|
||||||
editPopupOpen,
|
|
||||||
editingConnection,
|
|
||||||
connectionColumns,
|
|
||||||
connectionEditFields,
|
|
||||||
tableActions,
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
handleCreateConnection,
|
|
||||||
handleConnect,
|
|
||||||
handleDisconnect,
|
|
||||||
handleDelete,
|
|
||||||
handleDeleteMultiple,
|
|
||||||
handleUpdateConnection,
|
|
||||||
handleEditConnection,
|
|
||||||
handleSaveConnection,
|
|
||||||
handleCancelEdit
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
// Export all components
|
|
||||||
export { ConnectionsTable } from './ConnectionsTable';
|
|
||||||
export { ConnectionEditModal } from './ConnectionEditModal';
|
|
||||||
export { ConnectionsErrorDisplay } from './ConnectionsErrorDisplay';
|
|
||||||
|
|
||||||
// Export logic hook
|
|
||||||
export { useConnectionsLogic } from './connectionsLogic';
|
|
||||||
|
|
||||||
// Export all interfaces and types
|
|
||||||
export * from './connectionsInterfaces';
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { DashboardChatProps } from "./dashboardChatAreaTypes";
|
|
||||||
import DashboardChatArea from './DashboardChatArea.tsx';
|
|
||||||
|
|
||||||
import styles from './DashboardChatAreaStyles/DashboardChat.module.css';
|
|
||||||
|
|
||||||
const DashboardChat: React.FC<DashboardChatProps> = ({
|
|
||||||
workflowState,
|
|
||||||
workflowActions
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={styles.dashboard_chat}>
|
|
||||||
<DashboardChatArea
|
|
||||||
workflowState={workflowState}
|
|
||||||
workflowActions={workflowActions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardChat;
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
import MessageList from "./DashboardChatAreaMessageList";
|
|
||||||
|
|
||||||
import InputArea from "./DashboardChatAreaInput";
|
|
||||||
import ConnectedFiles from "./DashboardChatAreaConnectedFiles";
|
|
||||||
import { DashboardChatAreaProps } from "./dashboardChatAreaTypes";
|
|
||||||
import styles from './DashboardChatAreaStyles/DashboardChat.module.css';
|
|
||||||
|
|
||||||
const DashboardChatArea: React.FC<DashboardChatAreaProps> = ({
|
|
||||||
workflowState,
|
|
||||||
workflowActions
|
|
||||||
}) => {
|
|
||||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
|
||||||
const [attachedFiles, setAttachedFiles] = useState<any[]>([]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.chat_grid}>
|
|
||||||
{/* Top Left: Message List */}
|
|
||||||
<div className={`${styles.quadrant} ${styles.messages_quadrant}`}>
|
|
||||||
<MessageList
|
|
||||||
workflowState={workflowState}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/*Top Right: File Preview (disabled for now)
|
|
||||||
<div className={`${styles.quadrant} ${styles.file_preview_quadrant}`}>
|
|
||||||
<FilePreview selectedFile={selectedFile} />
|
|
||||||
</div>
|
|
||||||
*/}
|
|
||||||
|
|
||||||
{/* Bottom Left: Input Area */}
|
|
||||||
<div className={`${styles.quadrant} ${styles.input_quadrant}`}>
|
|
||||||
<InputArea
|
|
||||||
workflowState={workflowState}
|
|
||||||
workflowActions={workflowActions}
|
|
||||||
attachedFiles={attachedFiles}
|
|
||||||
onAttachedFilesChange={setAttachedFiles}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Right: Connected Files */}
|
|
||||||
<div className={`${styles.quadrant} ${styles.connected_files_quadrant}`}>
|
|
||||||
<ConnectedFiles
|
|
||||||
onFileSelect={setSelectedFile}
|
|
||||||
selectedFile={selectedFile}
|
|
||||||
attachedFiles={attachedFiles}
|
|
||||||
onRemoveFile={(fileId) => {
|
|
||||||
// If the removed file is currently selected, clear the selection
|
|
||||||
if (selectedFile?.id === fileId) {
|
|
||||||
setSelectedFile(null);
|
|
||||||
}
|
|
||||||
// Remove the file from attached files
|
|
||||||
setAttachedFiles(files => files.filter(f => f.id !== fileId));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardChatArea;
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { ConnectedFilesProps } from './dashboardChatAreaTypes';
|
|
||||||
import styles from './DashboardChatAreaStyles/DashboardChatConnectedFiles.module.css';
|
|
||||||
import { IoIosAttach } from 'react-icons/io';
|
|
||||||
|
|
||||||
const ConnectedFiles: React.FC<ConnectedFilesProps> = ({
|
|
||||||
onFileSelect,
|
|
||||||
selectedFile,
|
|
||||||
attachedFiles = [],
|
|
||||||
onRemoveFile
|
|
||||||
}) => {
|
|
||||||
const convertedFiles = attachedFiles.map(file => ({
|
|
||||||
id: file.id,
|
|
||||||
name: file.name,
|
|
||||||
mimeType: file.type,
|
|
||||||
size: file.size,
|
|
||||||
creationDate: new Date().toISOString(),
|
|
||||||
fileData: file.fileData,
|
|
||||||
objectUrl: file.objectUrl
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
|
|
||||||
return Math.round(bytes / (1024 * 1024)) + ' MB';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
{attachedFiles.length > 0 && (
|
|
||||||
<div className={styles.attachedInfo}>
|
|
||||||
<IoIosAttach className={styles.attachedInfoIcon}/> {attachedFiles.length} file{attachedFiles.length !== 1 ? 's' : ''} attached for workflow
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{convertedFiles.length === 0 ? (
|
|
||||||
<p className={styles.emptyState}>
|
|
||||||
No files connected to this workflow
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className={styles.filesList}>
|
|
||||||
{convertedFiles.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
onClick={() => onFileSelect?.(file)}
|
|
||||||
className={`${styles.fileItem} ${selectedFile?.id === file.id ? styles.selected : ''} ${styles.attached}`}
|
|
||||||
>
|
|
||||||
|
|
||||||
<div className={styles.fileInfo}>
|
|
||||||
<div className={styles.fileName}>
|
|
||||||
{file.name}
|
|
||||||
</div>
|
|
||||||
<div className={styles.fileSize}>
|
|
||||||
{file.size ? formatFileSize(file.size) : 'Unknown size'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.fileActions}>
|
|
||||||
{onRemoveFile && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemoveFile(file.id);
|
|
||||||
}}
|
|
||||||
className={styles.removeButton}
|
|
||||||
title="Remove from attachment"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConnectedFiles;
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { useFilePreview } from '../../../hooks/useWorkflows';
|
|
||||||
import { FileInfo } from './dashboardChatAreaTypes';
|
|
||||||
|
|
||||||
interface AttachedFileWithData extends FileInfo {
|
|
||||||
fileData?: File;
|
|
||||||
objectUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilePreviewProps {
|
|
||||||
selectedFile?: AttachedFileWithData | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilePreview: React.FC<FilePreviewProps> = ({ selectedFile }) => {
|
|
||||||
const { previewContent, fileMetadata, isLoading, error, fetchPreview } = useFilePreview();
|
|
||||||
const [imageUrl, setImageUrl] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
// Handle base64 image data from backend
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (fileMetadata && fileMetadata.base64Encoded && fileMetadata.preview) {
|
|
||||||
const isImage = fileMetadata.mimeType?.startsWith('image/') ||
|
|
||||||
selectedFile?.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/);
|
|
||||||
|
|
||||||
if (isImage) {
|
|
||||||
|
|
||||||
const dataUrl = `data:${fileMetadata.mimeType || 'image/png'};base64,${fileMetadata.preview}`;
|
|
||||||
setImageUrl(dataUrl);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [fileMetadata, selectedFile]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// Clean up previous object URL
|
|
||||||
const currentImageUrl = imageUrl;
|
|
||||||
if (currentImageUrl && currentImageUrl.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(currentImageUrl);
|
|
||||||
}
|
|
||||||
setImageUrl(null);
|
|
||||||
|
|
||||||
if (selectedFile?.id) {
|
|
||||||
|
|
||||||
// Check if it's an image file (either from mimeType or file extension)
|
|
||||||
const isImage = selectedFile.mimeType?.startsWith('image/') ||
|
|
||||||
selectedFile.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/);
|
|
||||||
|
|
||||||
if (isImage) {
|
|
||||||
// If it's an attached file with file data, create object URL for preview
|
|
||||||
if (selectedFile.fileData) {
|
|
||||||
const url = URL.createObjectURL(selectedFile.fileData);
|
|
||||||
setImageUrl(url);
|
|
||||||
} else if (selectedFile.objectUrl) {
|
|
||||||
setImageUrl(selectedFile.objectUrl);
|
|
||||||
} else if (selectedFile.downloadUrl) {
|
|
||||||
setImageUrl(selectedFile.downloadUrl);
|
|
||||||
} else {
|
|
||||||
// For existing uploaded files, fetch the image data
|
|
||||||
fetchPreview(selectedFile.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For non-image files, try to fetch preview
|
|
||||||
fetchPreview(selectedFile.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
|
||||||
if (currentImageUrl && currentImageUrl.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(currentImageUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [selectedFile?.id, selectedFile?.fileData, selectedFile?.objectUrl]);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
React.useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (imageUrl) {
|
|
||||||
URL.revokeObjectURL(imageUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getFileType = (mimeType?: string) => {
|
|
||||||
if (!mimeType) return 'Unknown';
|
|
||||||
if (mimeType.startsWith('image/')) return 'Image';
|
|
||||||
if (mimeType.startsWith('text/')) return 'Text';
|
|
||||||
if (mimeType.includes('pdf')) return 'PDF';
|
|
||||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'Spreadsheet';
|
|
||||||
return 'Document';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '16px', height: '100%', overflow: 'auto' }}>
|
|
||||||
|
|
||||||
|
|
||||||
{!selectedFile && (
|
|
||||||
<p style={{ color: 'var(--color-gray)', textAlign: 'center' }}>
|
|
||||||
Select a file to preview
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedFile && (
|
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
padding: '12px',
|
|
||||||
backgroundColor: 'var(--color-surface)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}}>
|
|
||||||
<h4 style={{ margin: '0 0 8px 0' }}>{selectedFile.name}</h4>
|
|
||||||
<div style={{ fontSize: '12px', color: 'var(--color-gray)' }}>
|
|
||||||
Type: {getFileType(selectedFile.mimeType)} •
|
|
||||||
Size: {selectedFile.size ? Math.round(selectedFile.size / 1024) + ' KB' : 'Unknown'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image Preview - Show first for images */}
|
|
||||||
{(selectedFile.mimeType?.startsWith('image/') || selectedFile.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/)) && imageUrl ? (
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={selectedFile.name}
|
|
||||||
style={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '500px',
|
|
||||||
objectFit: 'contain',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid var(--color-gray-disabled)',
|
|
||||||
display: 'block',
|
|
||||||
margin: '0 auto'
|
|
||||||
}}
|
|
||||||
onLoad={() => console.log('Image loaded successfully')}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error('Image failed to load:', e);
|
|
||||||
console.log('Image URL:', imageUrl);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (selectedFile.mimeType?.startsWith('image/') || selectedFile.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/)) ? (
|
|
||||||
<div style={{
|
|
||||||
padding: '20px',
|
|
||||||
textAlign: 'center',
|
|
||||||
backgroundColor: 'var(--color-surface)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}}>
|
|
||||||
<p>🖼️ Image preview loading...</p>
|
|
||||||
<small style={{ color: 'var(--color-gray)' }}>
|
|
||||||
Debug: imageUrl={imageUrl ? 'yes' : 'no'}, downloadUrl={selectedFile.downloadUrl ? 'yes' : 'no'},
|
|
||||||
fileData={selectedFile.fileData ? 'yes' : 'no'}, objectUrl={selectedFile.objectUrl ? 'yes' : 'no'}
|
|
||||||
<br />
|
|
||||||
MimeType: {selectedFile.mimeType}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Text/Code Preview - Only for non-images and when we don't have an image URL */}
|
|
||||||
{!(selectedFile.mimeType?.startsWith('image/') || selectedFile.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/)) && !imageUrl && (
|
|
||||||
<>
|
|
||||||
{isLoading && <p>Loading preview...</p>}
|
|
||||||
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
|
|
||||||
|
|
||||||
{previewContent && (
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'var(--color-surface)',
|
|
||||||
padding: '16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: '400px'
|
|
||||||
}}>
|
|
||||||
{previewContent}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!previewContent && !isLoading && !error && (
|
|
||||||
<p style={{ color: 'var(--color-gray)' }}>
|
|
||||||
Preview not available for this file type
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FilePreview;
|
|
||||||
|
|
@ -1,364 +0,0 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import FileAttachmentPopup from './FileAttachmentPopup';
|
|
||||||
import { InputAreaProps, AttachedFile } from './dashboardChatAreaTypes';
|
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
|
||||||
import { usePrompts } from '../../../hooks/usePrompts';
|
|
||||||
|
|
||||||
import styles from './DashboardChatAreaStyles/DashboardChatAreaInput.module.css';
|
|
||||||
import sharedStyles from './DashboardChatAreaStyles/DashboardChat.module.css';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const InputArea: React.FC<InputAreaProps> = ({
|
|
||||||
workflowState,
|
|
||||||
workflowActions,
|
|
||||||
attachedFiles: externalAttachedFiles = [],
|
|
||||||
onAttachedFilesChange
|
|
||||||
}) => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const { prompts, loading: promptsLoading } = usePrompts();
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [showFilePopup, setShowFilePopup] = useState(false);
|
|
||||||
const [isSending, setIsSending] = useState(false);
|
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Always use external attached files from parent component
|
|
||||||
const currentAttachedFiles = externalAttachedFiles;
|
|
||||||
|
|
||||||
// Auto-resize textarea function
|
|
||||||
const adjustTextareaHeight = () => {
|
|
||||||
const textarea = textareaRef.current;
|
|
||||||
if (!textarea) return;
|
|
||||||
|
|
||||||
// Reset height to auto to get the actual scroll height
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
|
|
||||||
// Calculate the height based on content
|
|
||||||
const scrollHeight = textarea.scrollHeight;
|
|
||||||
const lineHeight = 1.5 * 14; // 1.5em * 14px font size
|
|
||||||
const padding = 32; // 16px top + 16px bottom padding
|
|
||||||
const minHeight = lineHeight * 4 + padding; // 4 rows
|
|
||||||
const maxHeight = lineHeight * 8 + padding; // 8 rows
|
|
||||||
|
|
||||||
// Set height within constraints
|
|
||||||
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
|
|
||||||
textarea.style.height = `${newHeight}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the current selected prompt from workflow state
|
|
||||||
const currentSelectedPrompt = workflowState.selectedPrompt;
|
|
||||||
|
|
||||||
// Auto-fill input when prompt is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSelectedPrompt) {
|
|
||||||
setInputValue(currentSelectedPrompt.content);
|
|
||||||
}
|
|
||||||
}, [currentSelectedPrompt]);
|
|
||||||
|
|
||||||
// Adjust height when input value changes
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight();
|
|
||||||
}, [inputValue]);
|
|
||||||
|
|
||||||
// Initial resize on mount
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!inputValue.trim() || isSending) return;
|
|
||||||
|
|
||||||
setIsSending(true);
|
|
||||||
setSendError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileIds = currentAttachedFiles.map(f => f.id);
|
|
||||||
let success = false;
|
|
||||||
|
|
||||||
if (workflowState.currentWorkflowId) {
|
|
||||||
success = await workflowActions.continueWorkflow(inputValue, fileIds);
|
|
||||||
} else {
|
|
||||||
const newWorkflowId = await workflowActions.startNewWorkflow(inputValue, fileIds);
|
|
||||||
success = !!newWorkflowId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setInputValue('');
|
|
||||||
if (onAttachedFilesChange) {
|
|
||||||
onAttachedFilesChange([]);
|
|
||||||
}
|
|
||||||
// Prompt was used
|
|
||||||
} else {
|
|
||||||
setSendError('Failed to send message. Please try again.');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to send message:', error);
|
|
||||||
setSendError(error.message || 'Failed to send message. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStop = async () => {
|
|
||||||
// Stop the workflow but keep it selected
|
|
||||||
await workflowActions.stopWorkflow();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilesAttached = (files: AttachedFile[]) => {
|
|
||||||
setShowFilePopup(false);
|
|
||||||
if (onAttachedFilesChange) {
|
|
||||||
onAttachedFilesChange(files);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePromptSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const promptId = e.target.value;
|
|
||||||
if (promptId === '') {
|
|
||||||
workflowActions.clearPrompt();
|
|
||||||
setInputValue('');
|
|
||||||
} else {
|
|
||||||
const prompt = prompts.find(p => p.id === promptId);
|
|
||||||
if (prompt) {
|
|
||||||
workflowActions.selectPrompt(prompt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearPrompt = () => {
|
|
||||||
workflowActions.clearPrompt();
|
|
||||||
setInputValue('');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Drag and drop handlers
|
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.dataTransfer.types.includes('Files')) {
|
|
||||||
setIsDragOver(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
// Only hide drag over if we're leaving the drop zone entirely
|
|
||||||
if (dropZoneRef.current && !dropZoneRef.current.contains(e.relatedTarget as Node)) {
|
|
||||||
setIsDragOver(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
// Set the drop effect to copy
|
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
if (shouldShowStopButton) {
|
|
||||||
// Don't allow file drops when workflow is active
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
if (files.length > 0) {
|
|
||||||
// Convert File objects to AttachedFile format
|
|
||||||
const attachedFiles: AttachedFile[] = files.map((file, index) => ({
|
|
||||||
id: (Date.now() + index).toString(), // Simple ID generation
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
fileData: file,
|
|
||||||
objectUrl: URL.createObjectURL(file)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Add to existing attached files
|
|
||||||
const updatedFiles = [...currentAttachedFiles, ...attachedFiles];
|
|
||||||
if (onAttachedFilesChange) {
|
|
||||||
onAttachedFilesChange(updatedFiles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Check if workflow is in an active state where we should show stop button
|
|
||||||
const isWorkflowActive = workflowState.workflow &&
|
|
||||||
['running', 'processing', 'started'].includes(workflowState.workflow.status);
|
|
||||||
|
|
||||||
const shouldShowStopButton = !!isWorkflowActive;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Determine if label should be in focused/moved state
|
|
||||||
const shouldLabelBeFocused = isFocused || inputValue.trim().length > 0;
|
|
||||||
|
|
||||||
// Get placeholder text
|
|
||||||
const placeholderText = workflowState.currentWorkflowId
|
|
||||||
? t('chat.input.continue_workflow')
|
|
||||||
: t('chat.input.enter_message');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={dropZoneRef}
|
|
||||||
className={`${styles.input_area_container} ${isDragOver ? styles.drag_over : ''}`}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
|
|
||||||
{/* Error messages */}
|
|
||||||
{(sendError || workflowState.error) && (
|
|
||||||
<div className={styles.error_message}>
|
|
||||||
{t('chat.input.error_prefix')} {sendError || workflowState.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drag and drop overlay */}
|
|
||||||
{isDragOver && (
|
|
||||||
<div className={`${styles.drag_overlay} ${shouldShowStopButton ? styles.disabled : ''}`}>
|
|
||||||
<div className={styles.drag_overlay_content}>
|
|
||||||
<div className={styles.drag_icon}>
|
|
||||||
{shouldShowStopButton ? '🚫' : '📁'}
|
|
||||||
</div>
|
|
||||||
<div className={styles.drag_text}>
|
|
||||||
{shouldShowStopButton
|
|
||||||
? t('chat.input.drop_disabled')
|
|
||||||
: t('chat.input.drop_files_here')
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Prompt selection dropdown */}
|
|
||||||
<div className={styles.prompt_selection_container}>
|
|
||||||
<div className={styles.prompt_dropdown_wrapper}>
|
|
||||||
<select
|
|
||||||
value={currentSelectedPrompt?.id || ''}
|
|
||||||
onChange={handlePromptSelect}
|
|
||||||
disabled={isSending || shouldShowStopButton || promptsLoading}
|
|
||||||
className={styles.prompt_dropdown}
|
|
||||||
>
|
|
||||||
<option value="">{promptsLoading ? t('chat.input.loading_prompts') : t('chat.input.select_prompt')}</option>
|
|
||||||
{prompts && Array.isArray(prompts) && prompts.map(prompt => (
|
|
||||||
<option key={prompt.id} value={prompt.id}>
|
|
||||||
{prompt.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{currentSelectedPrompt && (
|
|
||||||
<button
|
|
||||||
onClick={handleClearPrompt}
|
|
||||||
disabled={isSending || shouldShowStopButton}
|
|
||||||
className={styles.clear_prompt_button}
|
|
||||||
title={t('chat.input.clear_prompt')}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.input_form_container}>
|
|
||||||
<div className={styles.floating_label_textarea}>
|
|
||||||
<label
|
|
||||||
className={shouldLabelBeFocused ? styles.textarea_label_focused : styles.textarea_label}
|
|
||||||
>
|
|
||||||
{placeholderText}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInputValue(e.target.value);
|
|
||||||
setTimeout(adjustTextareaHeight, 0);
|
|
||||||
}}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={() => setIsFocused(false)}
|
|
||||||
placeholder=""
|
|
||||||
disabled={isSending || shouldShowStopButton}
|
|
||||||
className={`${styles.message_textarea} ${
|
|
||||||
!isFocused && inputValue.trim().length > 0 ? styles.message_textarea_with_content : ''
|
|
||||||
}`}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.input_actions_row}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFilePopup(true)}
|
|
||||||
disabled={isSending || shouldShowStopButton}
|
|
||||||
className={`${sharedStyles.button_secondary} ${
|
|
||||||
(isSending || shouldShowStopButton) ? styles.disabled : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t('chat.input.attach_files')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{shouldShowStopButton ? (
|
|
||||||
<button
|
|
||||||
onClick={handleStop}
|
|
||||||
disabled={isSending}
|
|
||||||
className={`${sharedStyles.button_primary} ${
|
|
||||||
isSending ? styles.disabled : styles.enabled
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isSending ? t('chat.input.stopping') : t('chat.input.stop')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!inputValue.trim() || isSending}
|
|
||||||
className={`${sharedStyles.button_primary} ${
|
|
||||||
(!inputValue.trim() || isSending)
|
|
||||||
? styles.disabled
|
|
||||||
: styles.enabled
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isSending ? t('chat.input.sending') :
|
|
||||||
workflowState.currentWorkflowId ? t('chat.input.continue') : t('chat.input.send')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{workflowState.currentWorkflowId && !shouldShowStopButton && (
|
|
||||||
<button
|
|
||||||
onClick={() => workflowActions.clearWorkflow()}
|
|
||||||
className={sharedStyles.button_primary}
|
|
||||||
>
|
|
||||||
{t('chat.input.new_chat')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File Attachment Popup */}
|
|
||||||
{showFilePopup && (
|
|
||||||
<FileAttachmentPopup
|
|
||||||
onClose={() => setShowFilePopup(false)}
|
|
||||||
onFilesSelected={handleFilesAttached}
|
|
||||||
currentAttachedFiles={currentAttachedFiles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InputArea;
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { WorkflowLog } from "./dashboardChatAreaTypes";
|
|
||||||
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
|
|
||||||
|
|
||||||
interface LogItemProps {
|
|
||||||
log: WorkflowLog;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LogItem: React.FC<LogItemProps> = ({ log }) => {
|
|
||||||
|
|
||||||
|
|
||||||
// Format timestamp with robust parsing (same logic as MessageList)
|
|
||||||
const formatTimestamp = (timestamp: any) => {
|
|
||||||
|
|
||||||
|
|
||||||
if (!timestamp) {
|
|
||||||
|
|
||||||
return 'No timestamp';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different timestamp formats (same as safeParseDate)
|
|
||||||
let dateToTry = timestamp;
|
|
||||||
|
|
||||||
// If it's a number, check if it's in seconds or milliseconds
|
|
||||||
if (typeof timestamp === 'number') {
|
|
||||||
// If it's a 10-digit number, it's likely seconds since epoch
|
|
||||||
if (timestamp < 10000000000) {
|
|
||||||
dateToTry = timestamp * 1000; // Convert seconds to milliseconds
|
|
||||||
} else {
|
|
||||||
dateToTry = timestamp; // Already in milliseconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If it's a string that looks like a number, parse it and handle seconds/milliseconds
|
|
||||||
else if (typeof timestamp === 'string' && /^\d+$/.test(timestamp)) {
|
|
||||||
const numericTimestamp = parseInt(timestamp);
|
|
||||||
// If it's a 10-digit number, it's likely seconds since epoch
|
|
||||||
if (numericTimestamp < 10000000000) {
|
|
||||||
dateToTry = numericTimestamp * 1000; // Convert seconds to milliseconds
|
|
||||||
} else {
|
|
||||||
dateToTry = numericTimestamp; // Already in milliseconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If it's already a Date object
|
|
||||||
else if (timestamp instanceof Date) {
|
|
||||||
dateToTry = timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(dateToTry);
|
|
||||||
|
|
||||||
// Check if the date is valid
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
console.warn(`⚠️ LogItem: Invalid timestamp detected:`, {
|
|
||||||
originalTimestamp: timestamp,
|
|
||||||
type: typeof timestamp,
|
|
||||||
processedTimestamp: dateToTry
|
|
||||||
});
|
|
||||||
return `Invalid: ${timestamp}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
|
||||||
|
|
||||||
let formatted = '';
|
|
||||||
if (isToday) {
|
|
||||||
formatted = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
} else {
|
|
||||||
formatted = date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
|
||||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatted;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine log level for styling
|
|
||||||
const logLevel = log.level || (log.type?.toLowerCase() as 'info' | 'warning' | 'error' | 'debug') || 'info';
|
|
||||||
|
|
||||||
// Debug: Log what the LogItem is receiving
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${messageStyles.log_container} ${messageStyles[logLevel]}`}>
|
|
||||||
<div className={messageStyles.log_header}>
|
|
||||||
<span className={`${messageStyles.log_level} ${messageStyles[logLevel]}`}>
|
|
||||||
{logLevel.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className={messageStyles.log_timestamp}>
|
|
||||||
{formatTimestamp(log.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={messageStyles.log_message}>
|
|
||||||
{log.message}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{log.source && (
|
|
||||||
<div className={messageStyles.log_source}>
|
|
||||||
Source: {log.source}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LogItem;
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Message, Document } from "./dashboardChatAreaTypes";
|
|
||||||
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
|
|
||||||
import { FilePreview } from '../../FilePreview';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface MessageItemProps {
|
|
||||||
message: Message;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number) => {
|
|
||||||
if (!bytes) return '';
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const MessageItem: React.FC<MessageItemProps> = ({ message }) => {
|
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState<boolean>(false);
|
|
||||||
const [previewingFile, setPreviewingFile] = useState<Document | null>(null);
|
|
||||||
|
|
||||||
const handlePreview = (doc: Document) => {
|
|
||||||
console.log('👁️ Opening preview for document:', doc);
|
|
||||||
|
|
||||||
// Extract the UUID file ID from the downloadUrl (same as files page)
|
|
||||||
let actualFileId: string | null = null;
|
|
||||||
|
|
||||||
if (doc.downloadUrl) {
|
|
||||||
// Extract UUID from the downloadUrl: /api/workflows/files/{UUID}/download
|
|
||||||
const match = doc.downloadUrl.match(/\/api\/workflows\/files\/([^\/]+)\/download/);
|
|
||||||
if (match) {
|
|
||||||
actualFileId = match[1];
|
|
||||||
console.log(`🔗 Extracted UUID from downloadUrl: ${actualFileId}`);
|
|
||||||
} else {
|
|
||||||
console.error('Could not extract file ID from downloadUrl:', doc.downloadUrl);
|
|
||||||
alert('Could not extract file ID from download URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (doc.id) {
|
|
||||||
// Fallback to using the document id directly
|
|
||||||
actualFileId = doc.id;
|
|
||||||
console.log(`🔗 Using document id as fileId: ${actualFileId}`);
|
|
||||||
} else {
|
|
||||||
console.error('No downloadUrl or id available in document');
|
|
||||||
alert('No file ID available for preview');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!actualFileId) {
|
|
||||||
console.error('Could not determine actual file ID');
|
|
||||||
alert('Could not determine file ID for preview');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a modified document with the correct file ID
|
|
||||||
const documentWithFileId = {
|
|
||||||
...doc,
|
|
||||||
id: actualFileId
|
|
||||||
};
|
|
||||||
|
|
||||||
setPreviewingFile(documentWithFileId);
|
|
||||||
setPreviewModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClosePreview = () => {
|
|
||||||
console.log('❌ Closing preview');
|
|
||||||
setPreviewModalOpen(false);
|
|
||||||
setPreviewingFile(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDocumentClick = (doc: Document) => {
|
|
||||||
console.log('📋 Document clicked, opening preview:', doc);
|
|
||||||
handlePreview(doc);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const hasDocuments = message.documents && message.documents.length > 0;
|
|
||||||
|
|
||||||
const formatTimestamp = (ts?: string) => {
|
|
||||||
if (!ts) return '';
|
|
||||||
const date = new Date(ts);
|
|
||||||
if (isNaN(date.getTime())) return '';
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
|
||||||
|
|
||||||
return isToday
|
|
||||||
? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
|
||||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const isUser = message.role === 'user';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={`${messageStyles.message_item} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
|
|
||||||
<div className={messageStyles.message_header}>
|
|
||||||
{isUser ? 'You' : message.agentName}
|
|
||||||
{message.timestamp && (
|
|
||||||
<span className={messageStyles.message_timestamp_inline}>
|
|
||||||
• {formatTimestamp(message.timestamp)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`${messageStyles.message_bubble} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
|
|
||||||
<div className={messageStyles.message_content}>
|
|
||||||
{message.content || <span className={messageStyles.message_no_content}>[No message content]</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasDocuments && (
|
|
||||||
<div className={`${messageStyles.message_documents} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
|
|
||||||
<div className={`${messageStyles.message_documents_header} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
|
|
||||||
{isUser ? 'Uploaded' : 'Attached'} Files ({message.documents?.length || 0})
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{message.documents!.map((doc, i) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={doc.id || i}
|
|
||||||
className={messageStyles.message_document_item}
|
|
||||||
onClick={() => handleDocumentClick(doc)}
|
|
||||||
title={`Click to preview ${doc.name}`}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={messageStyles.message_document_info}>
|
|
||||||
<div className={messageStyles.message_document_name}>
|
|
||||||
{doc.ext ? `${doc.name}.${doc.ext}` : doc.name}
|
|
||||||
</div>
|
|
||||||
{doc.size && (
|
|
||||||
<div className={messageStyles.message_document_size}>
|
|
||||||
{formatFileSize(doc.size)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={messageStyles.message_document_actions}>
|
|
||||||
{/* Preview and Download buttons disabled for now
|
|
||||||
<button
|
|
||||||
onClick={(e) => handlePreview(doc, e)}
|
|
||||||
className={messageStyles.message_document_action_button}
|
|
||||||
title="Preview file"
|
|
||||||
>
|
|
||||||
<div className={messageStyles.message_document_action_icon}>
|
|
||||||
<FaEye size={16} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleDownload(doc, e)}
|
|
||||||
disabled={isDownloading}
|
|
||||||
className={messageStyles.message_document_action_button}
|
|
||||||
title="Download file"
|
|
||||||
>
|
|
||||||
<div className={messageStyles.message_document_action_icon}>
|
|
||||||
<FaDownload size={16} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
*/}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File Preview Modal */}
|
|
||||||
{previewingFile && (
|
|
||||||
<FilePreview
|
|
||||||
isOpen={previewModalOpen}
|
|
||||||
onClose={handleClosePreview}
|
|
||||||
fileId={previewingFile.id || ''}
|
|
||||||
fileName={previewingFile.ext ? `${previewingFile.name}.${previewingFile.ext}` : previewingFile.name}
|
|
||||||
mimeType={previewingFile.type}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MessageItem;
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { MessageListProps } from './dashboardChatAreaTypes';
|
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
|
||||||
import MessageItem from './DashboardChatAreaMessageItem';
|
|
||||||
import LogItem from './DashboardChatAreaLogItem';
|
|
||||||
import { mergeMessagesAndLogs, transformWorkflowMessage } from './dashboardChatAreaProgressBar';
|
|
||||||
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
|
|
||||||
import { IoIosArrowDown, IoIosChatbubbles } from 'react-icons/io';
|
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
|
||||||
|
|
||||||
const MessageList: React.FC<MessageListProps> = ({ workflowState }) => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const { request } = useApiRequest();
|
|
||||||
const [messages, setMessages] = React.useState<any[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const [isScrolledUp, setIsScrolledUp] = React.useState(false);
|
|
||||||
const lastCountRef = React.useRef(0);
|
|
||||||
const [showProgress, setShowProgress] = React.useState(false);
|
|
||||||
const [progressText, setProgressText] = React.useState('');
|
|
||||||
const [progressPercent, setProgressPercent] = React.useState(0);
|
|
||||||
const [maxTasks, setMaxTasks] = React.useState(0);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const pending = workflowState.pendingMessages || [];
|
|
||||||
const logs = workflowState.logs || [];
|
|
||||||
|
|
||||||
if ((pending.length > 0 || logs.length > 0) && !showProgress) {
|
|
||||||
setShowProgress(true);
|
|
||||||
setProgressPercent(0);
|
|
||||||
setMaxTasks(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logs.length === 0 && pending.length > 0) {
|
|
||||||
setProgressText(t('chat.messages.loading_progress'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logs.length > 0) {
|
|
||||||
let total = 0;
|
|
||||||
let completed = 0;
|
|
||||||
|
|
||||||
logs.forEach(log => {
|
|
||||||
const msg = log.message || '';
|
|
||||||
const startMatch = msg.match(/Executing task (\d+)\/(\d+)/i);
|
|
||||||
if (startMatch) total = Math.max(total, parseInt(startMatch[2]));
|
|
||||||
|
|
||||||
const completeMatch = msg.match(/✅.*Task (\d+).*completed/i);
|
|
||||||
if (completeMatch) completed = Math.max(completed, parseInt(completeMatch[1]));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (total > 0) {
|
|
||||||
const percent = Math.round((completed / total) * 100);
|
|
||||||
setProgressText(`${completed}/${total} ${t('chat.messages.tasks')} (${percent}%)`);
|
|
||||||
|
|
||||||
if (completed > maxTasks) {
|
|
||||||
setMaxTasks(completed);
|
|
||||||
setProgressPercent(percent);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setProgressText(`${t('chat.messages.analyzing_workflow')} (${logs.length} ${t('chat.messages.logs')})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [workflowState.pendingMessages, workflowState.logs, showProgress, maxTasks, t]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!workflowState.currentWorkflowId) {
|
|
||||||
setShowProgress(false);
|
|
||||||
setProgressText('');
|
|
||||||
setProgressPercent(0);
|
|
||||||
setMaxTasks(0);
|
|
||||||
}
|
|
||||||
}, [workflowState.currentWorkflowId]);
|
|
||||||
|
|
||||||
const timeline = React.useMemo(() => {
|
|
||||||
return mergeMessagesAndLogs(messages, workflowState.logs || []);
|
|
||||||
}, [messages, workflowState.logs]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const transform = async () => {
|
|
||||||
const pending = workflowState.pendingMessages || [];
|
|
||||||
const backend = workflowState.messages || [];
|
|
||||||
const all = [...pending, ...backend];
|
|
||||||
|
|
||||||
if (all.length === 0) {
|
|
||||||
setMessages([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const transformed = await Promise.all(all.map(msg => transformWorkflowMessage(msg, request)));
|
|
||||||
setMessages(transformed);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Message transformation failed:', error);
|
|
||||||
setMessages([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
transform();
|
|
||||||
}, [workflowState.messages, workflowState.pendingMessages, request]);
|
|
||||||
|
|
||||||
const checkScroll = React.useCallback(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
|
||||||
setIsScrolledUp(scrollHeight - scrollTop - clientHeight > 100);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollToBottom = React.useCallback(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const count = timeline.length;
|
|
||||||
if (count > lastCountRef.current && !isScrolledUp) {
|
|
||||||
setTimeout(scrollToBottom, 100);
|
|
||||||
}
|
|
||||||
lastCountRef.current = count;
|
|
||||||
}, [timeline.length, isScrolledUp, scrollToBottom]);
|
|
||||||
|
|
||||||
const { currentWorkflowId, isLoading: workflowLoading, error } = workflowState;
|
|
||||||
const isEmpty = timeline.length === 0 && !workflowLoading && !isLoading && !currentWorkflowId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={messageStyles.message_list_container}>
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className={isEmpty ? messageStyles.chat_messages_empty : messageStyles.chat_messages}
|
|
||||||
onScroll={checkScroll}
|
|
||||||
>
|
|
||||||
{error && <div className={messageStyles.message_error}>Error: {error}</div>}
|
|
||||||
|
|
||||||
<div className={messageStyles.messages_container}>
|
|
||||||
{timeline.map((item, index) => {
|
|
||||||
if (item.type === 'message') {
|
|
||||||
return (
|
|
||||||
<MessageItem
|
|
||||||
key={`message-${item.item.id}`}
|
|
||||||
message={item.item}
|
|
||||||
index={index}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (item.type === 'log') {
|
|
||||||
return (
|
|
||||||
<LogItem
|
|
||||||
key={`log-${item.item.id}`}
|
|
||||||
log={item.item}
|
|
||||||
index={index}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEmpty && (
|
|
||||||
<div className={messageStyles.message_empty_state}>
|
|
||||||
<div className={messageStyles.message_empty_state_icon}>
|
|
||||||
<IoIosChatbubbles />
|
|
||||||
</div>
|
|
||||||
<h3>{t('chat.messages.no_workflow_selected')}</h3>
|
|
||||||
<p>{t('chat.messages.no_workflow_selected_description')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showProgress && (
|
|
||||||
<div className={messageStyles.workflow_progress_container}>
|
|
||||||
<div className={messageStyles.workflow_progress_label}>
|
|
||||||
<span>{t('chat.messages.workflow_progress')}</span>
|
|
||||||
<span>{progressText}</span>
|
|
||||||
</div>
|
|
||||||
<div className={messageStyles.workflow_progress_bar}>
|
|
||||||
<div
|
|
||||||
className={messageStyles.workflow_progress_fill}
|
|
||||||
style={{ width: `${progressPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`${messageStyles.scroll_to_bottom_btn} ${
|
|
||||||
(isScrolledUp && timeline.length > 0) ? messageStyles.visible : messageStyles.hidden
|
|
||||||
}`}
|
|
||||||
onClick={scrollToBottom}
|
|
||||||
title={t('chat.messages.scroll_to_bottom_btn')}
|
|
||||||
>
|
|
||||||
<IoIosArrowDown className={messageStyles.scroll_to_bottom_btn_icon} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MessageList;
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
.dashboard_chat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-self: stretch;
|
|
||||||
background: var(--color-bg);
|
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid Layout */
|
|
||||||
.chat_grid {
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
grid-template-rows: 1fr auto;
|
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
gap: 0px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quadrant {
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Quadrant specific styles */
|
|
||||||
.messages_quadrant {
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file_preview_quadrant {
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input_quadrant {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connected_files_quadrant {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 2;
|
|
||||||
max-height: 200px; /* Fixed height for the connected files area */
|
|
||||||
overflow: hidden; /* Ensure no overflow at quadrant level */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chat Messages styles moved to DashboardChatMessages.module.css */
|
|
||||||
|
|
||||||
/* Chat Input */
|
|
||||||
.chat_input {
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
opacity: 0.6;
|
|
||||||
min-width: 120px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat_input.drag_over {
|
|
||||||
background-color: var(--color-secondary-disabled);
|
|
||||||
border: 2px dashed var(--color-secondary);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input_row {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: flex-end;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid var(--color-gray-disabled);
|
|
||||||
border-radius: 12px;
|
|
||||||
outline: none;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_input:focus {
|
|
||||||
border-color: var(--color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_input:disabled {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button_secondary {
|
|
||||||
border: 2px dashed var(--color-secondary);
|
|
||||||
border-radius: 30px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: var(--color-bg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
min-width: 100px;
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
color: var(--color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button_secondary:hover {
|
|
||||||
background-color: var(--color-secondary);
|
|
||||||
color: var(--color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button_secondary:disabled {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button_primary {
|
|
||||||
border-radius: 30px;
|
|
||||||
border: 1px solid var(--color-secondary);
|
|
||||||
background: var(--color-secondary);
|
|
||||||
color: var(--color-bg);
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 100px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button_primary:hover {
|
|
||||||
background-color: var(--color-secondary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button_primary:disabled {
|
|
||||||
background-color: var(--color-secondary-disabled);
|
|
||||||
border: 1px solid var(--color-secondary-disabled);
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Attached Files */
|
|
||||||
.attached_files {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached_file {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background-color: var(--color-secondary-disabled);
|
|
||||||
border: 1px solid var(--color-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-family: var(--font-family);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached_file_icon {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached_file_name {
|
|
||||||
max-width: 150px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached_file_remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-gray);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
margin-left: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached_file_remove:hover {
|
|
||||||
background-color: var(--color-gray-disabled);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input Area styles moved to DashboardChatAreaInput.module.css */
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
/* Input Area Specific Styles */
|
|
||||||
.input_area_container {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 16px 16px 16px 0;
|
|
||||||
min-height: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_status {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 8px;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error_message {
|
|
||||||
padding: 8px;
|
|
||||||
background-color: var(--color-error, #ffe6e6);
|
|
||||||
color: var(--color-error-text, #d00);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prompt Selection Styles */
|
|
||||||
.prompt_selection_container {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt_dropdown_wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt_dropdown {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px 16px;
|
|
||||||
padding-right: 40px; /* Space for the dropdown arrow */
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: border-color 0.2s ease, opacity 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none; /* Remove default dropdown arrow */
|
|
||||||
background-image: url("data:image/svg+xml;charset=US-ASCII,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M5 7l3 3 3-3'/></svg>"); background-repeat: no-repeat;
|
|
||||||
background-position: right 16px center; /* Position the custom arrow */
|
|
||||||
background-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt_dropdown:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-secondary);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt_dropdown:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear_prompt_button {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear_prompt_button:hover {
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-secondary);
|
|
||||||
border: 1px solid var(--color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear_prompt_button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached_files_count {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background-color: var(--color-secondary-disabled);
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-bg);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input_form_container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating_label_textarea {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea_label {
|
|
||||||
position: absolute;
|
|
||||||
left: 16px;
|
|
||||||
top: 16px;
|
|
||||||
color: var(--color-text);
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 14px;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background-color: transparent;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea_label_focused {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
top: -8px;
|
|
||||||
transform: translateY(0);
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
padding: 0 4px;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-weight: 500;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_textarea {
|
|
||||||
resize: none;
|
|
||||||
width: 100%;
|
|
||||||
min-height: calc(1.5em * 4 + 32px); /* 4 rows + padding */
|
|
||||||
max-height: calc(1.5em * 8 + 32px); /* 8 rows + padding */
|
|
||||||
height: calc(1.5em * 4 + 32px); /* Start with 4 rows */
|
|
||||||
|
|
||||||
padding:16px;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: border-color 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-secondary);
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_textarea::placeholder {
|
|
||||||
color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_textarea:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_textarea_with_content {
|
|
||||||
opacity: 0.9;
|
|
||||||
border-color: var(--color-secondary);
|
|
||||||
background: var(--color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input_actions_row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.new_chat_button {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-gray-disabled);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Drag and Drop Styles */
|
|
||||||
.input_area_container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag_over {
|
|
||||||
border: 2px dashed var(--color-primary);
|
|
||||||
background-color: rgba(var(--color-primary-rgb), 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag_overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(var(--color-primary-rgb), 0.1);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10;
|
|
||||||
border: 2px dashed var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag_overlay_content {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag_icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag_text {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag_overlay.disabled {
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
border-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag_overlay.disabled .drag_overlay_content {
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
.container {
|
|
||||||
padding: 16px;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachedInfo {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 8px 15px;
|
|
||||||
background-color: var(--color-secondary);
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--color-bg);
|
|
||||||
font-weight: 500;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachedInfoIcon {
|
|
||||||
width: 1.2rem;
|
|
||||||
height: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState {
|
|
||||||
color: var(--color-gray);
|
|
||||||
text-align: center;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filesList {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileItem {
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: 25px;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileInfo {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileName {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileSize {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeButton {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--color-secondary);
|
|
||||||
border-radius: 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeButton:hover {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
background-color: var(--color-secondary);
|
|
||||||
border: 1px solid var(--color-secondary);
|
|
||||||
border-radius: 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloadButton {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--color-gray-disabled);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
@ -1,550 +0,0 @@
|
||||||
/* Message-specific styles for DashboardChat components */
|
|
||||||
|
|
||||||
/* Message List Container - wraps the entire message list and positions scroll button */
|
|
||||||
.message_list_container {
|
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chat Messages Scrollable Area */
|
|
||||||
.chat_messages {
|
|
||||||
flex: 1;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat_messages_empty {
|
|
||||||
flex: 1;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.message_empty_state_icon {
|
|
||||||
font-size: 90px;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_empty_state h3 {
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_empty_state p {
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat_messages::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat_messages::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat_messages::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--color-gray-disabled);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat_messages::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Messages Container */
|
|
||||||
.messages_container {
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 8px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages_spacer {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Message Item Styles - Modern Chat Layout */
|
|
||||||
.message_item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 80%;
|
|
||||||
min-width: 0%;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design for smaller screens */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.message_item {
|
|
||||||
max-width: 85%;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.message_item {
|
|
||||||
max-width: 95%;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User messages - right aligned */
|
|
||||||
.message_item.user {
|
|
||||||
align-self: flex-end;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Assistant messages - left aligned */
|
|
||||||
.message_item.assistant {
|
|
||||||
align-self: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_header {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_bubble {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 25px;
|
|
||||||
position: relative;
|
|
||||||
word-wrap: break-word;
|
|
||||||
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User message bubble */
|
|
||||||
.message_bubble.user {
|
|
||||||
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-hover) 100%);
|
|
||||||
color: white;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Assistant message bubble */
|
|
||||||
.message_bubble.assistant {
|
|
||||||
background-color: var(--color-highlight-gray);
|
|
||||||
color: #181818;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_content {
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_timestamp {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
margin-top: 4px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_timestamp_inline {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
opacity: 0.8;
|
|
||||||
font-weight: 400;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_no_content {
|
|
||||||
color: var(--color-gray);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Document/File Attachments in Messages */
|
|
||||||
.message_documents {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
background-color: color-mix(in srgb, var(--color-primary), transparent 80%);
|
|
||||||
border-radius: 15px;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.message_documents_header {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Document header styling for user messages */
|
|
||||||
.message_documents_header.user {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Document header styling for assistant messages */
|
|
||||||
.message_documents_header.assistant {
|
|
||||||
color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_document_item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--color-highlight-gray);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.message_document_info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_document_name {
|
|
||||||
font-size: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_document_size {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_document_actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.message_document_action_button {
|
|
||||||
|
|
||||||
width: 33px;
|
|
||||||
height: 32px;
|
|
||||||
font-size: 12px;
|
|
||||||
background-color: var(--color-secondary);
|
|
||||||
border: 1px solid var(--color-secondary);
|
|
||||||
border-radius: 25px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_document_action_icon {
|
|
||||||
font-size: 16px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_document_action:hover {
|
|
||||||
background-color: var(--color-gray-disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_document_action:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scroll to Bottom Button */
|
|
||||||
.scroll_to_bottom_btn {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 80px;
|
|
||||||
right: 20px;
|
|
||||||
background-color: var(--color-secondary);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out, transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll_to_bottom_btn:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll_to_bottom_btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll_to_bottom_btn.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll_to_bottom_btn.visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading and Error States */
|
|
||||||
.message_loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 16px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_error {
|
|
||||||
padding: 8px;
|
|
||||||
background-color: var(--color-error);
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message_empty_state {
|
|
||||||
color: var(--color-gray);
|
|
||||||
text-align: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Workflow Status Display */
|
|
||||||
.workflow_status {
|
|
||||||
padding: 8px;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_status_title {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_status_polling {
|
|
||||||
margin-left: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation for polling indicator */
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.7; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.polling_indicator {
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { left: -100%; }
|
|
||||||
100% { left: 100%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes loadingSlide {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
50% { transform: translateX(0%); }
|
|
||||||
100% { transform: translateX(100%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_progress_container {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-top: 1px solid var(--color-primary);
|
|
||||||
border-bottom: 1px solid var(--color-primary);
|
|
||||||
margin-top: auto;
|
|
||||||
margin-right: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_progress_label {
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_progress_breakdown {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
text-align: center;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_progress_bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
background-color: var(--color-gray-disabled);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_progress_fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--color-secondary) 0%, var(--color-secondary-hover) 100%);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: width 1.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow_progress_fill.loading {
|
|
||||||
width: 100% !important;
|
|
||||||
background: linear-gradient(90deg, transparent, var(--color-gray-disabled), transparent);
|
|
||||||
animation: loadingSlide 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced shimmer animation for workflow progress */
|
|
||||||
.workflow_progress_fill::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
|
||||||
animation: shimmer 3s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide shimmer during loading state */
|
|
||||||
.workflow_progress_fill.loading::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Workflow Log Messages */
|
|
||||||
.log_container {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
border-left: 3px solid;
|
|
||||||
border: 1px solid var(--color-gray-disabled);
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_level {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_level.info {
|
|
||||||
background-color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_level.success {
|
|
||||||
background-color: var(--color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_level.warning {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_level.error {
|
|
||||||
background-color: var(--color-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_level.debug {
|
|
||||||
background-color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_timestamp {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_message {
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--color-text);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_source {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
margin-top: 4px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_progress {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log details (expandable) */
|
|
||||||
.log_details {
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 6px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.03);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--color-gray);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log_details pre {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
@ -1,501 +0,0 @@
|
||||||
import React, { useState, useRef } from 'react';
|
|
||||||
import { useUserFiles, UserFile } from '../../../hooks/useFiles';
|
|
||||||
|
|
||||||
interface AttachedFile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
type: string;
|
|
||||||
fileData?: File;
|
|
||||||
objectUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileAttachmentPopupProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onFilesSelected: (files: AttachedFile[]) => void;
|
|
||||||
currentAttachedFiles: AttachedFile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileAttachmentPopup: React.FC<FileAttachmentPopupProps> = ({
|
|
||||||
onClose,
|
|
||||||
onFilesSelected,
|
|
||||||
currentAttachedFiles
|
|
||||||
}) => {
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<AttachedFile[]>(currentAttachedFiles);
|
|
||||||
const [dragOver, setDragOver] = useState(false);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState<'upload' | 'existing'>('upload');
|
|
||||||
const [fileSubTab, setFileSubTab] = useState<'all' | 'uploads' | 'created' | 'shared'>('all');
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const { data: existingFiles, loading: filesLoading } = useUserFiles();
|
|
||||||
|
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragOver(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragOver(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragOver(false);
|
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
handleFileUpload(files);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = e.target.files ? Array.from(e.target.files) : [];
|
|
||||||
handleFileUpload(files);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (files: File[]) => {
|
|
||||||
setUploading(true);
|
|
||||||
|
|
||||||
// Create file objects with data for preview
|
|
||||||
const uploadedFiles: AttachedFile[] = files.map((file, index) => {
|
|
||||||
const fileObj: AttachedFile = {
|
|
||||||
id: (Date.now() + index).toString(),
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
fileData: file
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create object URL for images
|
|
||||||
if (file.type.startsWith('image/')) {
|
|
||||||
fileObj.objectUrl = URL.createObjectURL(file);
|
|
||||||
console.log('FileAttachmentPopup: Created object URL for', file.name, ':', fileObj.objectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('FileAttachmentPopup: Created file object:', fileObj.name, 'Has fileData:', !!fileObj.fileData, 'Has objectUrl:', !!fileObj.objectUrl);
|
|
||||||
return fileObj;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to selected files
|
|
||||||
setSelectedFiles(prev => [...prev, ...uploadedFiles]);
|
|
||||||
setUploading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleFileSelection = (file: UserFile) => {
|
|
||||||
const attachedFile: AttachedFile = {
|
|
||||||
id: file.id,
|
|
||||||
name: file.file_name,
|
|
||||||
size: file.size || 0,
|
|
||||||
type: 'application/octet-stream'
|
|
||||||
};
|
|
||||||
|
|
||||||
setSelectedFiles(prev => {
|
|
||||||
const isSelected = prev.some(f => f.id === file.id);
|
|
||||||
if (isSelected) {
|
|
||||||
return prev.filter(f => f.id !== file.id);
|
|
||||||
} else {
|
|
||||||
return [...prev, attachedFile];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
onFilesSelected(selectedFiles);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup object URLs when component unmounts
|
|
||||||
React.useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
selectedFiles.forEach(file => {
|
|
||||||
if (file.objectUrl) {
|
|
||||||
URL.revokeObjectURL(file.objectUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
|
|
||||||
return Math.round(bytes / (1024 * 1024)) + ' MB';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simplified selectable files list
|
|
||||||
const SelectableFilesList: React.FC<{
|
|
||||||
files: UserFile[];
|
|
||||||
selectedFiles: AttachedFile[];
|
|
||||||
onFileSelect: (file: UserFile) => void;
|
|
||||||
activeTab: string;
|
|
||||||
}> = ({ files, selectedFiles, onFileSelect, activeTab }) => {
|
|
||||||
// Filter files based on active tab
|
|
||||||
const filteredFiles = files.filter(file => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'uploads':
|
|
||||||
return file.source === 'user_uploaded';
|
|
||||||
case 'created':
|
|
||||||
return file.source === 'agent_created';
|
|
||||||
case 'shared':
|
|
||||||
return file.source === 'shared_with_me';
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const isFileSelected = (fileId: string) => {
|
|
||||||
return selectedFiles.some(f => f.id === fileId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileIcon = (fileName: string) => {
|
|
||||||
const extension = fileName.split('.').pop()?.toLowerCase();
|
|
||||||
switch (extension) {
|
|
||||||
case 'pdf': return '📄';
|
|
||||||
case 'doc': case 'docx': return '📝';
|
|
||||||
case 'xls': case 'xlsx': return '📊';
|
|
||||||
case 'jpg': case 'jpeg': case 'png': case 'gif': return '🖼️';
|
|
||||||
case 'mp4': case 'avi': return '🎥';
|
|
||||||
case 'txt': return '📄';
|
|
||||||
default: return '📎';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (filteredFiles.length === 0) {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>
|
|
||||||
No files found in this category
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '40px 1fr 80px 100px 80px',
|
|
||||||
padding: '12px',
|
|
||||||
fontWeight: '500',
|
|
||||||
borderBottom: '1px solid #eee',
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
fontSize: '12px'
|
|
||||||
}}>
|
|
||||||
<div></div>
|
|
||||||
<div>Name</div>
|
|
||||||
<div>Type</div>
|
|
||||||
<div>Size</div>
|
|
||||||
<div>Date</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File List */}
|
|
||||||
<div>
|
|
||||||
{filteredFiles.map(file => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
onClick={() => onFileSelect(file)}
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '40px 1fr 80px 100px 80px',
|
|
||||||
padding: '12px',
|
|
||||||
borderBottom: '1px solid #f0f0f0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
backgroundColor: isFileSelected(file.id) ? '#e3f2fd' : 'white',
|
|
||||||
border: isFileSelected(file.id) ? '1px solid var(--color-secondary)' : '1px solid transparent',
|
|
||||||
transition: 'all 0.2s ease'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isFileSelected(file.id)) {
|
|
||||||
e.currentTarget.style.backgroundColor = '#f8f9fa';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isFileSelected(file.id)) {
|
|
||||||
e.currentTarget.style.backgroundColor = 'white';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isFileSelected(file.id)}
|
|
||||||
onChange={() => {}}
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<span style={{ fontSize: '16px' }}>{getFileIcon(file.file_name)}</span>
|
|
||||||
<span style={{ fontSize: '14px', fontWeight: '500' }}>{file.file_name}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#666' }}>{file.action}</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
|
||||||
{file.size ? formatFileSize(file.size) : 'Unknown'}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
|
||||||
{new Date(file.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 1000
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '12px',
|
|
||||||
width: '600px',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)'
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
|
||||||
padding: '20px',
|
|
||||||
borderBottom: '1px solid #eee',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: 0 }}>Attach Files</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
fontSize: '18px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
borderBottom: '1px solid #eee'
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('upload')}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '12px',
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: activeTab === 'upload' ? '#f0f0f0' : 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: activeTab === 'upload' ? '2px solid var(--color-secondary)' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Upload New Files
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('existing')}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '12px',
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: activeTab === 'existing' ? '#f0f0f0' : 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: activeTab === 'existing' ? '2px solid var(--color-secondary)' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select Existing Files
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div style={{ padding: '20px', maxHeight: '400px', overflowY: 'auto' }}>
|
|
||||||
{activeTab === 'upload' && (
|
|
||||||
<div>
|
|
||||||
{/* Drag and Drop Area */}
|
|
||||||
<div
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
style={{
|
|
||||||
border: `2px dashed ${dragOver ? 'var(--color-secondary)' : '#ccc'}`,
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '40px',
|
|
||||||
textAlign: 'center',
|
|
||||||
backgroundColor: dragOver ? '#f0f8ff' : '#fafafa',
|
|
||||||
marginBottom: '20px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📁</div>
|
|
||||||
<div style={{ fontSize: '16px', marginBottom: '8px' }}>
|
|
||||||
Drag and drop files here or click to browse
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
|
||||||
Supports all file types
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{uploading && (
|
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
|
||||||
Uploading files...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'existing' && (
|
|
||||||
<div>
|
|
||||||
{/* File Category Sub-tabs */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
marginBottom: '16px',
|
|
||||||
borderBottom: '1px solid #eee'
|
|
||||||
}}>
|
|
||||||
{[
|
|
||||||
{ key: 'all', label: 'All Files' },
|
|
||||||
{ key: 'uploads', label: 'Uploads' },
|
|
||||||
{ key: 'created', label: 'AI Created' },
|
|
||||||
{ key: 'shared', label: 'Shared' }
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setFileSubTab(tab.key as any)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: fileSubTab === tab.key ? '#f0f0f0' : 'transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px',
|
|
||||||
borderBottom: fileSubTab === tab.key ? '2px solid var(--color-secondary)' : '2px solid transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filesLoading ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
|
||||||
Loading files...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
maxHeight: '300px',
|
|
||||||
overflow: 'auto',
|
|
||||||
border: '1px solid #eee',
|
|
||||||
borderRadius: '6px',
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<SelectableFilesList
|
|
||||||
files={existingFiles}
|
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
onFileSelect={toggleFileSelection}
|
|
||||||
activeTab={fileSubTab}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Selection Instructions */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: '12px',
|
|
||||||
padding: '8px',
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#666'
|
|
||||||
}}>
|
|
||||||
💡 Click on files to select them for attachment
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected Files Summary */}
|
|
||||||
{selectedFiles.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
padding: '16px 20px',
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
borderTop: '1px solid #eee'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
|
|
||||||
Selected Files ({selectedFiles.length}):
|
|
||||||
</div>
|
|
||||||
<div style={{ maxHeight: '100px', overflowY: 'auto' }}>
|
|
||||||
{selectedFiles.map(file => (
|
|
||||||
<div key={file.id} style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: '2px'
|
|
||||||
}}>
|
|
||||||
📎 {file.name} ({formatFileSize(file.size)})
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div style={{
|
|
||||||
padding: '20px',
|
|
||||||
borderTop: '1px solid #eee',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '12px',
|
|
||||||
justifyContent: 'flex-end'
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={selectedFiles.length === 0}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: selectedFiles.length === 0 ? '#ccc' : 'var(--color-secondary)',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: selectedFiles.length === 0 ? 'not-allowed' : 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Attach {selectedFiles.length} File{selectedFiles.length !== 1 ? 's' : ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileAttachmentPopup;
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
import { WorkflowLog, WorkflowProgress, TimelineItem } from './dashboardChatAreaTypes';
|
|
||||||
|
|
||||||
const parseLogProgress = (msg: string) => {
|
|
||||||
const m = msg.trim();
|
|
||||||
|
|
||||||
const taskStart = m.match(/^Executing task (\d+)\/(\d+)$/i);
|
|
||||||
if (taskStart) return { taskNumber: parseInt(taskStart[1]), totalTasks: parseInt(taskStart[2]), type: 'task_start' as const };
|
|
||||||
|
|
||||||
const actionStart = m.match(/^Task (\d+) - Starting action (\d+)\/(\d+)$/i);
|
|
||||||
if (actionStart) return { taskNumber: parseInt(actionStart[1]), actionNumber: parseInt(actionStart[2]), totalActions: parseInt(actionStart[3]), type: 'action_start' as const };
|
|
||||||
|
|
||||||
const actionComplete = m.match(/^(?:✅\s+)?Task (\d+) - Action (\d+)\/(\d+) completed$/i);
|
|
||||||
if (actionComplete) return { taskNumber: parseInt(actionComplete[1]), actionNumber: parseInt(actionComplete[2]), totalActions: parseInt(actionComplete[3]), isCompleted: true, type: 'action_complete' as const };
|
|
||||||
|
|
||||||
const taskComplete = m.match(/^(?:🎯\s+)?Task (\d+)\/(\d+) completed$/i);
|
|
||||||
if (taskComplete) return { taskNumber: parseInt(taskComplete[1]), totalTasks: parseInt(taskComplete[2]), isCompleted: true, type: 'task_complete' as const };
|
|
||||||
|
|
||||||
return { type: 'unknown' as const };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calculateWorkflowProgress = (messages: any[], logs: WorkflowLog[] = []): WorkflowProgress | null => {
|
|
||||||
if (messages.length === 0 && logs.length === 0) return null;
|
|
||||||
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
if (lastMessage?.role === 'user') return { current: 0, total: 0, percentage: 0, isLoading: true };
|
|
||||||
|
|
||||||
let totalTasks = 0;
|
|
||||||
const taskCounts: { [key: number]: { total: number; completed: Set<number> } } = {};
|
|
||||||
|
|
||||||
logs.forEach(log => {
|
|
||||||
if (!log.message) return;
|
|
||||||
const p = parseLogProgress(log.message);
|
|
||||||
|
|
||||||
if (p.type === 'task_start' && p.totalTasks) totalTasks = Math.max(totalTasks, p.totalTasks);
|
|
||||||
|
|
||||||
if (p.type === 'action_start' && p.taskNumber && p.totalActions) {
|
|
||||||
if (!taskCounts[p.taskNumber]) taskCounts[p.taskNumber] = { total: 0, completed: new Set() };
|
|
||||||
taskCounts[p.taskNumber].total = Math.max(taskCounts[p.taskNumber].total, p.totalActions);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.type === 'action_complete' && p.taskNumber && p.actionNumber && p.totalActions) {
|
|
||||||
if (!taskCounts[p.taskNumber]) taskCounts[p.taskNumber] = { total: p.totalActions, completed: new Set() };
|
|
||||||
taskCounts[p.taskNumber].completed.add(p.actionNumber);
|
|
||||||
taskCounts[p.taskNumber].total = Math.max(taskCounts[p.taskNumber].total, p.totalActions);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.type === 'task_complete' && p.taskNumber && p.totalTasks) {
|
|
||||||
totalTasks = Math.max(totalTasks, p.totalTasks);
|
|
||||||
if (taskCounts[p.taskNumber]) {
|
|
||||||
const task = taskCounts[p.taskNumber];
|
|
||||||
for (let i = 1; i <= task.total; i++) task.completed.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
messages.forEach(msg => {
|
|
||||||
if (msg.role !== 'assistant' || !msg.content) return;
|
|
||||||
const match = msg.content.match(/✅\s+Task (\d+) - Action\s+(?:(\d+)\/(\d+)|.*completed)/i);
|
|
||||||
if (match) {
|
|
||||||
const taskNum = parseInt(match[1]);
|
|
||||||
if (match[2] && match[3]) {
|
|
||||||
const actionNum = parseInt(match[2]);
|
|
||||||
const totalActions = parseInt(match[3]);
|
|
||||||
if (!taskCounts[taskNum]) taskCounts[taskNum] = { total: totalActions, completed: new Set() };
|
|
||||||
taskCounts[taskNum].completed.add(actionNum);
|
|
||||||
taskCounts[taskNum].total = Math.max(taskCounts[taskNum].total, totalActions);
|
|
||||||
} else {
|
|
||||||
if (!taskCounts[taskNum]) taskCounts[taskNum] = { total: 1, completed: new Set() };
|
|
||||||
taskCounts[taskNum].completed.add(1);
|
|
||||||
if (taskCounts[taskNum].total === 0) taskCounts[taskNum].total = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let completed = 0;
|
|
||||||
for (let i = 1; i <= totalTasks; i++) {
|
|
||||||
const task = taskCounts[i];
|
|
||||||
if (task && task.total > 0 && task.completed.size === task.total) completed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalTasks > 0 ? { current: completed, total: totalTasks, percentage: Math.round((completed / totalTasks) * 100), isLoading: false } : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const safeParseDate = (ts: any, fallback = Date.now()): Date => {
|
|
||||||
if (!ts) return new Date(fallback);
|
|
||||||
|
|
||||||
let d = ts;
|
|
||||||
if (typeof ts === 'number') d = ts < 10000000000 ? ts * 1000 : ts;
|
|
||||||
else if (typeof ts === 'string' && /^\d+$/.test(ts)) {
|
|
||||||
const n = parseInt(ts);
|
|
||||||
d = n < 10000000000 ? n * 1000 : n;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(d);
|
|
||||||
return isNaN(date.getTime()) ? new Date(fallback) : date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const mergeMessagesAndLogs = (messages: any[], logs: WorkflowLog[]): TimelineItem[] => {
|
|
||||||
const items: TimelineItem[] = [];
|
|
||||||
|
|
||||||
messages.forEach((msg, i) => {
|
|
||||||
const ts = safeParseDate(msg.timestamp || msg.publishedAt, Date.now() - ((messages.length + logs.length) - i) * 1000);
|
|
||||||
items.push({ type: 'message', item: msg, timestamp: ts });
|
|
||||||
});
|
|
||||||
|
|
||||||
logs.forEach((log, i) => {
|
|
||||||
const ts = safeParseDate(log.timestamp, Date.now() - (logs.length - i) * 1000);
|
|
||||||
items.push({ type: 'log', item: log, timestamp: ts });
|
|
||||||
});
|
|
||||||
|
|
||||||
return items.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
export const transformWorkflowMessage = async (msg: any, request: any): Promise<any> => {
|
|
||||||
let docs: any[] = [];
|
|
||||||
|
|
||||||
if (msg.documents?.length > 0) {
|
|
||||||
// Try to get filenames from the documents, but if missing, fetch from API
|
|
||||||
const needsApiCall = msg.documents.some((d: any) => !d.filename && !d.fileName && !d.name);
|
|
||||||
|
|
||||||
if (needsApiCall) {
|
|
||||||
// If any document is missing filename, fetch details from API for all
|
|
||||||
const promises = msg.documents.map(async (d: any) => {
|
|
||||||
try {
|
|
||||||
const fileId = d.fileId || d.id;
|
|
||||||
const res = await request({ url: `/api/workflows/files/${fileId}/preview`, method: 'get' });
|
|
||||||
const fullName = res.name || res.fileName || d.filename || d.fileName || d.name || `File_${fileId}`;
|
|
||||||
const lastDotIndex = fullName.lastIndexOf('.');
|
|
||||||
const hasExtension = lastDotIndex > 0 && lastDotIndex < fullName.length - 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: d.id || d.fileId,
|
|
||||||
fileId: typeof d.fileId === 'string' ? parseInt(d.fileId) : d.fileId,
|
|
||||||
name: hasExtension ? fullName.substring(0, lastDotIndex) : fullName,
|
|
||||||
ext: res.extension || res.ext || (hasExtension ? fullName.substring(lastDotIndex + 1) : ''),
|
|
||||||
type: d.mimeType || res.mimeType || res.type || 'application/octet-stream',
|
|
||||||
size: d.fileSize || res.size || 0,
|
|
||||||
downloadUrl: `/api/workflows/files/${d.fileId}/download`
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// Fallback if API call fails
|
|
||||||
const fullFilename = d.filename || d.fileName || d.name || `File_${d.id || d.fileId || 'unknown'}`;
|
|
||||||
const lastDotIndex = fullFilename.lastIndexOf('.');
|
|
||||||
const hasExtension = lastDotIndex > 0 && lastDotIndex < fullFilename.length - 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: d.id || d.fileId,
|
|
||||||
fileId: typeof d.fileId === 'string' ? parseInt(d.fileId) : d.fileId,
|
|
||||||
name: hasExtension ? fullFilename.substring(0, lastDotIndex) : fullFilename,
|
|
||||||
ext: hasExtension ? fullFilename.substring(lastDotIndex + 1) : '',
|
|
||||||
type: d.mimeType || 'application/octet-stream',
|
|
||||||
size: d.fileSize || 0,
|
|
||||||
downloadUrl: `/api/workflows/files/${d.fileId}/download`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
docs = await Promise.all(promises);
|
|
||||||
} else {
|
|
||||||
// All documents have filenames, process them directly
|
|
||||||
docs = msg.documents.map((d: any) => {
|
|
||||||
const fullFilename = d.filename || d.fileName || d.name || `File_${d.id || d.fileId || 'unknown'}`;
|
|
||||||
const lastDotIndex = fullFilename.lastIndexOf('.');
|
|
||||||
const hasExtension = lastDotIndex > 0 && lastDotIndex < fullFilename.length - 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: d.id || d.fileId,
|
|
||||||
fileId: typeof d.fileId === 'string' ? parseInt(d.fileId) : d.fileId,
|
|
||||||
name: hasExtension ? fullFilename.substring(0, lastDotIndex) : fullFilename,
|
|
||||||
ext: hasExtension ? fullFilename.substring(lastDotIndex + 1) : '',
|
|
||||||
type: d.mimeType,
|
|
||||||
size: d.fileSize,
|
|
||||||
downloadUrl: `/api/workflows/files/${d.fileId}/download`
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (msg.fileIds?.length > 0) {
|
|
||||||
const promises = msg.fileIds.map(async (id: number) => {
|
|
||||||
try {
|
|
||||||
const res = await request({ url: `/api/workflows/files/${id}/preview`, method: 'get' });
|
|
||||||
const fullName = res.name || res.fileName || `File_${id}`;
|
|
||||||
const lastDotIndex = fullName.lastIndexOf('.');
|
|
||||||
const hasExtension = lastDotIndex > 0 && lastDotIndex < fullName.length - 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id.toString(),
|
|
||||||
fileId: id,
|
|
||||||
name: hasExtension ? fullName.substring(0, lastDotIndex) : fullName,
|
|
||||||
ext: res.extension || res.ext || (hasExtension ? fullName.substring(lastDotIndex + 1) : ''),
|
|
||||||
type: res.mimeType || res.type || 'application/octet-stream',
|
|
||||||
size: res.size || 0,
|
|
||||||
downloadUrl: res.downloadUrl || res.url
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { id: id.toString(), fileId: id, name: `File_${id}`, ext: '', type: 'application/octet-stream', size: 0 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
docs = await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: msg.id,
|
|
||||||
role: msg.role,
|
|
||||||
agentName: msg.role === 'user' ? 'You' : 'Assistant',
|
|
||||||
content: msg.message || msg.content || msg.text || msg.body || '',
|
|
||||||
timestamp: msg.publishedAt
|
|
||||||
? (typeof msg.publishedAt === 'number' ? new Date(msg.publishedAt * 1000).toISOString() : msg.publishedAt)
|
|
||||||
: msg.timestamp,
|
|
||||||
documents: docs
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
// Simplified types - everything in one place
|
|
||||||
export interface Prompt {
|
|
||||||
id: string;
|
|
||||||
mandateId: string;
|
|
||||||
name: string;
|
|
||||||
content: string;
|
|
||||||
createdAt?: string;
|
|
||||||
isShared?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardChatProps {
|
|
||||||
workflowState: WorkflowState;
|
|
||||||
workflowActions: WorkflowActions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardChatAreaProps {
|
|
||||||
workflowState: WorkflowState;
|
|
||||||
workflowActions: WorkflowActions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workflow interfaces - Updated to match full API response
|
|
||||||
export interface WorkflowStats {
|
|
||||||
id: string;
|
|
||||||
processingTime: number;
|
|
||||||
tokenCount: number;
|
|
||||||
bytesSent: number;
|
|
||||||
bytesReceived: number;
|
|
||||||
successRate: number;
|
|
||||||
errorCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowDocument {
|
|
||||||
id: string;
|
|
||||||
fileId: string;
|
|
||||||
filename: string;
|
|
||||||
fileSize: number;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowMessageStats extends WorkflowStats {}
|
|
||||||
|
|
||||||
export interface WorkflowMessage {
|
|
||||||
id: string;
|
|
||||||
workflowId: string;
|
|
||||||
parentMessageId?: string;
|
|
||||||
documents?: WorkflowDocument[];
|
|
||||||
documentsLabel?: string;
|
|
||||||
message: string;
|
|
||||||
content?: string; // For backward compatibility
|
|
||||||
role: 'user' | 'assistant' | 'system';
|
|
||||||
status: string;
|
|
||||||
sequenceNr: number;
|
|
||||||
publishedAt: number; // UTC timestamp in seconds (float from backend)
|
|
||||||
timestamp?: string; // For backward compatibility
|
|
||||||
fileIds?: string[]; // For backward compatibility
|
|
||||||
stats?: WorkflowMessageStats;
|
|
||||||
success: boolean;
|
|
||||||
actionId?: string;
|
|
||||||
actionMethod?: string;
|
|
||||||
actionName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowLog {
|
|
||||||
id: string;
|
|
||||||
workflowId: string;
|
|
||||||
message: string;
|
|
||||||
type: string;
|
|
||||||
timestamp: string;
|
|
||||||
status: string;
|
|
||||||
progress: number;
|
|
||||||
performance: any;
|
|
||||||
level?: 'info' | 'warning' | 'error' | 'debug';
|
|
||||||
source?: string;
|
|
||||||
details?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowAction {
|
|
||||||
id: string;
|
|
||||||
execMethod: string;
|
|
||||||
execAction: string;
|
|
||||||
execParameters: any;
|
|
||||||
execResultLabel: string;
|
|
||||||
expectedDocumentFormats?: any[];
|
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
||||||
error?: string;
|
|
||||||
retryCount: number;
|
|
||||||
retryMax: number;
|
|
||||||
processingTime: number;
|
|
||||||
timestamp: string;
|
|
||||||
result?: string;
|
|
||||||
resultDocuments?: WorkflowDocument[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowTask {
|
|
||||||
id: string;
|
|
||||||
workflowId: string;
|
|
||||||
userInput: string;
|
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
||||||
error?: string;
|
|
||||||
startedAt: string;
|
|
||||||
finishedAt?: string;
|
|
||||||
actionList: WorkflowAction[];
|
|
||||||
retryCount: number;
|
|
||||||
retryMax: number;
|
|
||||||
rollbackOnFailure: boolean;
|
|
||||||
dependencies: string[];
|
|
||||||
feedback?: string;
|
|
||||||
processingTime: number;
|
|
||||||
resultLabels: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Workflow {
|
|
||||||
id: string;
|
|
||||||
mandateId: string;
|
|
||||||
status: string;
|
|
||||||
name?: string;
|
|
||||||
currentRound: number;
|
|
||||||
lastActivity: number; // UTC timestamp in seconds (float from backend)
|
|
||||||
startedAt: number; // UTC timestamp in seconds (float from backend)
|
|
||||||
logs?: WorkflowLog[];
|
|
||||||
messages?: WorkflowMessage[];
|
|
||||||
stats?: WorkflowStats;
|
|
||||||
tasks?: WorkflowTask[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowState {
|
|
||||||
currentWorkflowId: string | null;
|
|
||||||
workflow: Workflow | null;
|
|
||||||
messages: WorkflowMessage[];
|
|
||||||
pendingMessages: WorkflowMessage[]; // Messages sent but not yet confirmed by backend
|
|
||||||
logs: WorkflowLog[];
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
selectedPrompt: Prompt | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowActions {
|
|
||||||
loadWorkflow: (workflowId: string) => void;
|
|
||||||
startNewWorkflow: (prompt: string, fileIds?: string[]) => Promise<string | null>;
|
|
||||||
continueWorkflow: (prompt: string, fileIds?: string[]) => Promise<boolean>;
|
|
||||||
stopWorkflow: () => Promise<boolean>;
|
|
||||||
clearWorkflow: () => void;
|
|
||||||
selectPrompt: (prompt: Prompt | null) => void;
|
|
||||||
clearPrompt: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
mimeType: string;
|
|
||||||
size?: number;
|
|
||||||
creationDate?: string;
|
|
||||||
downloadUrl?: string;
|
|
||||||
fileData?: File;
|
|
||||||
objectUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add old interfaces for compatibility
|
|
||||||
export interface Document {
|
|
||||||
id?: string;
|
|
||||||
fileId?: number;
|
|
||||||
name: string;
|
|
||||||
url?: string;
|
|
||||||
type?: string;
|
|
||||||
size?: number;
|
|
||||||
downloadUrl?: string;
|
|
||||||
ext?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Message {
|
|
||||||
id?: string;
|
|
||||||
role: 'user' | 'assistant' | 'system';
|
|
||||||
agentName: string;
|
|
||||||
content: string;
|
|
||||||
timestamp?: string;
|
|
||||||
documents?: Document[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InputAreaProps {
|
|
||||||
workflowState: WorkflowState;
|
|
||||||
workflowActions: WorkflowActions;
|
|
||||||
attachedFiles?: AttachedFile[];
|
|
||||||
onAttachedFilesChange?: (files: AttachedFile[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AttachedFile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
type: string;
|
|
||||||
fileData?: File;
|
|
||||||
objectUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectedFilesProps {
|
|
||||||
onFileSelect?: (file: FileInfo) => void;
|
|
||||||
selectedFile?: FileInfo | null;
|
|
||||||
attachedFiles?: AttachedFile[];
|
|
||||||
onRemoveFile?: (fileId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageListProps {
|
|
||||||
workflowState: WorkflowState;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress bar interfaces
|
|
||||||
export interface WorkflowProgress {
|
|
||||||
current: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
taskBreakdown?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeline item for merged messages and logs
|
|
||||||
export interface TimelineItem {
|
|
||||||
type: 'message' | 'log';
|
|
||||||
item: any;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { default } from './DashboardChatArea';
|
|
||||||
export type { DashboardChatAreaProps } from './dashboardChatAreaTypes';
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
||||||
import { useWorkflow, useWorkflowStatus, useWorkflowMessages, useWorkflowLogs, useWorkflowOperations, StartWorkflowRequest } from '../../../hooks/useWorkflows';
|
|
||||||
import { WorkflowState, WorkflowActions } from './dashboardChatAreaTypes';
|
|
||||||
|
|
||||||
export function useWorkflowManager(initialWorkflowId?: string | null): [WorkflowState, WorkflowActions] {
|
|
||||||
// Core state
|
|
||||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(initialWorkflowId || null);
|
|
||||||
const [isPolling, setIsPolling] = useState(false);
|
|
||||||
const [pendingMessages, setPendingMessages] = useState<any[]>([]);
|
|
||||||
const [sentUserMessages, setSentUserMessages] = useState<Set<string>>(new Set()); // Track sent user messages
|
|
||||||
const [selectedPrompt, setSelectedPrompt] = useState<any | null>(null); // Selected prompt state
|
|
||||||
const pollingIntervalRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// Hook-based data fetching
|
|
||||||
const { workflow, loading: workflowLoading, error: workflowError } = useWorkflow(currentWorkflowId);
|
|
||||||
const { status: workflowStatus, loading: statusLoading, error: statusError, refetch: refetchStatus } = useWorkflowStatus(currentWorkflowId);
|
|
||||||
const { messages, loading: messagesLoading, error: messagesError, refetch: refetchMessages } = useWorkflowMessages(currentWorkflowId);
|
|
||||||
const { logs, loading: logsLoading, error: logsError, refetch: refetchLogs } = useWorkflowLogs(currentWorkflowId);
|
|
||||||
const { startWorkflow, stopWorkflow: stopWorkflowRequest } = useWorkflowOperations();
|
|
||||||
|
|
||||||
// Use status for real-time updates, fallback to workflow for initial data
|
|
||||||
const currentWorkflow = workflowStatus || workflow;
|
|
||||||
|
|
||||||
// Helper to create optimistic user message
|
|
||||||
const createOptimisticMessage = useCallback((prompt: string, fileIds: string[] = []) => {
|
|
||||||
// Use UTC timestamp in seconds (float) to match backend expectation
|
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
|
||||||
return {
|
|
||||||
id: `temp-${Date.now()}`,
|
|
||||||
workflowId: currentWorkflowId || 'pending',
|
|
||||||
message: prompt,
|
|
||||||
role: 'user' as const,
|
|
||||||
status: 'pending',
|
|
||||||
sequenceNr: 0,
|
|
||||||
publishedAt: timestamp,
|
|
||||||
success: true,
|
|
||||||
fileIds: fileIds.length > 0 ? fileIds : undefined
|
|
||||||
};
|
|
||||||
}, [currentWorkflowId]);
|
|
||||||
|
|
||||||
// Combined loading and error states
|
|
||||||
const isLoading = workflowLoading || statusLoading || messagesLoading || logsLoading;
|
|
||||||
const error = workflowError || statusError || messagesError || logsError;
|
|
||||||
|
|
||||||
// Filter messages based on workflow state and pending messages
|
|
||||||
const filteredMessages = useMemo(() => {
|
|
||||||
if (!messages) return [];
|
|
||||||
|
|
||||||
// For completed/stopped workflows, always show all messages (including user messages from backend)
|
|
||||||
const isWorkflowComplete = currentWorkflow && ['completed', 'stopped', 'failed', 'error'].includes(currentWorkflow.status);
|
|
||||||
|
|
||||||
// If workflow is complete OR we have no pending messages and no tracked sent messages,
|
|
||||||
// show all messages (this covers historical workflows and completed workflows)
|
|
||||||
if (isWorkflowComplete || (pendingMessages.length === 0 && sentUserMessages.size === 0)) {
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For active workflows with pending messages, filter out user messages from backend
|
|
||||||
// to prevent duplicates with optimistic messages
|
|
||||||
if (pendingMessages.length > 0 || sentUserMessages.size > 0) {
|
|
||||||
return messages.filter(msg => msg.role !== 'user');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: show all messages
|
|
||||||
return messages;
|
|
||||||
}, [messages, sentUserMessages, pendingMessages, currentWorkflow]);
|
|
||||||
|
|
||||||
// Auto-polling for active workflows and message updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPolling && currentWorkflowId) {
|
|
||||||
pollingIntervalRef.current = window.setInterval(() => {
|
|
||||||
refetchStatus();
|
|
||||||
if (currentWorkflow) {
|
|
||||||
const isActive = ['running', 'processing', 'started'].includes(currentWorkflow.status);
|
|
||||||
if (isActive) {
|
|
||||||
refetchMessages();
|
|
||||||
refetchLogs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (pollingIntervalRef.current) {
|
|
||||||
clearInterval(pollingIntervalRef.current);
|
|
||||||
pollingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isPolling, currentWorkflowId, currentWorkflow?.status, refetchStatus, refetchMessages, refetchLogs]);
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const loadWorkflow = useCallback(async (workflowId: string) => {
|
|
||||||
// Immediately clear pending state when loading a different workflow
|
|
||||||
if (currentWorkflowId !== workflowId) {
|
|
||||||
setPendingMessages([]);
|
|
||||||
setSentUserMessages(new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentWorkflowId(workflowId);
|
|
||||||
}, [currentWorkflowId]);
|
|
||||||
|
|
||||||
const startNewWorkflow = useCallback(async (prompt: string, fileIds: string[] = []): Promise<string | null> => {
|
|
||||||
// Add optimistic message immediately
|
|
||||||
const optimisticMessage = createOptimisticMessage(prompt, fileIds);
|
|
||||||
setPendingMessages(prev => [...prev, optimisticMessage]);
|
|
||||||
|
|
||||||
// Track this message as sent
|
|
||||||
setSentUserMessages(prev => new Set(prev).add(prompt.trim()));
|
|
||||||
|
|
||||||
const workflowData: StartWorkflowRequest = {
|
|
||||||
prompt,
|
|
||||||
listFileId: fileIds
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await startWorkflow(workflowData);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
const newWorkflowId = result.data.id;
|
|
||||||
setCurrentWorkflowId(newWorkflowId);
|
|
||||||
setIsPolling(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
refetchMessages();
|
|
||||||
refetchLogs();
|
|
||||||
}, 500);
|
|
||||||
return newWorkflowId;
|
|
||||||
} else {
|
|
||||||
// Remove optimistic message and sent tracking on failure
|
|
||||||
setPendingMessages(prev => prev.filter(msg => msg.id !== optimisticMessage.id));
|
|
||||||
setSentUserMessages(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(prompt.trim());
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [startWorkflow, refetchMessages, createOptimisticMessage]);
|
|
||||||
|
|
||||||
const continueWorkflow = useCallback(async (prompt: string, fileIds: string[] = []): Promise<boolean> => {
|
|
||||||
if (!currentWorkflowId) return false;
|
|
||||||
|
|
||||||
// Add optimistic message immediately
|
|
||||||
const optimisticMessage = createOptimisticMessage(prompt, fileIds);
|
|
||||||
setPendingMessages(prev => [...prev, optimisticMessage]);
|
|
||||||
|
|
||||||
// Track this message as sent
|
|
||||||
setSentUserMessages(prev => new Set(prev).add(prompt.trim()));
|
|
||||||
|
|
||||||
const workflowData: StartWorkflowRequest = {
|
|
||||||
prompt,
|
|
||||||
listFileId: fileIds
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await startWorkflow(workflowData, currentWorkflowId);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setIsPolling(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
refetchMessages();
|
|
||||||
refetchLogs();
|
|
||||||
}, 500);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// Remove optimistic message and sent tracking on failure
|
|
||||||
setPendingMessages(prev => prev.filter(msg => msg.id !== optimisticMessage.id));
|
|
||||||
setSentUserMessages(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(prompt.trim());
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [currentWorkflowId, startWorkflow, refetchMessages, refetchLogs, createOptimisticMessage]);
|
|
||||||
|
|
||||||
const stopWorkflow = useCallback(async (): Promise<boolean> => {
|
|
||||||
if (!currentWorkflowId) return false;
|
|
||||||
|
|
||||||
const result = await stopWorkflowRequest(currentWorkflowId);
|
|
||||||
if (result) {
|
|
||||||
// Immediately refresh status to reflect the stopped state
|
|
||||||
setTimeout(() => {
|
|
||||||
refetchStatus();
|
|
||||||
refetchMessages();
|
|
||||||
refetchLogs();
|
|
||||||
}, 500);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [currentWorkflowId, stopWorkflowRequest, refetchStatus, refetchMessages, refetchLogs]);
|
|
||||||
|
|
||||||
const clearWorkflow = useCallback(() => {
|
|
||||||
setCurrentWorkflowId(null);
|
|
||||||
setIsPolling(false);
|
|
||||||
setPendingMessages([]);
|
|
||||||
setSentUserMessages(new Set());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectPrompt = useCallback((prompt: any | null) => {
|
|
||||||
setSelectedPrompt(prompt);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearPrompt = useCallback(() => {
|
|
||||||
setSelectedPrompt(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Only clear pending messages when workflow changes to a different ID
|
|
||||||
// (not when creating a new workflow)
|
|
||||||
const previousWorkflowId = useRef(currentWorkflowId);
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = previousWorkflowId.current;
|
|
||||||
const current = currentWorkflowId;
|
|
||||||
|
|
||||||
// If we're switching between different existing workflows, clear state
|
|
||||||
if (prev && current && prev !== current) {
|
|
||||||
setPendingMessages([]);
|
|
||||||
setSentUserMessages(new Set());
|
|
||||||
}
|
|
||||||
// If we're going from a workflow to no workflow, clear state
|
|
||||||
else if (prev && !current) {
|
|
||||||
setPendingMessages([]);
|
|
||||||
setSentUserMessages(new Set());
|
|
||||||
}
|
|
||||||
// If we're going from no workflow to a workflow (new workflow creation), keep pending messages
|
|
||||||
|
|
||||||
previousWorkflowId.current = current;
|
|
||||||
}, [currentWorkflowId]);
|
|
||||||
|
|
||||||
// Sync with external workflow ID changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialWorkflowId !== currentWorkflowId) {
|
|
||||||
if (initialWorkflowId) {
|
|
||||||
loadWorkflow(initialWorkflowId);
|
|
||||||
} else {
|
|
||||||
setCurrentWorkflowId(null);
|
|
||||||
setIsPolling(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [initialWorkflowId]);
|
|
||||||
|
|
||||||
// Auto-enable polling only for active workflows
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentWorkflowId && currentWorkflow) {
|
|
||||||
const isActive = ['running', 'processing', 'started'].includes(currentWorkflow.status);
|
|
||||||
setIsPolling(isActive);
|
|
||||||
|
|
||||||
// Clear pending messages and sent user tracking when workflow completes
|
|
||||||
// This allows all backend messages to show for completed workflows
|
|
||||||
if (!isActive && (pendingMessages.length > 0 || sentUserMessages.size > 0)) {
|
|
||||||
console.log('🏁 Workflow completed, clearing pending messages and sent user tracking');
|
|
||||||
setPendingMessages([]);
|
|
||||||
setSentUserMessages(new Set());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setIsPolling(false);
|
|
||||||
}
|
|
||||||
}, [currentWorkflowId, currentWorkflow?.status, pendingMessages.length, sentUserMessages.size]);
|
|
||||||
|
|
||||||
const state: WorkflowState = {
|
|
||||||
currentWorkflowId,
|
|
||||||
workflow: currentWorkflow,
|
|
||||||
messages: filteredMessages,
|
|
||||||
pendingMessages,
|
|
||||||
logs: logs || [],
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
selectedPrompt
|
|
||||||
};
|
|
||||||
|
|
||||||
const actions: WorkflowActions = useMemo(() => ({
|
|
||||||
loadWorkflow,
|
|
||||||
startNewWorkflow,
|
|
||||||
continueWorkflow,
|
|
||||||
stopWorkflow,
|
|
||||||
clearWorkflow,
|
|
||||||
selectPrompt,
|
|
||||||
clearPrompt
|
|
||||||
}), [loadWorkflow, startNewWorkflow, continueWorkflow, stopWorkflow, clearWorkflow, selectPrompt, clearPrompt]);
|
|
||||||
|
|
||||||
return [state, actions];
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
|
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
|
||||||
|
|
||||||
import { Popup, PopupAction } from '../ui/Popup/Popup';
|
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { useFileOperations } from '../../hooks/useFiles';
|
import { useFileOperations } from '../../hooks/useFiles';
|
||||||
import {
|
import {
|
||||||
JsonRenderer,
|
JsonRenderer,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../FilePreview.module.css';
|
||||||
|
|
||||||
interface ErrorRendererProps {
|
interface ErrorRendererProps {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../FilePreview.module.css';
|
||||||
|
|
||||||
interface JsonRendererProps {
|
interface JsonRendererProps {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../FilePreview.module.css';
|
||||||
|
|
||||||
export function LoadingRenderer() {
|
export function LoadingRenderer() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { IoIosWarning } from 'react-icons/io';
|
import { IoIosWarning } from 'react-icons/io';
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../FilePreview.module.css';
|
||||||
|
|
||||||
interface PdfRendererProps {
|
interface PdfRendererProps {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../FilePreview.module.css';
|
import styles from '../FilePreview.module.css';
|
||||||
|
|
||||||
interface UnsupportedRendererProps {
|
interface UnsupportedRendererProps {
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,33 @@
|
||||||
background: var(--color-secondary-hover);
|
background: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionButton.connect {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.connect:hover {
|
||||||
|
background: var(--color-secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.refresh {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.refresh:hover {
|
||||||
|
background: var(--color-secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.remove {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.remove:hover {
|
||||||
|
background: var(--color-secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.actionButtons {
|
.actionButtons {
|
||||||
|
|
@ -232,4 +259,20 @@
|
||||||
.actionButton.copy:hover {
|
.actionButton.copy:hover {
|
||||||
background: var(--color-secondary-hover);
|
background: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionButton.connect {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.connect:hover {
|
||||||
|
background: var(--color-secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.refresh {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.refresh:hover {
|
||||||
|
background: var(--color-secondary-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { IoIosLink } from 'react-icons/io';
|
||||||
|
import { IoIosRefresh } from 'react-icons/io';
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
|
export interface ConnectActionButtonProps<T = any> {
|
||||||
|
row: T;
|
||||||
|
disabled?: boolean | { disabled: boolean; message?: string };
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
connectTitle?: string;
|
||||||
|
refreshTitle?: string;
|
||||||
|
hookData: any; // REQUIRED: Contains all hook data including operations
|
||||||
|
// Field mappings
|
||||||
|
idField?: string; // Field name for the unique identifier
|
||||||
|
statusField?: string; // Field name for the status field
|
||||||
|
operationName?: string; // Name of the connect operation in hookData
|
||||||
|
loadingStateName?: string; // Name of the loading state in hookData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectActionButton<T = any>({
|
||||||
|
row,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
className = '',
|
||||||
|
title,
|
||||||
|
connectTitle,
|
||||||
|
refreshTitle,
|
||||||
|
hookData,
|
||||||
|
idField = 'id',
|
||||||
|
statusField = 'status',
|
||||||
|
operationName = 'connectWithPopup',
|
||||||
|
loadingStateName = 'isConnecting'
|
||||||
|
}: ConnectActionButtonProps<T>) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
// Extract disabled state and tooltip message
|
||||||
|
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
||||||
|
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
|
||||||
|
|
||||||
|
// Validate that hookData is provided with required operations
|
||||||
|
if (!hookData) {
|
||||||
|
throw new Error('ConnectActionButton requires hookData to be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the connection data from the row
|
||||||
|
const connectionStatus = (row as any)[statusField];
|
||||||
|
const connectionId = (row as any)[idField];
|
||||||
|
const isActive = connectionStatus === 'active';
|
||||||
|
|
||||||
|
// Extract operations from hookData
|
||||||
|
const handleConnect = hookData[operationName];
|
||||||
|
const refetch = hookData.refetch;
|
||||||
|
const loadingState = hookData[loadingStateName];
|
||||||
|
|
||||||
|
// Validate required operations exist
|
||||||
|
if (!handleConnect) {
|
||||||
|
throw new Error(`ConnectActionButton requires hookData.${operationName} to be defined`);
|
||||||
|
}
|
||||||
|
if (!refetch) {
|
||||||
|
throw new Error('ConnectActionButton requires hookData.refetch to be defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isDisabled && !loading && !isProcessing) {
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
// Always use the connect operation for both active and inactive connections
|
||||||
|
// The backend will handle refreshing tokens for active connections
|
||||||
|
if (handleConnect) {
|
||||||
|
await handleConnect(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refetch to update the connection status
|
||||||
|
if (refetch) {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Connection operation failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine button title and icon based on connection status
|
||||||
|
const defaultTitle = isActive
|
||||||
|
? (refreshTitle || t('connections.action.refresh', 'Refresh'))
|
||||||
|
: (connectTitle || t('connections.action.connect', 'Connect'));
|
||||||
|
|
||||||
|
const buttonTitle = title || defaultTitle;
|
||||||
|
const buttonIcon = isActive ? <IoIosRefresh /> : <IoIosLink />;
|
||||||
|
|
||||||
|
// Check if this specific connection is being processed
|
||||||
|
const isLoadingState = loadingState === true || isProcessing;
|
||||||
|
|
||||||
|
// Determine the final button title (tooltip)
|
||||||
|
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`${styles.actionButton} ${isActive ? styles.refresh : styles.connect} ${isLoadingState ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
|
||||||
|
title={finalTitle}
|
||||||
|
disabled={isDisabled || loading || isLoadingState}
|
||||||
|
>
|
||||||
|
<span className={styles.actionIcon}>
|
||||||
|
{isLoadingState ? <IoIosRefresh /> : buttonIcon}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectActionButton;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as ConnectActionButton } from './ConnectActionButton';
|
||||||
|
export type { ConnectActionButtonProps } from './ConnectActionButton';
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { IoCopy } from 'react-icons/io5';
|
import { IoCopy } from 'react-icons/io5';
|
||||||
import { useLanguage } from '../../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import styles from '../ActionButton.module.css';
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
export interface CopyActionButtonProps<T = any> {
|
export interface CopyActionButtonProps<T = any> {
|
||||||
|
|
@ -11,13 +11,8 @@ export interface CopyActionButtonProps<T = any> {
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
isCopying?: boolean;
|
isCopying?: boolean;
|
||||||
hookData?: any; // Contains all hook data including operations
|
// Field mappings for clipboard copy
|
||||||
// Field mappings
|
contentField?: string; // Field name for content to copy to clipboard (default: 'content')
|
||||||
idField?: string; // Field name for the unique identifier
|
|
||||||
nameField?: string; // Field name for display name
|
|
||||||
contentField?: string; // Field name for content
|
|
||||||
loadingStateName?: string; // Name of the loading state in hookData
|
|
||||||
operationName?: string; // Name of the operation function in hookData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyActionButton<T = any>({
|
export function CopyActionButton<T = any>({
|
||||||
|
|
@ -28,11 +23,7 @@ export function CopyActionButton<T = any>({
|
||||||
className = '',
|
className = '',
|
||||||
title,
|
title,
|
||||||
isCopying = false,
|
isCopying = false,
|
||||||
hookData,
|
contentField = 'content'
|
||||||
nameField = 'name',
|
|
||||||
contentField = 'content',
|
|
||||||
loadingStateName = 'creatingPrompt',
|
|
||||||
operationName = 'handlePromptCreate'
|
|
||||||
}: CopyActionButtonProps<T>) {
|
}: CopyActionButtonProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [internalLoading, setInternalLoading] = useState(false);
|
const [internalLoading, setInternalLoading] = useState(false);
|
||||||
|
|
@ -47,48 +38,33 @@ export function CopyActionButton<T = any>({
|
||||||
if (!isDisabled && !loading && !isCopying && !internalLoading) {
|
if (!isDisabled && !loading && !isCopying && !internalLoading) {
|
||||||
setInternalLoading(true);
|
setInternalLoading(true);
|
||||||
try {
|
try {
|
||||||
// If operationName is provided and hookData is available, use the hook function
|
if (onCopy) {
|
||||||
if (operationName && hookData && hookData[operationName]) {
|
// Use custom copy function if provided
|
||||||
// Extract data from row for creating a copy
|
|
||||||
const copyData = {
|
|
||||||
name: `${(row as any)[nameField]} (Copy)`,
|
|
||||||
content: (row as any)[contentField],
|
|
||||||
mandateId: (row as any).mandateId
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await hookData[operationName](copyData);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Show copied feedback
|
|
||||||
setShowCopiedFeedback(true);
|
|
||||||
setTimeout(() => setShowCopiedFeedback(false), 2000);
|
|
||||||
|
|
||||||
// Refetch to update the list
|
|
||||||
if (hookData.refetch) {
|
|
||||||
await hookData.refetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (onCopy) {
|
|
||||||
// Fallback to the provided onCopy function
|
|
||||||
await onCopy(row);
|
await onCopy(row);
|
||||||
setShowCopiedFeedback(true);
|
|
||||||
setTimeout(() => setShowCopiedFeedback(false), 2000);
|
|
||||||
} else {
|
} else {
|
||||||
console.error('No copy function available');
|
// Default behavior: copy the specified field content to clipboard
|
||||||
|
const contentToCopy = (row as any)[contentField];
|
||||||
|
|
||||||
|
if (contentToCopy !== undefined && contentToCopy !== null) {
|
||||||
|
await navigator.clipboard.writeText(String(contentToCopy));
|
||||||
|
} else {
|
||||||
|
console.warn(`Field "${contentField}" not found or is empty in row:`, row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show copied feedback
|
||||||
|
setShowCopiedFeedback(true);
|
||||||
|
setTimeout(() => setShowCopiedFeedback(false), 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Copy failed:', error);
|
console.error('Copy to clipboard failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setInternalLoading(false);
|
setInternalLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonTitle = title || t('prompts.action.copy', 'Copy');
|
const buttonTitle = title || t('prompts.action.copy', 'Copy to Clipboard');
|
||||||
// Use hookData copying state if available, otherwise use passed isCopying
|
const isLoading = loading || isCopying || internalLoading;
|
||||||
const loadingState = hookData?.[loadingStateName];
|
|
||||||
const actualIsCopying = (typeof loadingState === 'boolean' && loadingState) || isCopying;
|
|
||||||
const isLoading = loading || actualIsCopying || internalLoading;
|
|
||||||
|
|
||||||
// Determine the final button title (tooltip)
|
// Determine the final button title (tooltip)
|
||||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { IoIosTrash, IoIosCheckmark, IoIosClose } from 'react-icons/io';
|
import { IoIosTrash, IoIosCheckmark, IoIosClose } from 'react-icons/io';
|
||||||
import { useLanguage } from '../../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import styles from '../ActionButton.module.css';
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
export interface DeleteActionButtonProps<T = any> {
|
export interface DeleteActionButtonProps<T = any> {
|
||||||
|
|
@ -56,6 +56,7 @@ export function DeleteActionButton<T = any>({
|
||||||
const refetch = hookData.refetch;
|
const refetch = hookData.refetch;
|
||||||
const loadingState = hookData[loadingStateName];
|
const loadingState = hookData[loadingStateName];
|
||||||
|
|
||||||
|
|
||||||
// Validate required operations exist
|
// Validate required operations exist
|
||||||
if (!handleDelete) {
|
if (!handleDelete) {
|
||||||
throw new Error(`DeleteActionButton requires hookData.${operationName} to be defined`);
|
throw new Error(`DeleteActionButton requires hookData.${operationName} to be defined`);
|
||||||
|
|
@ -117,12 +118,11 @@ export function DeleteActionButton<T = any>({
|
||||||
const success = await handleDelete(itemId);
|
const success = await handleDelete(itemId);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// If we used optimistic removal, delay refetch to ensure server has synced
|
// If we used optimistic removal, don't refetch immediately
|
||||||
|
// The item is already removed from UI, and refetch might bring it back
|
||||||
if (removeOptimistically) {
|
if (removeOptimistically) {
|
||||||
// Delay refetch by 500ms to give server time to fully process deletion
|
// Only refetch if there was an error or if we need to sync other changes
|
||||||
setTimeout(() => {
|
// For now, we trust the optimistic removal worked
|
||||||
refetch();
|
|
||||||
}, 500);
|
|
||||||
} else {
|
} else {
|
||||||
// No optimistic removal, refetch immediately
|
// No optimistic removal, refetch immediately
|
||||||
refetch();
|
refetch();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { IoIosDownload } from 'react-icons/io';
|
import { IoIosDownload } from 'react-icons/io';
|
||||||
import { useLanguage } from '../../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import styles from '../ActionButton.module.css';
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
export interface DownloadActionButtonProps<T = any> {
|
export interface DownloadActionButtonProps<T = any> {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { MdModeEdit } from 'react-icons/md';
|
import { MdModeEdit } from 'react-icons/md';
|
||||||
import { useLanguage } from '../../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import { Popup, EditForm } from '../../../ui/Popup';
|
import { Popup, EditForm } from '../../../UiComponents/Popup';
|
||||||
import styles from '../ActionButton.module.css';
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
export interface EditActionButtonProps<T = any> {
|
export interface EditActionButtonProps<T = any> {
|
||||||
|
|
@ -121,32 +121,50 @@ export function EditActionButton<T = any>({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if optimistic update is available
|
||||||
|
const updateOptimistically = hookData.updateOptimistically || hookData.updateFileOptimistically;
|
||||||
|
|
||||||
// Validate required operation exists
|
// Validate required operation exists
|
||||||
if (!hookData[operationName]) {
|
if (!hookData[operationName]) {
|
||||||
throw new Error(`EditActionButton requires hookData.${operationName} to be defined`);
|
throw new Error(`EditActionButton requires hookData.${operationName} to be defined`);
|
||||||
}
|
}
|
||||||
if (!hookData.refetch) {
|
|
||||||
throw new Error('EditActionButton requires hookData.refetch to be defined');
|
// Optimistically update the UI immediately
|
||||||
|
if (updateOptimistically) {
|
||||||
|
updateOptimistically(itemId, updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use hookData operation to update
|
// Close popup and reset state immediately for better UX
|
||||||
|
setIsPopupOpen(false);
|
||||||
|
setEditData(null);
|
||||||
|
|
||||||
|
// Use hookData operation to update in the background
|
||||||
const result = await hookData[operationName](itemId, updateData, editData);
|
const result = await hookData[operationName](itemId, updateData, editData);
|
||||||
const success = result?.success || result === true;
|
const success = result?.success || result === true;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Close popup and reset state
|
// If we used optimistic update, don't refetch to avoid overwriting our changes
|
||||||
setIsPopupOpen(false);
|
if (updateOptimistically) {
|
||||||
setEditData(null);
|
// Trust the optimistic update worked
|
||||||
|
} else {
|
||||||
// Trigger refetch to sync with backend
|
// No optimistic update, refetch to sync with backend
|
||||||
await hookData.refetch();
|
if (hookData.refetch) {
|
||||||
|
await hookData.refetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// If update failed, refetch to restore original state
|
||||||
|
if (hookData.refetch) {
|
||||||
|
await hookData.refetch();
|
||||||
|
}
|
||||||
console.error('Failed to update item:', itemId);
|
console.error('Failed to update item:', itemId);
|
||||||
// TODO: Show error message to user
|
// TODO: Show error message to user
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// If update failed, refetch to restore original state
|
||||||
|
if (hookData.refetch) {
|
||||||
|
await hookData.refetch();
|
||||||
|
}
|
||||||
console.error('Failed to update item:', error);
|
console.error('Failed to update item:', error);
|
||||||
// TODO: Show error message to user
|
// TODO: Show error message to user
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { IoIosPlay } from 'react-icons/io';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import { useWorkflowSelection } from '../../../../contexts/WorkflowSelectionContext';
|
||||||
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
|
export interface PlayActionButtonProps<T = any> {
|
||||||
|
row: T;
|
||||||
|
onPlay?: (row: T) => Promise<void> | void;
|
||||||
|
disabled?: boolean | { disabled: boolean; message?: string };
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
hookData?: any; // Contains all hook data including operations
|
||||||
|
// Field mappings
|
||||||
|
idField?: string; // Field name for the unique identifier
|
||||||
|
nameField?: string; // Field name for display name
|
||||||
|
// Navigation
|
||||||
|
navigateTo?: string; // Path to navigate to after selection (default: 'start/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayActionButton<T = any>({
|
||||||
|
row,
|
||||||
|
onPlay,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
className = '',
|
||||||
|
title,
|
||||||
|
hookData,
|
||||||
|
idField = 'id',
|
||||||
|
nameField = 'name',
|
||||||
|
navigateTo = 'start/dashboard'
|
||||||
|
}: PlayActionButtonProps<T>) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { selectWorkflow } = useWorkflowSelection();
|
||||||
|
|
||||||
|
// Extract disabled state and tooltip message
|
||||||
|
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
||||||
|
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
|
||||||
|
|
||||||
|
const handleClick = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isDisabled && !loading) {
|
||||||
|
try {
|
||||||
|
// Get workflow ID from row
|
||||||
|
const workflowId = (row as any)[idField];
|
||||||
|
if (!workflowId) {
|
||||||
|
console.error('Workflow ID not found in row');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the onPlay callback if provided
|
||||||
|
if (onPlay) {
|
||||||
|
await onPlay(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select the workflow in context
|
||||||
|
selectWorkflow(workflowId);
|
||||||
|
|
||||||
|
// Navigate to dashboard (or specified path)
|
||||||
|
navigate(`/${navigateTo}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error playing workflow:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonTitle = title || t('workflows.action.play', 'Play');
|
||||||
|
const isLoading = loading;
|
||||||
|
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`${styles.actionButton} ${styles.play} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
|
||||||
|
title={finalTitle}
|
||||||
|
disabled={isDisabled || isLoading}
|
||||||
|
>
|
||||||
|
<span className={styles.actionIcon}>
|
||||||
|
{isLoading ? '⏳' : <IoIosPlay />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlayActionButton;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { PlayActionButton } from './PlayActionButton';
|
||||||
|
export type { PlayActionButtonProps } from './PlayActionButton';
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { IoIosCloseCircle } from 'react-icons/io';
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
|
export interface RemoveActionButtonProps<T = any> {
|
||||||
|
row: T;
|
||||||
|
onRemove: (row: T) => Promise<void> | void;
|
||||||
|
disabled?: boolean | { disabled: boolean; message?: string };
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
idField?: string; // Field name for the unique identifier
|
||||||
|
loadingStateName?: string; // Name of the loading state in hookData
|
||||||
|
hookData?: any; // Optional hook data for loading state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveActionButton<T = any>({
|
||||||
|
row,
|
||||||
|
onRemove,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
className = '',
|
||||||
|
title,
|
||||||
|
idField = 'id',
|
||||||
|
loadingStateName = 'removingItems',
|
||||||
|
hookData
|
||||||
|
}: RemoveActionButtonProps<T>) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [internalLoading, setInternalLoading] = useState(false);
|
||||||
|
|
||||||
|
// Extract disabled state and tooltip message
|
||||||
|
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
||||||
|
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
|
||||||
|
|
||||||
|
const handleClick = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isDisabled && !loading && !internalLoading) {
|
||||||
|
setInternalLoading(true);
|
||||||
|
try {
|
||||||
|
await onRemove(row);
|
||||||
|
} finally {
|
||||||
|
setInternalLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonTitle = title || t('files.action.remove', 'Remove from workflow');
|
||||||
|
|
||||||
|
// Use hookData removing state if available, otherwise use passed loading
|
||||||
|
const loadingState = hookData?.[loadingStateName];
|
||||||
|
const itemId = (row as any)[idField];
|
||||||
|
const actualIsRemoving = loadingState?.has?.(itemId) || false;
|
||||||
|
const isLoading = loading || actualIsRemoving || internalLoading;
|
||||||
|
|
||||||
|
// Determine the final button title (tooltip)
|
||||||
|
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`${styles.actionButton} ${styles.remove} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
|
||||||
|
title={finalTitle}
|
||||||
|
disabled={isDisabled || isLoading}
|
||||||
|
>
|
||||||
|
<span className={styles.actionIcon}>
|
||||||
|
{isLoading ? '⏳' : <IoIosCloseCircle />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RemoveActionButton;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export { default as RemoveActionButton } from './RemoveActionButton';
|
||||||
|
export type { RemoveActionButtonProps } from './RemoveActionButton';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { IoIosEye } from 'react-icons/io';
|
import { IoIosEye } from 'react-icons/io';
|
||||||
import { useLanguage } from '../../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import { FilePreview } from '../../../FilePreview/FilePreview';
|
import { FilePreview } from '../../../FilePreview/FilePreview';
|
||||||
import styles from '../ActionButton.module.css';
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ export { DeleteActionButton } from './DeleteActionButton';
|
||||||
export { DownloadActionButton } from './DownloadActionButton';
|
export { DownloadActionButton } from './DownloadActionButton';
|
||||||
export { ViewActionButton } from './ViewActionButton';
|
export { ViewActionButton } from './ViewActionButton';
|
||||||
export { CopyActionButton } from './CopyActionButton';
|
export { CopyActionButton } from './CopyActionButton';
|
||||||
|
export { ConnectActionButton } from './ConnectActionButton';
|
||||||
|
export { PlayActionButton } from './PlayActionButton';
|
||||||
|
export { RemoveActionButton } from './RemoveActionButton';
|
||||||
|
|
||||||
// Action Button Types
|
// Action Button Types
|
||||||
export type { EditActionButtonProps } from './EditActionButton';
|
export type { EditActionButtonProps } from './EditActionButton';
|
||||||
|
|
@ -11,3 +14,6 @@ export type { DeleteActionButtonProps } from './DeleteActionButton';
|
||||||
export type { DownloadActionButtonProps } from './DownloadActionButton';
|
export type { DownloadActionButtonProps } from './DownloadActionButton';
|
||||||
export type { ViewActionButtonProps } from './ViewActionButton';
|
export type { ViewActionButtonProps } from './ViewActionButton';
|
||||||
export type { CopyActionButtonProps } from './CopyActionButton';
|
export type { CopyActionButtonProps } from './CopyActionButton';
|
||||||
|
export type { ConnectActionButtonProps } from './ConnectActionButton';
|
||||||
|
export type { PlayActionButtonProps } from './PlayActionButton';
|
||||||
|
export type { RemoveActionButtonProps } from './RemoveActionButton';
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import styles from './FormGenerator.module.css';
|
import styles from './FormGenerator.module.css';
|
||||||
import {
|
import {
|
||||||
EditActionButton,
|
EditActionButton,
|
||||||
DeleteActionButton,
|
DeleteActionButton,
|
||||||
DownloadActionButton,
|
DownloadActionButton,
|
||||||
ViewActionButton,
|
ViewActionButton,
|
||||||
CopyActionButton
|
CopyActionButton,
|
||||||
|
ConnectActionButton,
|
||||||
|
PlayActionButton
|
||||||
} from './ActionButtons';
|
} from './ActionButtons';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../UiComponents/Button';
|
||||||
|
|
||||||
import { IoIosRefresh } from "react-icons/io";
|
import { IoIosRefresh } from "react-icons/io";
|
||||||
import { FaTrash } from "react-icons/fa";
|
import { FaTrash } from "react-icons/fa";
|
||||||
|
|
@ -47,7 +49,7 @@ export interface FormGeneratorProps<T = any> {
|
||||||
isRowSelectable?: (row: T) => boolean;
|
isRowSelectable?: (row: T) => boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
actionButtons?: {
|
actionButtons?: {
|
||||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy';
|
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
|
||||||
onAction?: (row: T) => Promise<void> | void; // Optional for delete buttons since they handle their own logic
|
onAction?: (row: T) => Promise<void> | void; // Optional for delete buttons since they handle their own logic
|
||||||
disabled?: (row: T) => boolean | { disabled: boolean; message?: string };
|
disabled?: (row: T) => boolean | { disabled: boolean; message?: string };
|
||||||
loading?: (row: T) => boolean;
|
loading?: (row: T) => boolean;
|
||||||
|
|
@ -60,8 +62,11 @@ export interface FormGeneratorProps<T = any> {
|
||||||
nameField?: string; // Field name for display name
|
nameField?: string; // Field name for display name
|
||||||
typeField?: string; // Field name for type/mime type
|
typeField?: string; // Field name for type/mime type
|
||||||
contentField?: string; // Field name for content (used by copy button)
|
contentField?: string; // Field name for content (used by copy button)
|
||||||
|
statusField?: string; // Field name for status (used by connect button)
|
||||||
|
authorityField?: string; // Field name for authority (msft/google) (used by connect button)
|
||||||
// Operation and loading state names
|
// Operation and loading state names
|
||||||
operationName?: string; // Name of the operation function in hookData
|
operationName?: string; // Name of the operation function in hookData
|
||||||
|
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
|
||||||
loadingStateName?: string; // Name of the loading state in hookData
|
loadingStateName?: string; // Name of the loading state in hookData
|
||||||
// Edit configuration (for edit buttons)
|
// Edit configuration (for edit buttons)
|
||||||
editFields?: Array<{
|
editFields?: Array<{
|
||||||
|
|
@ -201,8 +206,36 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
} else if (column?.type === 'number') {
|
} else if (column?.type === 'number') {
|
||||||
return Number(value) === Number(filterValue);
|
return Number(value) === Number(filterValue);
|
||||||
} else if (column?.type === 'date') {
|
} else if (column?.type === 'date') {
|
||||||
// Convert row value to DD.MM.YYYY format for comparison
|
// Handle Unix timestamps in seconds (backend format) by converting to milliseconds
|
||||||
const rowDate = new Date(value);
|
let rowDate: Date;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
// If it's a number, check if it's in seconds (typical Unix timestamp range)
|
||||||
|
if (value < 10000000000) { // Less than year 2286 in seconds
|
||||||
|
rowDate = new Date(value * 1000); // Convert seconds to milliseconds
|
||||||
|
} else {
|
||||||
|
rowDate = new Date(value); // Already in milliseconds
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
// Check if it's an ISO date string or date string (contains 'T', '-', or ':')
|
||||||
|
if (value.includes('T') || value.includes('-') || value.includes(':')) {
|
||||||
|
rowDate = new Date(value); // Parse as date string (ISO or other formats)
|
||||||
|
} else {
|
||||||
|
// Try to parse as number (Unix timestamp as string)
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
if (numValue < 10000000000) { // Less than year 2286 in seconds
|
||||||
|
rowDate = new Date(numValue * 1000); // Convert seconds to milliseconds
|
||||||
|
} else {
|
||||||
|
rowDate = new Date(numValue); // Already in milliseconds
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rowDate = new Date(value); // Fallback: try parsing as date string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rowDate = new Date(value);
|
||||||
|
}
|
||||||
|
|
||||||
const rowFormatted = `${rowDate.getDate().toString().padStart(2, '0')}.${(rowDate.getMonth() + 1).toString().padStart(2, '0')}.${rowDate.getFullYear()}`;
|
const rowFormatted = `${rowDate.getDate().toString().padStart(2, '0')}.${(rowDate.getMonth() + 1).toString().padStart(2, '0')}.${rowDate.getFullYear()}`;
|
||||||
|
|
||||||
// Check if filter value is complete (DD.MM.YYYY)
|
// Check if filter value is complete (DD.MM.YYYY)
|
||||||
|
|
@ -416,7 +449,37 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
switch (column.type) {
|
switch (column.type) {
|
||||||
case 'date':
|
case 'date':
|
||||||
try {
|
try {
|
||||||
const date = new Date(value);
|
// Handle Unix timestamps in seconds (backend format) by converting to milliseconds
|
||||||
|
let date: Date;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
// If it's a number, check if it's in seconds (typical Unix timestamp range)
|
||||||
|
// Unix timestamps in seconds are typically much smaller than milliseconds
|
||||||
|
if (value < 10000000000) { // Less than year 2286 in seconds
|
||||||
|
date = new Date(value * 1000); // Convert seconds to milliseconds
|
||||||
|
} else {
|
||||||
|
date = new Date(value); // Already in milliseconds
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
// Check if it's an ISO date string or date string (contains 'T', '-', or ':')
|
||||||
|
if (value.includes('T') || value.includes('-') || value.includes(':')) {
|
||||||
|
date = new Date(value); // Parse as date string (ISO or other formats)
|
||||||
|
} else {
|
||||||
|
// Try to parse as number (Unix timestamp as string)
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
if (numValue < 10000000000) { // Less than year 2286 in seconds
|
||||||
|
date = new Date(numValue * 1000); // Convert seconds to milliseconds
|
||||||
|
} else {
|
||||||
|
date = new Date(numValue); // Already in milliseconds
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
date = new Date(value); // Fallback: try parsing as date string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
date = new Date(value);
|
||||||
|
}
|
||||||
|
|
||||||
if (isNaN(date.getTime())) return '-';
|
if (isNaN(date.getTime())) return '-';
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
|
@ -787,7 +850,10 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
nameField: actionButton.nameField ?? 'name',
|
nameField: actionButton.nameField ?? 'name',
|
||||||
typeField: actionButton.typeField ?? 'type',
|
typeField: actionButton.typeField ?? 'type',
|
||||||
contentField: actionButton.contentField ?? 'content',
|
contentField: actionButton.contentField ?? 'content',
|
||||||
|
statusField: actionButton.statusField ?? 'status',
|
||||||
|
authorityField: actionButton.authorityField ?? 'authority',
|
||||||
operationName: actionButton.operationName,
|
operationName: actionButton.operationName,
|
||||||
|
refreshOperationName: actionButton.refreshOperationName,
|
||||||
loadingStateName: actionButton.loadingStateName
|
loadingStateName: actionButton.loadingStateName
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -807,7 +873,11 @@ export function FormGenerator<T extends Record<string, any>>({
|
||||||
case 'view':
|
case 'view':
|
||||||
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
|
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
|
||||||
case 'copy':
|
case 'copy':
|
||||||
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} hookData={hookData} operationName={actionButton.operationName} contentField={actionButton.contentField} />;
|
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
|
||||||
|
case 'connect':
|
||||||
|
return <ConnectActionButton key={actionIndex} {...baseProps} hookData={hookData} />;
|
||||||
|
case 'play':
|
||||||
|
return <PlayActionButton key={actionIndex} {...baseProps} onPlay={actionButton.onAction} hookData={hookData} navigateTo={actionButton.navigateTo} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { FormGenerator } from '../FormGenerator/FormGenerator';
|
import { FormGenerator } from '../FormGenerator/FormGenerator';
|
||||||
import { Popup } from '../ui/Popup/Popup';
|
import { Popup } from '../UiComponents/Popup/Popup';
|
||||||
import { EditForm, EditFieldConfig } from '../ui/Popup/EditForm';
|
import { EditForm, EditFieldConfig } from '../UiComponents/Popup/EditForm';
|
||||||
import { useMitgliederLogic } from './mitgliederLogic';
|
import { useMitgliederLogic } from './mitgliederLogic';
|
||||||
import { MitgliederTableProps } from './mitgliederTypes';
|
import { MitgliederTableProps } from './mitgliederTypes';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import styles from './MitgliederTable.module.css';
|
import styles from './MitgliederTable.module.css';
|
||||||
|
|
||||||
function MitgliederTable({ className = '', showAddUser = false, onAddUserClose }: MitgliederTableProps) {
|
function MitgliederTable({ className = '', showAddUser = false, onAddUserClose }: MitgliederTableProps) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useOrgUsers } from '../../hooks/useUsers';
|
import { useOrgUsers } from '../../hooks/useUsers';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MitgliederLogicReturn,
|
MitgliederLogicReturn,
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
.promptsTable {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promptsFormGenerator {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error state styling */
|
|
||||||
.errorState {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-error, #dc3545);
|
|
||||||
background-color: var(--color-error-bg, #f8d7da);
|
|
||||||
border: 1px solid var(--color-error-border, #f5c6cb);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retryButton {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: var(--color-primary, #007bff);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 1rem;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retryButton:hover {
|
|
||||||
background-color: var(--color-primary-dark, #0056b3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prompt-specific styling */
|
|
||||||
.promptName {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.promptContent {
|
|
||||||
color: var(--color-text);
|
|
||||||
line-height: 1.4;
|
|
||||||
max-width: 100%;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.promptName {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promptContent {
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode support */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.promptName {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.promptContent {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
import { FormGenerator } from '../FormGenerator/FormGenerator';
|
|
||||||
import { Popup } from '../ui/Popup/Popup';
|
|
||||||
import { EditForm } from '../ui/Popup/EditForm';
|
|
||||||
import { usePromptsLogic } from './promptsLogic';
|
|
||||||
import { PromptsTableProps, Prompt } from './promptsTypes';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
import styles from './PromptsTable.module.css';
|
|
||||||
|
|
||||||
// Helper function to determine if a prompt can be selected/deleted
|
|
||||||
const isPromptSelectable = (prompt: Prompt): boolean => {
|
|
||||||
// Primary check: If backend explicitly sets _hideDelete, respect that
|
|
||||||
if (prompt._hideDelete === true) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hardcoded list of the six default system prompt IDs that cannot be selected/deleted
|
|
||||||
const systemPromptIds = [
|
|
||||||
"097d34f0-9fcc-4233-bf1e-e64e13818464",
|
|
||||||
"17546dc6-b792-40e1-9aa1-0fcc4860d0c1",
|
|
||||||
"17c42519-2bf6-49b4-83f9-3cde7498310c",
|
|
||||||
"2bb85d1e-4e02-4de8-98ae-0e815267d864",
|
|
||||||
"93343a5b-49f0-4dbf-9513-2ab5f6938fd8",
|
|
||||||
"cfb51260-486f-4b42-96fe-ef03f406dcf1"
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check if this prompt is one of the system default prompts
|
|
||||||
return !systemPromptIds.includes(prompt.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
function PromptsTable({ className = '' }: PromptsTableProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const {
|
|
||||||
prompts,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
columns,
|
|
||||||
actions,
|
|
||||||
handleDeleteSingle,
|
|
||||||
handleDeleteMultiple,
|
|
||||||
editModalOpen,
|
|
||||||
editingPrompt,
|
|
||||||
editPromptFields,
|
|
||||||
handleSavePrompt,
|
|
||||||
handleCancelEdit,
|
|
||||||
refetch
|
|
||||||
} = usePromptsLogic();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className={styles.errorState}>
|
|
||||||
<p>{t('prompts.error.loading', 'Error loading prompts:')} {error}</p>
|
|
||||||
<button onClick={refetch} className={styles.retryButton}>
|
|
||||||
{t('common.retry', 'Retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.promptsTable} ${className}`}>
|
|
||||||
<FormGenerator
|
|
||||||
data={prompts}
|
|
||||||
columns={columns}
|
|
||||||
title={t('prompts.title', 'Prompts')}
|
|
||||||
searchable={true}
|
|
||||||
filterable={true}
|
|
||||||
sortable={true}
|
|
||||||
resizable={true}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={10}
|
|
||||||
selectable={true}
|
|
||||||
isRowSelectable={isPromptSelectable}
|
|
||||||
loading={loading}
|
|
||||||
actionButtons={actions}
|
|
||||||
onDelete={handleDeleteSingle}
|
|
||||||
onDeleteMultiple={handleDeleteMultiple}
|
|
||||||
onRefresh={refetch}
|
|
||||||
className={styles.promptsFormGenerator}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Edit Modal */}
|
|
||||||
<Popup
|
|
||||||
isOpen={editModalOpen}
|
|
||||||
title={t('prompts.modal.edit.title', 'Edit Prompt')}
|
|
||||||
onClose={handleCancelEdit}
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
{editingPrompt && (
|
|
||||||
<EditForm
|
|
||||||
data={editingPrompt}
|
|
||||||
fields={editPromptFields}
|
|
||||||
onSave={handleSavePrompt}
|
|
||||||
onCancel={handleCancelEdit}
|
|
||||||
saveButtonText={t('prompts.modal.edit.save', 'Save Changes')}
|
|
||||||
cancelButtonText={t('common.cancel', 'Cancel')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Popup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PromptsTable;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { default as PromptsTable } from './PromptsTable';
|
|
||||||
export { usePromptsLogic } from './promptsLogic';
|
|
||||||
export type * from './promptsTypes';
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
import { useState, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { usePrompts, usePromptOperations, Prompt } from '../../hooks/usePrompts';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
import type { EditFieldConfig } from '../ui/Popup/EditForm';
|
|
||||||
|
|
||||||
// Helper function to determine if a prompt can be deleted
|
|
||||||
const isPromptDeletable = (prompt: Prompt): boolean => {
|
|
||||||
// Primary check: If backend explicitly sets _hideDelete, respect that
|
|
||||||
if (prompt._hideDelete === true) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hardcoded list of the six default system prompt IDs that cannot be deleted
|
|
||||||
const systemPromptIds = [
|
|
||||||
"097d34f0-9fcc-4233-bf1e-e64e13818464",
|
|
||||||
"17546dc6-b792-40e1-9aa1-0fcc4860d0c1",
|
|
||||||
"17c42519-2bf6-49b4-83f9-3cde7498310c",
|
|
||||||
"2bb85d1e-4e02-4de8-98ae-0e815267d864",
|
|
||||||
"93343a5b-49f0-4dbf-9513-2ab5f6938fd8",
|
|
||||||
"cfb51260-486f-4b42-96fe-ef03f406dcf1"
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check if this prompt is one of the system default prompts
|
|
||||||
return !systemPromptIds.includes(prompt.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
import type {
|
|
||||||
PromptsLogicReturn,
|
|
||||||
PromptActionConfig,
|
|
||||||
PromptColumnConfig
|
|
||||||
} from './promptsTypes';
|
|
||||||
|
|
||||||
export function usePromptsLogic(): PromptsLogicReturn {
|
|
||||||
const { prompts, loading, error, refetch } = usePrompts();
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const {
|
|
||||||
handlePromptDelete,
|
|
||||||
handlePromptUpdate,
|
|
||||||
deletingPrompts
|
|
||||||
} = usePromptOperations();
|
|
||||||
|
|
||||||
// Edit modal state
|
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
|
||||||
const [editingPrompt, setEditingPrompt] = useState<Prompt | null>(null);
|
|
||||||
|
|
||||||
// Configure edit fields for prompt editing
|
|
||||||
const editPromptFields: EditFieldConfig[] = useMemo(() => [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
label: t('prompts.field.name', 'Prompt Name'),
|
|
||||||
type: 'string',
|
|
||||||
editable: true,
|
|
||||||
required: true,
|
|
||||||
validator: (value: string) => {
|
|
||||||
if (!value || value.trim() === '') {
|
|
||||||
return t('prompts.validation.nameRequired', 'Prompt name cannot be empty');
|
|
||||||
}
|
|
||||||
if (value.length > 100) {
|
|
||||||
return t('prompts.validation.nameTooLong', 'Prompt name cannot exceed 100 characters');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'content',
|
|
||||||
label: t('prompts.field.content', 'Prompt Content'),
|
|
||||||
type: 'textarea',
|
|
||||||
editable: true,
|
|
||||||
required: true,
|
|
||||||
minRows: 4,
|
|
||||||
maxRows: 8,
|
|
||||||
validator: (value: string) => {
|
|
||||||
if (!value || value.trim() === '') {
|
|
||||||
return t('prompts.validation.contentRequired', 'Prompt content cannot be empty');
|
|
||||||
}
|
|
||||||
if (value.length > 10000) {
|
|
||||||
return t('prompts.validation.contentTooLong', 'Prompt content cannot exceed 10,000 characters');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
// Configure columns for the prompts table
|
|
||||||
const columns: PromptColumnConfig[] = useMemo(() => [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
label: t('prompts.column.name', 'Name'),
|
|
||||||
type: 'string',
|
|
||||||
width: 200,
|
|
||||||
minWidth: 150,
|
|
||||||
maxWidth: 300,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
formatter: (value: string | undefined) => (
|
|
||||||
<span className="promptName">
|
|
||||||
{value || t('prompts.unnamed', 'Unnamed')}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'content',
|
|
||||||
label: t('prompts.column.content', 'Content'),
|
|
||||||
type: 'string',
|
|
||||||
width: 400,
|
|
||||||
minWidth: 200,
|
|
||||||
maxWidth: 600,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
formatter: (value: string | undefined) => (
|
|
||||||
<span className="promptContent" title={value}>
|
|
||||||
{value && value.length > 100 ? `${value.substring(0, 100)}...` : value || '-'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
// Handle prompt actions
|
|
||||||
const handleDeletePrompt = async (prompt: Prompt) => {
|
|
||||||
const promptName = prompt.name || prompt.id;
|
|
||||||
if (window.confirm(t('prompts.delete.confirm', 'Are you sure you want to delete "{name}"?').replace('{name}', promptName))) {
|
|
||||||
const success = await handlePromptDelete(prompt.id);
|
|
||||||
if (success) {
|
|
||||||
refetch(); // Refresh the prompts list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle single prompt deletion for bulk delete
|
|
||||||
const handleDeleteSingle = async (prompt: Prompt) => {
|
|
||||||
const promptName = prompt.name || prompt.id;
|
|
||||||
if (window.confirm(t('prompts.delete.confirm', 'Are you sure you want to delete "{name}"?').replace('{name}', promptName))) {
|
|
||||||
const success = await handlePromptDelete(prompt.id);
|
|
||||||
if (success) {
|
|
||||||
refetch(); // Refresh the prompts list
|
|
||||||
} else {
|
|
||||||
console.error('Delete failed for prompt:', prompt.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle multiple prompt deletion
|
|
||||||
const handleDeleteMultiple = async (promptsToDelete: Prompt[]) => {
|
|
||||||
const promptCount = promptsToDelete.length;
|
|
||||||
if (window.confirm(t('prompts.delete.confirmMultiple', 'Are you sure you want to delete {count} prompts?').replace('{count}', promptCount.toString()))) {
|
|
||||||
// Start all delete operations simultaneously
|
|
||||||
const deletePromises = promptsToDelete.map(async (prompt) => {
|
|
||||||
try {
|
|
||||||
const success = await handlePromptDelete(prompt.id);
|
|
||||||
return { promptId: prompt.id, success };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete prompt:', prompt.id, error);
|
|
||||||
return { promptId: prompt.id, success: false };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for all deletions to complete
|
|
||||||
const results = await Promise.all(deletePromises);
|
|
||||||
|
|
||||||
// Check if any deletions failed
|
|
||||||
const failedDeletions = results.filter(result => !result.success);
|
|
||||||
if (failedDeletions.length > 0) {
|
|
||||||
console.error('Some prompt deletions failed:', failedDeletions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the prompt list regardless of individual failures
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edit prompt
|
|
||||||
const handleEditPrompt = (prompt: Prompt) => {
|
|
||||||
setEditingPrompt(prompt);
|
|
||||||
setEditModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle save prompt
|
|
||||||
const handleSavePrompt = async (updatedPrompt: Prompt) => {
|
|
||||||
if (!editingPrompt) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Call API to update prompt - backend requires full Prompt object
|
|
||||||
const updateData = {
|
|
||||||
id: editingPrompt.id,
|
|
||||||
name: updatedPrompt.name,
|
|
||||||
content: updatedPrompt.content,
|
|
||||||
mandateId: editingPrompt.mandateId
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await handlePromptUpdate(editingPrompt.id, updateData);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Close modal
|
|
||||||
setEditModalOpen(false);
|
|
||||||
setEditingPrompt(null);
|
|
||||||
|
|
||||||
// Refresh prompt list
|
|
||||||
await refetch();
|
|
||||||
|
|
||||||
// Notify other components that prompts have been updated
|
|
||||||
window.dispatchEvent(new CustomEvent('promptUpdated', {
|
|
||||||
detail: { promptId: editingPrompt.id, newName: updatedPrompt.name }
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
console.error('Failed to update prompt:', result.error);
|
|
||||||
// TODO: Show error message to user
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update prompt:', error);
|
|
||||||
// TODO: Show error message to user
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle cancel edit
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
setEditModalOpen(false);
|
|
||||||
setEditingPrompt(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle copy prompt
|
|
||||||
const handleCopyPrompt = async (prompt: Prompt) => {
|
|
||||||
try {
|
|
||||||
// Use the modern Clipboard API if available
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
await navigator.clipboard.writeText(prompt.content);
|
|
||||||
} else {
|
|
||||||
// Fallback for older browsers or non-secure contexts
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = prompt.content;
|
|
||||||
textArea.style.position = 'fixed';
|
|
||||||
textArea.style.left = '-999999px';
|
|
||||||
textArea.style.top = '-999999px';
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.focus();
|
|
||||||
textArea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success feedback
|
|
||||||
const promptName = prompt.name || t('prompts.unnamed', 'Unnamed');
|
|
||||||
console.log(`Copied content of "${promptName}" to clipboard`);
|
|
||||||
|
|
||||||
// Optional: You could add a toast notification here in the future
|
|
||||||
// showToast(t('prompts.copy.success', 'Prompt content copied to clipboard'), 'success');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy prompt content:', error);
|
|
||||||
|
|
||||||
// Optional: You could add a toast notification here in the future
|
|
||||||
// showToast(t('prompts.copy.error', 'Failed to copy prompt content'), 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configure action buttons
|
|
||||||
const actions: PromptActionConfig[] = useMemo(() => [
|
|
||||||
{
|
|
||||||
type: 'edit',
|
|
||||||
title: t('prompts.action.edit', 'Edit'),
|
|
||||||
onAction: (row: Prompt) => {
|
|
||||||
handleEditPrompt(row);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'copy',
|
|
||||||
title: t('prompts.action.copy', 'Copy'),
|
|
||||||
onAction: (row: Prompt) => {
|
|
||||||
handleCopyPrompt(row);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'delete',
|
|
||||||
title: (row: Prompt) => {
|
|
||||||
const isDeletable = isPromptDeletable(row);
|
|
||||||
return isDeletable
|
|
||||||
? t('prompts.action.delete', 'Delete')
|
|
||||||
: t('prompts.action.delete.disabled', 'No permission to delete prompt');
|
|
||||||
},
|
|
||||||
disabled: (row: Prompt) => !isPromptDeletable(row)
|
|
||||||
// onAction is handled by FormGenerator for delete confirmation
|
|
||||||
},
|
|
||||||
] as any, [t, deletingPrompts, handleDeletePrompt, handleEditPrompt, handleCopyPrompt]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Data
|
|
||||||
prompts,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
handleDeleteSingle,
|
|
||||||
handleDeleteMultiple,
|
|
||||||
handleEditPrompt,
|
|
||||||
handleCopyPrompt,
|
|
||||||
|
|
||||||
// Refetch function
|
|
||||||
refetch,
|
|
||||||
|
|
||||||
// Additional data for rendering
|
|
||||||
columns,
|
|
||||||
actions,
|
|
||||||
|
|
||||||
// Edit modal state
|
|
||||||
editModalOpen,
|
|
||||||
editingPrompt,
|
|
||||||
editPromptFields,
|
|
||||||
|
|
||||||
// Edit modal actions
|
|
||||||
handleSavePrompt,
|
|
||||||
handleCancelEdit
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
// Prompt interface based on the API response
|
|
||||||
export interface Prompt {
|
|
||||||
id: string;
|
|
||||||
mandateId: string;
|
|
||||||
content: string;
|
|
||||||
name: string;
|
|
||||||
_createdBy?: string; // Optional field to track who created the prompt
|
|
||||||
_hideDelete?: boolean; // Backend access control flag
|
|
||||||
}
|
|
||||||
|
|
||||||
// Props for the PromptsTable component
|
|
||||||
export interface PromptsTableProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action configuration for prompt actions
|
|
||||||
export interface PromptActionConfig {
|
|
||||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy';
|
|
||||||
title?: string | ((row: Prompt) => string);
|
|
||||||
onAction?: (row: Prompt) => Promise<void> | void;
|
|
||||||
disabled?: (row: Prompt) => boolean | { disabled: boolean; message?: string };
|
|
||||||
loading?: (row: Prompt) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Column configuration for the prompts table
|
|
||||||
export interface PromptColumnConfig {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum';
|
|
||||||
width: number;
|
|
||||||
minWidth: number;
|
|
||||||
maxWidth: number;
|
|
||||||
sortable: boolean;
|
|
||||||
filterable: boolean;
|
|
||||||
searchable?: boolean;
|
|
||||||
filterOptions?: string[];
|
|
||||||
formatter: (value: any, row?: any) => React.ReactElement | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return type for the prompts logic hook
|
|
||||||
export interface PromptsLogicReturn {
|
|
||||||
// Data
|
|
||||||
prompts: Prompt[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
handleDeleteSingle: (prompt: Prompt) => Promise<void>;
|
|
||||||
handleDeleteMultiple: (prompts: Prompt[]) => Promise<void>;
|
|
||||||
handleEditPrompt: (prompt: Prompt) => void;
|
|
||||||
handleCopyPrompt: (prompt: Prompt) => void;
|
|
||||||
|
|
||||||
// Refetch function
|
|
||||||
refetch: () => Promise<void>;
|
|
||||||
|
|
||||||
// Additional data for rendering
|
|
||||||
columns: PromptColumnConfig[];
|
|
||||||
actions: PromptActionConfig[];
|
|
||||||
|
|
||||||
// Edit modal state
|
|
||||||
editModalOpen: boolean;
|
|
||||||
editingPrompt: Prompt | null;
|
|
||||||
editPromptFields: any[];
|
|
||||||
|
|
||||||
// Edit modal actions
|
|
||||||
handleSavePrompt: (updatedPrompt: Prompt) => Promise<void>;
|
|
||||||
handleCancelEdit: () => void;
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import styles from './SidebarStyles/SidebarItem.module.css';
|
||||||
import SidebarSubmenu from "./SidebarSubmenu";
|
import SidebarSubmenu from "./SidebarSubmenu";
|
||||||
import { SidebarItemProps } from "./sidebarTypes";
|
import { SidebarItemProps } from "./sidebarTypes";
|
||||||
|
|
||||||
const SidebarItem: React.FC<SidebarItemProps> = ({
|
const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
item,
|
item,
|
||||||
isOpen,
|
isOpen,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
|
@ -84,6 +84,8 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||||
{hasSubItems && !isMinimized && !isDisabled && <SidebarSubmenu item={item} isOpen={isOpen} />}
|
{hasSubItems && !isMinimized && !isDisabled && <SidebarSubmenu item={item} isOpen={isOpen} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
SidebarItem.displayName = 'SidebarItem';
|
||||||
|
|
||||||
export default SidebarItem;
|
export default SidebarItem;
|
||||||
|
|
@ -2,16 +2,19 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { useMsal } from '@azure/msal-react'
|
import { useMsal } from '@azure/msal-react'
|
||||||
import { FaSignOutAlt } from 'react-icons/fa'
|
import { FaSignOutAlt } from 'react-icons/fa'
|
||||||
import styles from './SidebarStyles/SidebarUser.module.css'
|
import styles from './SidebarStyles/SidebarUser.module.css'
|
||||||
import { useCurrentUser, User } from '../../hooks/useUsers'
|
import { User } from '../../hooks/useUsers'
|
||||||
import { SidebarUserProps } from './sidebarTypes';
|
import { SidebarUserProps } from './sidebarTypes';
|
||||||
|
import { getUserDataCache, CachedUserData } from '../../utils/userCache';
|
||||||
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
|
|
||||||
const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
|
const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
|
||||||
const { instance } = useMsal();
|
const { instance } = useMsal();
|
||||||
const { user: currentUser, isLoading: currentUserLoading, logout } = useCurrentUser();
|
const { logout } = useCurrentUser(); // Only use logout function from hook
|
||||||
|
|
||||||
// Local state for user data created from currentUser
|
// Local state for user data created from cached data
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [userError, setUserError] = useState<string | null>(null);
|
const [userError, setUserError] = useState<string | null>(null);
|
||||||
|
const [cachedUserData, setCachedUserData] = useState<CachedUserData | null>(null);
|
||||||
|
|
||||||
const [showLogoutMenu, setShowLogoutMenu] = useState(false);
|
const [showLogoutMenu, setShowLogoutMenu] = useState(false);
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
@ -50,40 +53,58 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use currentUser data directly instead of making API calls
|
// Load cached user data on mount and when user updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🔍 SidebarUser useEffect: currentUser:', currentUser);
|
const loadCachedUserData = () => {
|
||||||
|
const cached = getUserDataCache();
|
||||||
|
setCachedUserData(cached);
|
||||||
|
|
||||||
if (currentUser?.id) {
|
if (cached?.id) {
|
||||||
console.log('✅ SidebarUser: Using currentUser data directly (avoiding CORS issues)');
|
// Create a User object from cached data with fallback values
|
||||||
// Create a User object from currentUser data with fallback values
|
const userData: User = {
|
||||||
const userData: User = {
|
id: cached.id,
|
||||||
id: currentUser.id,
|
username: cached.username,
|
||||||
username: currentUser.username,
|
email: cached.email || cached.username, // Use email or username as fallback
|
||||||
email: currentUser.username, // Use username as email fallback
|
fullName: cached.fullName || cached.username.split('@')[0] || cached.username,
|
||||||
fullName: currentUser.username.split('@')[0] || currentUser.username, // Extract name from email
|
language: cached.language || 'de', // Default language
|
||||||
language: 'de', // Default language
|
enabled: cached.enabled ?? true, // Assume enabled if logged in
|
||||||
enabled: true, // Assume enabled if logged in
|
privilege: cached.privilege || 'user',
|
||||||
privilege: currentUser.privilege || 'user',
|
authenticationAuthority: cached.authenticationAuthority || 'local',
|
||||||
authenticationAuthority: currentUser.authenticationAuthority || 'local',
|
mandateId: cached.mandateId || ''
|
||||||
mandateId: currentUser.mandateId || ''
|
};
|
||||||
};
|
setUser(userData);
|
||||||
setUser(userData);
|
setUserError(null);
|
||||||
setUserError(null);
|
} else {
|
||||||
console.log('✅ SidebarUser: User data set from currentUser:', userData);
|
setUser(null);
|
||||||
} else {
|
}
|
||||||
console.log('⚠️ SidebarUser: No currentUser available');
|
};
|
||||||
setUser(null);
|
|
||||||
}
|
loadCachedUserData();
|
||||||
}, [currentUser]);
|
}, []); // Empty dependency array - only run on mount
|
||||||
|
|
||||||
// Listen for user updates from settings page
|
// Listen for user updates from settings page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUserUpdate = () => {
|
const handleUserUpdate = () => {
|
||||||
// Trigger re-evaluation of currentUser data
|
// Refresh cached user data when user info is updated
|
||||||
if (currentUser?.id) {
|
const cached = getUserDataCache();
|
||||||
// User data will be updated automatically through currentUser dependency
|
setCachedUserData(cached);
|
||||||
console.log('🔄 SidebarUser: User info updated event received');
|
|
||||||
|
if (cached?.id) {
|
||||||
|
const userData: User = {
|
||||||
|
id: cached.id,
|
||||||
|
username: cached.username,
|
||||||
|
email: cached.email || cached.username,
|
||||||
|
fullName: cached.fullName || cached.username.split('@')[0] || cached.username,
|
||||||
|
language: cached.language || 'de',
|
||||||
|
enabled: cached.enabled ?? true,
|
||||||
|
privilege: cached.privilege || 'user',
|
||||||
|
authenticationAuthority: cached.authenticationAuthority || 'local',
|
||||||
|
mandateId: cached.mandateId || ''
|
||||||
|
};
|
||||||
|
setUser(userData);
|
||||||
|
setUserError(null);
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -91,7 +112,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('userInfoUpdated', handleUserUpdate);
|
window.removeEventListener('userInfoUpdated', handleUserUpdate);
|
||||||
};
|
};
|
||||||
}, [currentUser?.id]);
|
}, []); // Empty dependency array - only set up listener once
|
||||||
|
|
||||||
// Close popup when clicking outside
|
// Close popup when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -110,7 +131,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
|
||||||
};
|
};
|
||||||
}, [showLogoutMenu]);
|
}, [showLogoutMenu]);
|
||||||
|
|
||||||
if (currentUserLoading) {
|
if (!cachedUserData) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}>
|
<div className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}>
|
||||||
<div className={styles.userContainer}>Lädt...</div>
|
<div className={styles.userContainer}>Lädt...</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { IoIosCheckmarkCircle, IoIosMail, IoIosCall, IoIosTime, IoIosRefresh } f
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import sharedStyles from '../../core/PageManager/pages.module.css';
|
import sharedStyles from '../../core/PageManager/pages.module.css';
|
||||||
import styles from './SpeechConfirmation.module.css';
|
import styles from './SpeechConfirmation.module.css';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface MandateData {
|
interface MandateData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { IoIosLink, IoIosCall, IoIosAnalytics, IoIosFingerPrint, IoIosBook, IoIosChatbubbles, IoIosDesktop } from 'react-icons/io';
|
import { IoIosLink, IoIosCall, IoIosAnalytics, IoIosFingerPrint, IoIosBook, IoIosChatbubbles, IoIosDesktop } from 'react-icons/io';
|
||||||
import styles from './SpeechInfo.module.css';
|
import styles from './SpeechInfo.module.css';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
function SpeechInfo() {
|
function SpeechInfo() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { IoIosBusiness, IoIosContact, IoIosTime, IoIosRefresh } from 'react-icons/io';
|
import { IoIosBusiness, IoIosContact, IoIosTime, IoIosRefresh } from 'react-icons/io';
|
||||||
import sharedStyles from '../../core/PageManager/pages.module.css';
|
import sharedStyles from '../../core/PageManager/pages.module.css';
|
||||||
import styles from './SpeechSettings.module.css';
|
import styles from './SpeechSettings.module.css';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface MandateData {
|
interface MandateData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||||
import { IoIosArrowBack, IoIosBusiness, IoIosPeople } from 'react-icons/io';
|
import { IoIosArrowBack, IoIosBusiness, IoIosPeople } from 'react-icons/io';
|
||||||
import sharedStyles from '../../core/PageManager/pages.module.css';
|
import sharedStyles from '../../core/PageManager/pages.module.css';
|
||||||
import styles from './SpeechSignUp.module.css';
|
import styles from './SpeechSignUp.module.css';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface SpeechSignUpProps {
|
interface SpeechSignUpProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
.testSharepointTable {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorState {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
background: var(--color-bg);
|
|
||||||
border: 1px solid var(--color-error, #dc3545);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--color-error, #dc3545);
|
|
||||||
text-align: center;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retryButton {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--color-error, #dc3545);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
font-size: 14px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retryButton:hover {
|
|
||||||
background: var(--color-error-hover, #c82333);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sharepointFormGenerator {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { FormGenerator } from '../FormGenerator';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
import styles from './TestSharepointTable.module.css';
|
|
||||||
import { useTestSharepointLogic } from './testSharepointLogic';
|
|
||||||
import type { TestSharepointTableProps } from './testSharepointInterfaces';
|
|
||||||
|
|
||||||
export function TestSharepointTable({
|
|
||||||
className = '',
|
|
||||||
documents,
|
|
||||||
documentsLoading,
|
|
||||||
documentsError,
|
|
||||||
columns,
|
|
||||||
actions,
|
|
||||||
onRowClick
|
|
||||||
}: TestSharepointTableProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
// Get fallback data from hook if props not provided (for backwards compatibility)
|
|
||||||
const hookData = useTestSharepointLogic();
|
|
||||||
|
|
||||||
// Use props data if provided, otherwise fall back to hook data
|
|
||||||
const actualDocuments = documents ?? hookData.documents;
|
|
||||||
const actualLoading = documentsLoading ?? hookData.documentsLoading;
|
|
||||||
const actualError = documentsError ?? hookData.documentsError;
|
|
||||||
const actualColumns = columns ?? hookData.columns;
|
|
||||||
const actualActions = actions ?? hookData.actions;
|
|
||||||
|
|
||||||
// Debug the data being passed to FormGenerator
|
|
||||||
console.log('TestSharepointTable - actualDocuments:', actualDocuments);
|
|
||||||
console.log('TestSharepointTable - actualLoading:', actualLoading);
|
|
||||||
console.log('TestSharepointTable - actualError:', actualError);
|
|
||||||
console.log('TestSharepointTable - actualColumns:', actualColumns);
|
|
||||||
|
|
||||||
// Show error state
|
|
||||||
if (actualError) {
|
|
||||||
return (
|
|
||||||
<div className={`${styles.testSharepointTable} ${className}`}>
|
|
||||||
<div className={styles.errorState}>
|
|
||||||
<p>{t('sharepoint.error.loading', 'Error loading SharePoint documents:')} {actualError}</p>
|
|
||||||
<button onClick={() => window.location.reload()} className={styles.retryButton}>
|
|
||||||
{t('sharepoint.button.retry', 'Retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.testSharepointTable} ${className}`}>
|
|
||||||
<FormGenerator
|
|
||||||
data={actualDocuments}
|
|
||||||
columns={actualColumns}
|
|
||||||
title={t('sharepoint.table.title', 'SharePoint Documents')}
|
|
||||||
loading={actualLoading}
|
|
||||||
searchable={true}
|
|
||||||
filterable={true}
|
|
||||||
sortable={true}
|
|
||||||
resizable={true}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={10}
|
|
||||||
pageSizeOptions={[10, 25, 50, 100]}
|
|
||||||
showPageSizeSelector={true}
|
|
||||||
selectable={false}
|
|
||||||
onRowClick={onRowClick}
|
|
||||||
actionButtons={actualActions}
|
|
||||||
className={styles.sharepointFormGenerator}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TestSharepointTable;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export { TestSharepointTable } from './TestSharepointTable';
|
|
||||||
export { useTestSharepointLogic } from './testSharepointLogic';
|
|
||||||
export type {
|
|
||||||
TestSharepointTableProps,
|
|
||||||
TableAction,
|
|
||||||
SharePointHandlers,
|
|
||||||
SharePointOperationsReturn,
|
|
||||||
SharePointConnectionsReturn,
|
|
||||||
SharePointTableConfig,
|
|
||||||
TestSharepointLogicReturn
|
|
||||||
} from './testSharepointInterfaces';
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { ColumnConfig } from '../FormGenerator';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
// Re-export SharePoint-related interfaces from hooks
|
|
||||||
export type {
|
|
||||||
SharePointConnection,
|
|
||||||
SharePointDocument,
|
|
||||||
SharePointResponse,
|
|
||||||
SharePointListRequest,
|
|
||||||
SharePointFindRequest,
|
|
||||||
SharePointReadRequest,
|
|
||||||
SharePointUploadRequest
|
|
||||||
} from '../../hooks/useSharePointTest';
|
|
||||||
|
|
||||||
// Import for local use
|
|
||||||
import type { SharePointConnection, SharePointDocument } from '../../hooks/useSharePointTest';
|
|
||||||
|
|
||||||
// Component Props Interfaces
|
|
||||||
export interface TestSharepointTableProps {
|
|
||||||
className?: string;
|
|
||||||
documents?: SharePointDocument[];
|
|
||||||
documentsLoading?: boolean;
|
|
||||||
documentsError?: string | null;
|
|
||||||
columns?: any[];
|
|
||||||
actions?: any[];
|
|
||||||
onRowClick?: (row: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table Action Interface
|
|
||||||
export interface TableAction {
|
|
||||||
label: string;
|
|
||||||
onClick?: (document: SharePointDocument) => Promise<void> | void;
|
|
||||||
icon: React.ReactNode | ((document: SharePointDocument) => React.ReactNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SharePoint Operation Handler Types
|
|
||||||
export interface SharePointHandlers {
|
|
||||||
handleConnectionTest: (connectionId: string) => Promise<boolean>;
|
|
||||||
handleListDocuments: (connectionId: string, siteUrl: string, folderPaths: string[]) => Promise<SharePointDocument[]>;
|
|
||||||
handleFindDocuments: (connectionId: string, siteUrl: string, query: string) => Promise<SharePointDocument[]>;
|
|
||||||
handleReadDocument: (connectionId: string, siteUrl: string, documentPath: string) => Promise<{ success: boolean; data?: any; error?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook Return Types for SharePoint Operations
|
|
||||||
export interface SharePointOperationsReturn extends SharePointHandlers {
|
|
||||||
testingConnections: Set<string>;
|
|
||||||
loadingDocuments: boolean;
|
|
||||||
connectionError: string | null;
|
|
||||||
documentsError: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook Return Types for SharePoint Connections
|
|
||||||
export interface SharePointConnectionsReturn {
|
|
||||||
connections: SharePointConnection[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refetch: () => Promise<void>;
|
|
||||||
testConnection: (connectionId: string) => Promise<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SharePoint Table Configuration
|
|
||||||
export interface SharePointTableConfig {
|
|
||||||
columns: ColumnConfig[];
|
|
||||||
actions: TableAction[];
|
|
||||||
pageSize: number;
|
|
||||||
searchable: boolean;
|
|
||||||
filterable: boolean;
|
|
||||||
sortable: boolean;
|
|
||||||
resizable: boolean;
|
|
||||||
pagination: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook Return Type for TestSharepoint Logic
|
|
||||||
export interface TestSharepointLogicReturn {
|
|
||||||
// Connection data
|
|
||||||
connections: SharePointConnection[];
|
|
||||||
selectedConnection: SharePointConnection | null;
|
|
||||||
connectionLoading: boolean;
|
|
||||||
connectionError: string | null;
|
|
||||||
|
|
||||||
// Document data
|
|
||||||
documents: SharePointDocument[];
|
|
||||||
documentsLoading: boolean;
|
|
||||||
documentsError: string | null;
|
|
||||||
|
|
||||||
// Table configuration
|
|
||||||
columns: ColumnConfig[];
|
|
||||||
actions: TableAction[];
|
|
||||||
|
|
||||||
// Connection testing
|
|
||||||
testingConnections: Set<string>;
|
|
||||||
connectionTestResults: Record<string, any>;
|
|
||||||
|
|
||||||
// Site discovery
|
|
||||||
discoveredSites: any[];
|
|
||||||
sitesDiscovered: boolean;
|
|
||||||
|
|
||||||
// Token debug
|
|
||||||
tokenDebugInfo: any;
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
handleSelectConnection: (connectionId: string) => void;
|
|
||||||
handleTestConnection: (connectionId: string) => Promise<void>;
|
|
||||||
handleListDocuments: (siteUrl?: string, folderPaths?: string[]) => Promise<void>;
|
|
||||||
handleDiscoverSites: () => Promise<void>;
|
|
||||||
handleSelectSite: (siteUrl: string) => void;
|
|
||||||
handleDebugTokens: () => Promise<void>;
|
|
||||||
handleCleanupTokens: () => Promise<void>;
|
|
||||||
handleFolderNavigation: (document: SharePointDocument, currentPath: string) => string;
|
|
||||||
refetchConnections: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,395 +0,0 @@
|
||||||
import { useMemo, useState, useEffect } from 'react';
|
|
||||||
import { IoIosLink, IoIosCloudDownload } from 'react-icons/io';
|
|
||||||
|
|
||||||
import { ColumnConfig } from '../FormGenerator';
|
|
||||||
import { useSharePointTest } from '../../hooks/useSharePointTest';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
import type {
|
|
||||||
TableAction,
|
|
||||||
SharePointConnection,
|
|
||||||
SharePointDocument,
|
|
||||||
TestSharepointLogicReturn
|
|
||||||
} from './testSharepointInterfaces';
|
|
||||||
|
|
||||||
export function useTestSharepointLogic(): TestSharepointLogicReturn {
|
|
||||||
const {
|
|
||||||
getConnections,
|
|
||||||
testConnection,
|
|
||||||
listDocuments,
|
|
||||||
discoverSites,
|
|
||||||
debugTokenDetails,
|
|
||||||
cleanupTokens,
|
|
||||||
isLoading
|
|
||||||
} = useSharePointTest();
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
// State management
|
|
||||||
const [connections, setConnections] = useState<SharePointConnection[]>([]);
|
|
||||||
const [selectedConnection, setSelectedConnection] = useState<SharePointConnection | null>(null);
|
|
||||||
const [connectionLoading, setConnectionLoading] = useState(false);
|
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [documents, setDocuments] = useState<SharePointDocument[]>([]);
|
|
||||||
const [documentsLoading, setDocumentsLoading] = useState(false);
|
|
||||||
const [documentsError, setDocumentsError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [testingConnections, setTestingConnections] = useState<Set<string>>(new Set());
|
|
||||||
const [connectionTestResults, setConnectionTestResults] = useState<Record<string, any>>({});
|
|
||||||
|
|
||||||
const [discoveredSites, setDiscoveredSites] = useState<any[]>([]);
|
|
||||||
const [sitesDiscovered, setSitesDiscovered] = useState(false);
|
|
||||||
const [tokenDebugInfo, setTokenDebugInfo] = useState<any>(null);
|
|
||||||
|
|
||||||
// Load connections on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadConnections();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadConnections = async () => {
|
|
||||||
setConnectionLoading(true);
|
|
||||||
setConnectionError(null);
|
|
||||||
try {
|
|
||||||
const conns = await getConnections();
|
|
||||||
setConnections(conns);
|
|
||||||
if (conns.length > 0 && !selectedConnection) {
|
|
||||||
setSelectedConnection(conns[0]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load connections:', error);
|
|
||||||
setConnectionError(error instanceof Error ? error.message : 'Failed to load connections');
|
|
||||||
} finally {
|
|
||||||
setConnectionLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configure columns for the SharePoint documents table
|
|
||||||
const columns: ColumnConfig[] = useMemo(() => [
|
|
||||||
{
|
|
||||||
key: 'documentName',
|
|
||||||
label: t('sharepoint.column.documentName', 'Document Name'),
|
|
||||||
type: 'string',
|
|
||||||
width: 300,
|
|
||||||
minWidth: 200,
|
|
||||||
maxWidth: 400,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
formatter: (value: string, row: any) => (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
cursor: row?.type === 'folder' ? 'pointer' : 'default'
|
|
||||||
}}
|
|
||||||
title={value}
|
|
||||||
>
|
|
||||||
{row?.type === 'folder' ? '📁' : '📄'} {value}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'mimeType',
|
|
||||||
label: t('sharepoint.column.mimeType', 'MIME Type'),
|
|
||||||
type: 'string',
|
|
||||||
width: 200,
|
|
||||||
minWidth: 150,
|
|
||||||
maxWidth: 300,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'size',
|
|
||||||
label: t('sharepoint.column.size', 'Size'),
|
|
||||||
type: 'number',
|
|
||||||
width: 140,
|
|
||||||
minWidth: 120,
|
|
||||||
maxWidth: 180,
|
|
||||||
sortable: true,
|
|
||||||
filterable: false,
|
|
||||||
formatter: (value: number | string | undefined) => {
|
|
||||||
if (!value || value === 0) return '-';
|
|
||||||
|
|
||||||
const sizeInBytes = typeof value === 'string' ? parseInt(value, 10) : value;
|
|
||||||
const units = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
let size = sizeInBytes;
|
|
||||||
let unitIndex = 0;
|
|
||||||
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span style={{ fontWeight: 500, color: 'var(--color-text)' }}>
|
|
||||||
{`${size.toFixed(1)} ${units[unitIndex]}`}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'path',
|
|
||||||
label: t('sharepoint.column.path', 'Path'),
|
|
||||||
type: 'string',
|
|
||||||
width: 250,
|
|
||||||
minWidth: 200,
|
|
||||||
maxWidth: 400,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
},
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
// Handle connection selection
|
|
||||||
const handleSelectConnection = (connectionId: string) => {
|
|
||||||
const connection = connections.find(conn => conn.id === connectionId);
|
|
||||||
if (connection) {
|
|
||||||
setSelectedConnection(connection);
|
|
||||||
// Clear documents when changing connection
|
|
||||||
setDocuments([]);
|
|
||||||
setDocumentsError(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle connection testing
|
|
||||||
const handleTestConnection = async (connectionId: string) => {
|
|
||||||
setTestingConnections(prev => new Set(prev).add(connectionId));
|
|
||||||
try {
|
|
||||||
const result = await testConnection(connectionId);
|
|
||||||
setConnectionTestResults(prev => ({ ...prev, [connectionId]: result }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Connection test failed:', error);
|
|
||||||
setConnectionTestResults(prev => ({
|
|
||||||
...prev,
|
|
||||||
[connectionId]: {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Connection test failed'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setTestingConnections(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(connectionId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle listing documents
|
|
||||||
const handleListDocuments = async (siteUrl?: string, folderPaths?: string[]) => {
|
|
||||||
if (!selectedConnection) {
|
|
||||||
setDocumentsError('No connection selected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDocumentsLoading(true);
|
|
||||||
setDocumentsError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const connectionReference = `connection:${selectedConnection.authority}:${selectedConnection.externalUsername}:${selectedConnection.id}`;
|
|
||||||
|
|
||||||
const response = await listDocuments({
|
|
||||||
connectionReference,
|
|
||||||
siteUrl: siteUrl || 'https://your-tenant.sharepoint.com/sites/your-site',
|
|
||||||
folderPaths: folderPaths || ['/'],
|
|
||||||
includeSubfolders: false,
|
|
||||||
expectedDocumentFormats: [{ extension: '.json', mimeType: 'application/json' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('SharePoint response:', response);
|
|
||||||
|
|
||||||
if (response.success && response.data?.documents) {
|
|
||||||
// Extract the actual files from the nested structure
|
|
||||||
const documents = response.data.documents;
|
|
||||||
if (documents.length > 0 && documents[0].documentData?.listResults) {
|
|
||||||
// Flatten all files from all folder results
|
|
||||||
const allFiles: SharePointDocument[] = [];
|
|
||||||
documents[0].documentData.listResults.forEach((folderResult: any) => {
|
|
||||||
if (folderResult.items && Array.isArray(folderResult.items)) {
|
|
||||||
folderResult.items.forEach((item: any) => {
|
|
||||||
// Convert SharePoint item to our document format
|
|
||||||
allFiles.push({
|
|
||||||
documentName: item.name || 'Unknown',
|
|
||||||
mimeType: item.file?.mimeType || (item.type === 'folder' ? 'folder' : 'unknown'),
|
|
||||||
size: item.size || 0,
|
|
||||||
path: folderResult.folderPath || '/',
|
|
||||||
type: item.type || (item.folder ? 'folder' : 'file'),
|
|
||||||
id: item.id,
|
|
||||||
documentData: item // Store the full item data
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('Extracted files:', allFiles);
|
|
||||||
console.log('Sample file structure:', allFiles[0]);
|
|
||||||
setDocuments(allFiles);
|
|
||||||
} else {
|
|
||||||
console.log('No listResults found in response');
|
|
||||||
setDocuments([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('Response error or no documents:', response);
|
|
||||||
setDocumentsError(response.error || 'Failed to list documents');
|
|
||||||
setDocuments([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to list documents:', error);
|
|
||||||
setDocumentsError(error instanceof Error ? error.message : 'Failed to list documents');
|
|
||||||
setDocuments([]);
|
|
||||||
} finally {
|
|
||||||
setDocumentsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle site discovery
|
|
||||||
const handleDiscoverSites = async () => {
|
|
||||||
if (!selectedConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await discoverSites();
|
|
||||||
if (result.success && result.data && result.data.sites) {
|
|
||||||
setDiscoveredSites(result.data.sites);
|
|
||||||
setSitesDiscovered(true);
|
|
||||||
setDocumentsError(null); // Clear any previous errors
|
|
||||||
} else {
|
|
||||||
console.error('Site discovery failed:', result);
|
|
||||||
setDiscoveredSites([]);
|
|
||||||
setSitesDiscovered(true); // Set to true so we show the "no sites" message
|
|
||||||
// Set error message to help user understand what went wrong
|
|
||||||
const errorMsg = result.error || result.message || 'Unknown error occurred';
|
|
||||||
setDocumentsError(`Site discovery failed: ${errorMsg}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Site discovery failed:', error);
|
|
||||||
setDiscoveredSites([]);
|
|
||||||
setSitesDiscovered(true);
|
|
||||||
setDocumentsError(`Site discovery error: ${error instanceof Error ? error.message : 'Network or authentication error'}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle site selection
|
|
||||||
const handleSelectSite = (siteUrl: string) => {
|
|
||||||
// This will be used by the parent component
|
|
||||||
console.log('Site selected:', siteUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle token debug
|
|
||||||
const handleDebugTokens = async () => {
|
|
||||||
try {
|
|
||||||
const result = await debugTokenDetails();
|
|
||||||
setTokenDebugInfo(result);
|
|
||||||
console.log('Token debug info:', result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Token debug failed:', error);
|
|
||||||
setTokenDebugInfo({ error: error instanceof Error ? error.message : 'Failed to get token info' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle token cleanup
|
|
||||||
const handleCleanupTokens = async () => {
|
|
||||||
try {
|
|
||||||
const result = await cleanupTokens();
|
|
||||||
console.log('Token cleanup result:', result);
|
|
||||||
// Clear the debug info to force refresh
|
|
||||||
setTokenDebugInfo(null);
|
|
||||||
// Show success message
|
|
||||||
setDocumentsError(null);
|
|
||||||
alert(`Success! Deleted ${result.data?.tokensDeleted || 0} tokens. Please reconnect your Microsoft account now.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Token cleanup failed:', error);
|
|
||||||
alert(`Token cleanup failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle folder navigation
|
|
||||||
const handleFolderNavigation = (document: SharePointDocument, currentPath: string) => {
|
|
||||||
if (document.type === 'folder') {
|
|
||||||
// Build the new path by combining current path with folder name
|
|
||||||
const newPath = currentPath === '/' ? `/${document.documentName}` : `${currentPath}/${document.documentName}`;
|
|
||||||
console.log('Navigating to folder:', newPath);
|
|
||||||
return newPath;
|
|
||||||
}
|
|
||||||
return currentPath;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle document actions
|
|
||||||
const handleViewDocument = async (document: SharePointDocument) => {
|
|
||||||
console.log('View document:', document);
|
|
||||||
// TODO: Implement document viewing
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadDocument = async (document: SharePointDocument) => {
|
|
||||||
console.log('Download document:', document);
|
|
||||||
// TODO: Implement document download
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configure action buttons
|
|
||||||
const actions: TableAction[] = useMemo(() => [
|
|
||||||
{
|
|
||||||
label: t('sharepoint.action.view', 'View'),
|
|
||||||
icon: <IoIosLink />,
|
|
||||||
onClick: (document: SharePointDocument) => {
|
|
||||||
handleViewDocument(document);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('sharepoint.action.download', 'Download'),
|
|
||||||
icon: <IoIosCloudDownload />,
|
|
||||||
onClick: (document: SharePointDocument) => {
|
|
||||||
handleDownloadDocument(document);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
// Refetch connections
|
|
||||||
const refetchConnections = async () => {
|
|
||||||
await loadConnections();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Connection data
|
|
||||||
connections,
|
|
||||||
selectedConnection,
|
|
||||||
connectionLoading,
|
|
||||||
connectionError,
|
|
||||||
|
|
||||||
// Document data
|
|
||||||
documents,
|
|
||||||
documentsLoading: documentsLoading || isLoading,
|
|
||||||
documentsError,
|
|
||||||
|
|
||||||
// Table configuration
|
|
||||||
columns,
|
|
||||||
actions,
|
|
||||||
|
|
||||||
// Connection testing
|
|
||||||
testingConnections,
|
|
||||||
connectionTestResults,
|
|
||||||
|
|
||||||
// Site discovery
|
|
||||||
discoveredSites,
|
|
||||||
sitesDiscovered,
|
|
||||||
|
|
||||||
// Token debug
|
|
||||||
tokenDebugInfo,
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
handleSelectConnection,
|
|
||||||
handleTestConnection,
|
|
||||||
handleListDocuments,
|
|
||||||
handleDiscoverSites,
|
|
||||||
handleSelectSite,
|
|
||||||
handleDebugTokens,
|
|
||||||
handleCleanupTokens,
|
|
||||||
handleFolderNavigation,
|
|
||||||
refetchConnections
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||||
import { CreateButtonProps } from '../ButtonTypes';
|
import { CreateButtonProps } from '../ButtonTypes';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { Popup, EditForm } from '../../Popup';
|
import { Popup, EditForm } from '../../Popup';
|
||||||
import { useLanguage } from '../../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
const CreateButton: React.FC<CreateButtonProps> = ({
|
const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
onCreate,
|
onCreate,
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--background-secondary, #f5f5f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--background-primary, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem:hover {
|
||||||
|
border-color: var(--border-hover, #ccc);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileSize {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileSource {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--background-tertiary, #f0f0f0);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.fileList::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileList::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileList::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color, #ccc);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileList::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--border-hover, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ViewActionButton, DeleteActionButton, RemoveActionButton } from '../../FormGenerator/ActionButtons';
|
||||||
|
import { WorkflowFile } from '../../../hooks/usePlayground';
|
||||||
|
import styles from './ConnectedFilesList.module.css';
|
||||||
|
|
||||||
|
export interface ConnectedFilesListProps {
|
||||||
|
files: WorkflowFile[];
|
||||||
|
pendingFiles?: WorkflowFile[];
|
||||||
|
onDelete: (file: WorkflowFile) => Promise<void>;
|
||||||
|
onRemove?: (file: WorkflowFile) => Promise<void>;
|
||||||
|
onAttach?: (fileId: string) => Promise<void>; // New: attach file for next message
|
||||||
|
deletingFiles?: Set<string>;
|
||||||
|
previewingFiles?: Set<string>;
|
||||||
|
removingFiles?: Set<string>;
|
||||||
|
workflowId?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectedFilesList({
|
||||||
|
files,
|
||||||
|
pendingFiles = [],
|
||||||
|
onDelete,
|
||||||
|
onRemove,
|
||||||
|
onAttach,
|
||||||
|
deletingFiles = new Set(),
|
||||||
|
previewingFiles = new Set(),
|
||||||
|
removingFiles = new Set(),
|
||||||
|
workflowId,
|
||||||
|
emptyMessage = 'No files connected to this workflow'
|
||||||
|
}: ConnectedFilesListProps) {
|
||||||
|
// Combine workflow files and pending files, deduplicating by fileId
|
||||||
|
const allFiles = useMemo(() => {
|
||||||
|
const fileMap = new Map<string, WorkflowFile>();
|
||||||
|
|
||||||
|
// Add workflow files first (filter out files without fileId)
|
||||||
|
files.forEach(file => {
|
||||||
|
if (file.fileId && file.fileId.trim() !== '') {
|
||||||
|
fileMap.set(file.fileId, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add pending files (may override workflow files if same fileId)
|
||||||
|
pendingFiles.forEach(file => {
|
||||||
|
if (file.fileId && file.fileId.trim() !== '') {
|
||||||
|
fileMap.set(file.fileId, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(fileMap.values());
|
||||||
|
}, [files, pendingFiles]);
|
||||||
|
|
||||||
|
// Create hookData object for action buttons
|
||||||
|
const hookData = useMemo(() => ({
|
||||||
|
handleDelete: async (fileId: string) => {
|
||||||
|
const file = allFiles.find(f => f.fileId === fileId);
|
||||||
|
if (file) {
|
||||||
|
await onDelete(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
removeOptimistically: (fileId: string) => {
|
||||||
|
// This will be handled by the parent component's state
|
||||||
|
},
|
||||||
|
refetch: async () => {
|
||||||
|
// Refetch handled by parent
|
||||||
|
},
|
||||||
|
deletingItems: deletingFiles,
|
||||||
|
previewingFiles: previewingFiles
|
||||||
|
}), [allFiles, onDelete, deletingFiles, previewingFiles]);
|
||||||
|
|
||||||
|
const handleView = async (file: WorkflowFile) => {
|
||||||
|
// View is handled by ViewActionButton's FilePreview component
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (file: WorkflowFile) => {
|
||||||
|
// Remove file from workflow (not delete from backend)
|
||||||
|
if (onRemove) {
|
||||||
|
await onRemove(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (allFiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h3 className={styles.title}>Connected Files</h3>
|
||||||
|
<span className={styles.count}>({allFiles.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fileList}>
|
||||||
|
{allFiles
|
||||||
|
.filter(file => file.fileId && file.fileId.trim() !== '') // Ensure fileId exists
|
||||||
|
.map((file, index) => {
|
||||||
|
const isDeleting = deletingFiles.has(file.fileId!);
|
||||||
|
const isPreviewing = previewingFiles.has(file.fileId!);
|
||||||
|
const isRemoving = removingFiles.has(file.fileId!);
|
||||||
|
// Use fileId as key since we've filtered out files without it
|
||||||
|
const uniqueKey = file.fileId!;
|
||||||
|
|
||||||
|
// Check if file is in pending files (can be removed) or in messages (already sent)
|
||||||
|
const isPendingFile = pendingFiles.some(f => f.fileId === file.fileId);
|
||||||
|
|
||||||
|
// Handle clicking on file item to attach/detach for next message
|
||||||
|
const handleFileItemClick = async (e: React.MouseEvent) => {
|
||||||
|
// Don't trigger if clicking on action buttons - they handle their own clicks
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const fileActionsElement = target.closest(`.${styles.fileActions}`);
|
||||||
|
const buttonElement = target.closest('button');
|
||||||
|
|
||||||
|
if (fileActionsElement || buttonElement) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default and stop propagation to ensure click handler fires
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (onAttach && file.fileId) {
|
||||||
|
console.log('🖱️ ConnectedFilesList: Clicking file to attach/detach:', file.fileId);
|
||||||
|
await onAttach(file.fileId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={uniqueKey}
|
||||||
|
className={styles.fileItem}
|
||||||
|
onClick={handleFileItemClick}
|
||||||
|
style={{
|
||||||
|
cursor: onAttach ? 'pointer' : 'default',
|
||||||
|
userSelect: 'none' // Prevent text selection on click
|
||||||
|
}}
|
||||||
|
title={onAttach ? (isPendingFile ? 'Click to detach from next message' : 'Click to attach for next message') : undefined}
|
||||||
|
>
|
||||||
|
<div className={styles.fileInfo}>
|
||||||
|
<div className={styles.fileName} title={file.fileName}>
|
||||||
|
{file.fileName}
|
||||||
|
{onAttach && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: '#666' }}>
|
||||||
|
{isPendingFile ? '(click to detach)' : '(click to attach)'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.fileMeta}>
|
||||||
|
<span className={styles.fileSize}>
|
||||||
|
{formatFileSize(file.fileSize)}
|
||||||
|
</span>
|
||||||
|
{file.source && (
|
||||||
|
<span className={styles.fileSource}>
|
||||||
|
{file.source === 'user_uploaded' ? 'Uploaded' : 'AI Created'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPendingFile && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#4CAF50', fontWeight: 500 }}>
|
||||||
|
• Attached
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fileActions} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ViewActionButton
|
||||||
|
row={file}
|
||||||
|
onView={handleView}
|
||||||
|
disabled={isDeleting || isRemoving}
|
||||||
|
loading={isPreviewing}
|
||||||
|
hookData={hookData}
|
||||||
|
idField="fileId"
|
||||||
|
nameField="fileName"
|
||||||
|
typeField="mimeType"
|
||||||
|
/>
|
||||||
|
{isPendingFile && onRemove && (
|
||||||
|
<RemoveActionButton
|
||||||
|
row={file}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
disabled={isDeleting}
|
||||||
|
loading={isRemoving}
|
||||||
|
hookData={hookData}
|
||||||
|
idField="fileId"
|
||||||
|
loadingStateName="removingItems"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DeleteActionButton
|
||||||
|
row={file}
|
||||||
|
hookData={hookData}
|
||||||
|
idField="fileId"
|
||||||
|
operationName="handleDelete"
|
||||||
|
loadingStateName="deletingItems"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectedFilesList;
|
||||||
|
|
||||||
6
src/components/UiComponents/ConnectedFilesList/index.ts
Normal file
6
src/components/UiComponents/ConnectedFilesList/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export { default as ConnectedFilesList } from './ConnectedFilesList';
|
||||||
|
export type { ConnectedFilesListProps } from './ConnectedFilesList';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './DragDropOverlay.module.css';
|
import styles from './DragDropOverlay.module.css';
|
||||||
import { IoFolderOpen } from 'react-icons/io5';
|
import { IoFolderOpen } from 'react-icons/io5';
|
||||||
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
.dropdownContainer {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
min-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton:hover:not(.disabled):not(:disabled) {
|
||||||
|
border-color: var(--color-secondary);
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 0, 123, 255), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton.disabled,
|
||||||
|
.dropdownButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: var(--color-bg-disabled, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton.loading {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants */
|
||||||
|
.buttonPrimary {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary:hover:not(.disabled):not(:disabled) {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSecondary {
|
||||||
|
border-color: var(--color-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSecondary:hover:not(.disabled):not(:disabled) {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonDanger {
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonDanger:hover:not(.disabled):not(:disabled) {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSuccess {
|
||||||
|
border-color: #10b981;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSuccess:hover:not(.disabled):not(:disabled) {
|
||||||
|
background-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonWarning {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonWarning:hover:not(.disabled):not(:disabled) {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button sizes */
|
||||||
|
.buttonSm {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonMd {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonLg {
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonText {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronOpen {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearIcon {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearIcon:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSpinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownMenu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownHeader {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-bottom: 1px solid var(--color-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItems {
|
||||||
|
max-height: inherit;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownEmpty {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItem {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-bottom: 1px solid var(--color-primary);
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItem:hover {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItemSelected {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItemSelected:hover {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemLabel {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedIndicator {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme support */
|
||||||
|
[data-theme="dark"] .dropdownButton {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .dropdownMenu {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .dropdownItem {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.dropdownButton {
|
||||||
|
font-size: 16px; /* Prevents zoom on iOS */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
240
src/components/UiComponents/DropdownSelect/DropdownSelect.tsx
Normal file
240
src/components/UiComponents/DropdownSelect/DropdownSelect.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { IconType } from 'react-icons';
|
||||||
|
import { IoChevronDown, IoClose } from 'react-icons/io5';
|
||||||
|
import styles from './DropdownSelect.module.css';
|
||||||
|
import { ButtonVariant, ButtonSize } from '../Button/ButtonTypes';
|
||||||
|
|
||||||
|
export interface DropdownSelectItem<T = any> {
|
||||||
|
id: string | number;
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
metadata?: Record<string, any>; // Additional data for custom rendering
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropdownSelectProps<T = any> {
|
||||||
|
items: DropdownSelectItem<T>[];
|
||||||
|
selectedItemId?: string | number | null;
|
||||||
|
onSelect: (item: DropdownSelectItem<T> | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
headerText?: string;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
icon?: IconType;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
renderItem?: (item: DropdownSelectItem<T>, isSelected: boolean) => React.ReactNode;
|
||||||
|
renderButton?: (selectedItem: DropdownSelectItem<T> | null, isOpen: boolean) => React.ReactNode;
|
||||||
|
renderClearButton?: (selectedItem: DropdownSelectItem<T>, onClear: () => void) => React.ReactNode;
|
||||||
|
minWidth?: string;
|
||||||
|
maxHeight?: string;
|
||||||
|
showClearButton?: boolean; // Enable/disable clear button feature
|
||||||
|
clearButtonLabel?: string; // Label for clear button (defaults to showing selected item name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownSelect<T = any>({
|
||||||
|
items = [],
|
||||||
|
selectedItemId,
|
||||||
|
onSelect,
|
||||||
|
placeholder = 'Select an item',
|
||||||
|
emptyMessage = 'No items available',
|
||||||
|
headerText,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
icon: Icon,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
className = '',
|
||||||
|
renderItem,
|
||||||
|
renderButton,
|
||||||
|
renderClearButton,
|
||||||
|
minWidth = '180px',
|
||||||
|
maxHeight = '300px',
|
||||||
|
showClearButton = true,
|
||||||
|
clearButtonLabel
|
||||||
|
}: DropdownSelectProps<T>) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Find selected item
|
||||||
|
const selectedItem = selectedItemId !== null && selectedItemId !== undefined
|
||||||
|
? items.find(item => item.id === selectedItemId) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Handle item selection
|
||||||
|
const handleItemClick = (item: DropdownSelectItem<T>) => {
|
||||||
|
onSelect(item);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clear selection
|
||||||
|
const handleClear = () => {
|
||||||
|
if (!disabled && !loading) {
|
||||||
|
onSelect(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle dropdown
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
if (!disabled && !loading) {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build button classes
|
||||||
|
const buttonClasses = [
|
||||||
|
styles.dropdownButton,
|
||||||
|
styles[`button${variant.charAt(0).toUpperCase() + variant.slice(1)}`],
|
||||||
|
styles[`button${size.charAt(0).toUpperCase() + size.slice(1)}`],
|
||||||
|
loading ? styles.loading : '',
|
||||||
|
disabled ? styles.disabled : '',
|
||||||
|
className
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
// Render default button content
|
||||||
|
const renderDefaultButton = () => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.buttonSpinner} />
|
||||||
|
<span>{placeholder}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedItem) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Icon && <Icon className={styles.buttonIcon} />}
|
||||||
|
<span className={styles.buttonText}>{selectedItem.label}</span>
|
||||||
|
<IoChevronDown className={`${styles.chevronIcon} ${isOpen ? styles.chevronOpen : ''}`} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Icon && <Icon className={styles.buttonIcon} />}
|
||||||
|
<span className={styles.buttonText}>{placeholder}</span>
|
||||||
|
<IoChevronDown className={`${styles.chevronIcon} ${isOpen ? styles.chevronOpen : ''}`} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render clear button when item is selected and showClearButton is true
|
||||||
|
const renderClearButtonContent = () => {
|
||||||
|
if (renderClearButton && selectedItem) {
|
||||||
|
return renderClearButton(selectedItem, handleClear);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedItem && showClearButton) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={buttonClasses}
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
title={clearButtonLabel || `Clear selection: ${selectedItem.label}`}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className={styles.buttonIcon} />}
|
||||||
|
<span className={styles.buttonText}>
|
||||||
|
{clearButtonLabel || selectedItem.label}
|
||||||
|
</span>
|
||||||
|
<IoClose className={styles.clearIcon} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className={styles.dropdownContainer} style={{ minWidth }}>
|
||||||
|
{/* Show clear button if item is selected and showClearButton is enabled */}
|
||||||
|
{selectedItem && showClearButton ? (
|
||||||
|
renderClearButtonContent()
|
||||||
|
) : renderButton ? (
|
||||||
|
<div className={buttonClasses} onClick={toggleDropdown}>
|
||||||
|
{renderButton(selectedItem, isOpen)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={buttonClasses}
|
||||||
|
onClick={toggleDropdown}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
{renderDefaultButton()}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.dropdownMenu} style={{ maxHeight }}>
|
||||||
|
{headerText && (
|
||||||
|
<div className={styles.dropdownHeader}>
|
||||||
|
{headerText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className={styles.dropdownEmpty}>
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.dropdownItems}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isSelected = selectedItemId === item.id;
|
||||||
|
|
||||||
|
if (renderItem) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`${styles.dropdownItem} ${isSelected ? styles.dropdownItemSelected : ''}`}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
{renderItem(item, isSelected)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.dropdownItem} ${isSelected ? styles.dropdownItemSelected : ''}`}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
<span className={styles.itemLabel}>{item.label}</span>
|
||||||
|
{isSelected && <span className={styles.selectedIndicator}>✓</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DropdownSelect;
|
||||||
|
|
||||||
3
src/components/UiComponents/DropdownSelect/index.ts
Normal file
3
src/components/UiComponents/DropdownSelect/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as DropdownSelect } from './DropdownSelect';
|
||||||
|
export type { DropdownSelectItem, DropdownSelectProps } from './DropdownSelect';
|
||||||
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { DropdownSelect, DropdownSelectItem } from '../../DropdownSelect';
|
||||||
|
import { ButtonSize } from '../../Button/ButtonTypes';
|
||||||
|
|
||||||
|
export interface SelectFieldOption {
|
||||||
|
id: string | number;
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldProps {
|
||||||
|
value?: string | number | null;
|
||||||
|
onChange?: (value: any) => void;
|
||||||
|
label?: string;
|
||||||
|
options: SelectFieldOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
description?: string;
|
||||||
|
className?: string;
|
||||||
|
size?: ButtonSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectField: React.FC<SelectFieldProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
placeholder = 'Select an option',
|
||||||
|
required = false,
|
||||||
|
disabled = false,
|
||||||
|
description,
|
||||||
|
className = '',
|
||||||
|
size = 'md'
|
||||||
|
}) => {
|
||||||
|
// Convert options to DropdownSelectItem format
|
||||||
|
const items: DropdownSelectItem[] = options.map(opt => ({
|
||||||
|
id: opt.id,
|
||||||
|
label: opt.label,
|
||||||
|
value: opt.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Find selected item ID from value
|
||||||
|
const selectedItemId = value !== null && value !== undefined
|
||||||
|
? options.find(opt => opt.value === value || opt.id === value)?.id ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSelect = (item: DropdownSelectItem | null) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(item ? item.value : null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontFamily: 'var(--font-family)'
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
{required && <span style={{ color: 'var(--color-error)', marginLeft: '4px' }}>*</span>}
|
||||||
|
</label>
|
||||||
|
{description && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
marginTop: '4px',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DropdownSelect
|
||||||
|
items={items}
|
||||||
|
selectedItemId={selectedItemId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
size={size}
|
||||||
|
variant="primary"
|
||||||
|
showClearButton={false}
|
||||||
|
minWidth="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectField;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default } from './SelectField';
|
||||||
|
export type { SelectFieldProps, SelectFieldOption } from './SelectField';
|
||||||
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import React from 'react';
|
||||||
|
import TextField from '../../TextField';
|
||||||
|
import { TextFieldSize } from '../../TextField/TextFieldTypes';
|
||||||
|
|
||||||
|
export interface TextInputFieldProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
type?: 'text' | 'email' | 'tel';
|
||||||
|
description?: string;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
size?: TextFieldSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextInputField: React.FC<TextInputFieldProps> = ({
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
required = false,
|
||||||
|
disabled = false,
|
||||||
|
readonly = false,
|
||||||
|
type = 'text',
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
className = '',
|
||||||
|
size = 'md'
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontFamily: 'var(--font-family)'
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
{required && <span style={{ color: 'var(--color-error)', marginLeft: '4px' }}>*</span>}
|
||||||
|
</label>
|
||||||
|
{description && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
marginTop: '4px',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
readonly={readonly}
|
||||||
|
type={type}
|
||||||
|
error={error}
|
||||||
|
label={undefined} // Don't use TextField's label since we render it above
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextInputField;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default } from './TextInputField';
|
||||||
|
export type { TextInputFieldProps } from './TextInputField';
|
||||||
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface ToggleFieldProps {
|
||||||
|
value?: boolean;
|
||||||
|
onChange?: (value: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleField: React.FC<ToggleFieldProps> = ({
|
||||||
|
value = false,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
disabled = false,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (!disabled && onChange) {
|
||||||
|
onChange(!value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontFamily: 'var(--font-family)'
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{description && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
marginTop: '4px',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '25px',
|
||||||
|
border: `1px solid ${value ? 'var(--color-secondary)' : 'var(--color-primary)'}`,
|
||||||
|
background: value ? 'var(--color-secondary)' : 'var(--color-bg)',
|
||||||
|
color: value ? 'white' : 'var(--color-text)',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
fontFamily: 'var(--font-family)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!disabled) {
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(63, 81, 181, 0.2)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '44px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: value ? 'rgba(255, 255, 255, 0.3)' : 'var(--color-gray-light)',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'white',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '2px',
|
||||||
|
left: value ? '22px' : '2px',
|
||||||
|
transition: 'left 0.3s ease',
|
||||||
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>{value ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToggleField;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default } from './ToggleField';
|
||||||
|
export type { ToggleFieldProps } from './ToggleField';
|
||||||
|
|
||||||
7
src/components/UiComponents/EditFields/index.ts
Normal file
7
src/components/UiComponents/EditFields/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export { default as TextInputField } from './TextInputField';
|
||||||
|
export { default as SelectField } from './SelectField';
|
||||||
|
export { default as ToggleField } from './ToggleField';
|
||||||
|
export type { TextInputFieldProps } from './TextInputField';
|
||||||
|
export type { SelectFieldProps, SelectFieldOption } from './SelectField';
|
||||||
|
export type { ToggleFieldProps } from './ToggleField';
|
||||||
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
.locationInputContainer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrapper {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationButton {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 1.5rem; /* Align with input field */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inputRow {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationButton {
|
||||||
|
margin-top: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
73
src/components/UiComponents/LocationInput/LocationInput.tsx
Normal file
73
src/components/UiComponents/LocationInput/LocationInput.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button, TextField } from '../index';
|
||||||
|
import { FaLocationArrow } from 'react-icons/fa';
|
||||||
|
import styles from './LocationInput.module.css';
|
||||||
|
|
||||||
|
export interface LocationInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onUseCurrentLocation: () => void;
|
||||||
|
isGettingLocation?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationInput: React.FC<LocationInputProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onUseCurrentLocation,
|
||||||
|
isGettingLocation = false,
|
||||||
|
placeholder = 'Kanton, Gemeinde, Adresse oder Parzelle',
|
||||||
|
label = 'Standort',
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
const [isRequestingLocation, setIsRequestingLocation] = useState(false);
|
||||||
|
|
||||||
|
const handleUseCurrentLocation = async () => {
|
||||||
|
setIsRequestingLocation(true);
|
||||||
|
try {
|
||||||
|
await onUseCurrentLocation();
|
||||||
|
} finally {
|
||||||
|
setIsRequestingLocation(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.locationInputContainer}>
|
||||||
|
<div className={styles.inputRow}>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<TextField
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
label={label}
|
||||||
|
error={error}
|
||||||
|
helperText={helperText}
|
||||||
|
disabled={disabled}
|
||||||
|
size="md"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
icon={FaLocationArrow}
|
||||||
|
onClick={handleUseCurrentLocation}
|
||||||
|
disabled={disabled || isGettingLocation || isRequestingLocation}
|
||||||
|
loading={isGettingLocation || isRequestingLocation}
|
||||||
|
className={styles.locationButton}
|
||||||
|
>
|
||||||
|
Meine Position verwenden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationInput;
|
||||||
|
|
||||||
3
src/components/UiComponents/LocationInput/index.ts
Normal file
3
src/components/UiComponents/LocationInput/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as LocationInput } from './LocationInput';
|
||||||
|
export type { LocationInputProps } from './LocationInput';
|
||||||
|
|
||||||
25
src/components/UiComponents/MapView/LV95Converter.ts
Normal file
25
src/components/UiComponents/MapView/LV95Converter.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import proj4 from 'proj4';
|
||||||
|
|
||||||
|
// Define LV95 projection (EPSG:2056) and WGS84 (EPSG:4326)
|
||||||
|
// LV95 / Swiss TM 35
|
||||||
|
const LV95_PROJ = '+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs';
|
||||||
|
const WGS84_PROJ = 'EPSG:4326';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert LV95 coordinates to WGS84 (lat/lon)
|
||||||
|
* Uses proj4 for accurate coordinate transformation
|
||||||
|
*/
|
||||||
|
export function lv95ToWGS84(x: number, y: number): { lat: number; lon: number } {
|
||||||
|
const [lon, lat] = proj4(LV95_PROJ, WGS84_PROJ, [x, y]);
|
||||||
|
return { lat, lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert WGS84 (lat/lon) to LV95 coordinates
|
||||||
|
* Uses proj4 for accurate coordinate transformation
|
||||||
|
*/
|
||||||
|
export function wgs84ToLV95(lat: number, lon: number): { x: number; y: number } {
|
||||||
|
const [x, y] = proj4(WGS84_PROJ, LV95_PROJ, [lon, lat]);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue