feat: Add full CRUD functionality, project detail panel, and improved UI
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- Add UPDATE and DELETE endpoints to backend - Implement project detail panel with comprehensive editing - Add drag-and-drop functionality for projects in mind map - Show all projects in map (not just selected + children) - Fix infinite render loop in MindMap component - Improve UI spacing and button layouts - Add local development database schema with RLS disabled - Update docker-compose for regular docker-compose (not Swarm) - Add CORS support and nginx API proxying - Improve button spacing and modern design principles
This commit is contained in:
@@ -17,7 +17,7 @@ import { Project } from './projects/project.entity';
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_DATABASE || 'evrak',
|
||||
entities: [Tenant, Project],
|
||||
synchronize: true, // Disable in production!
|
||||
synchronize: false, // Schema is managed by SQL migration files
|
||||
}),
|
||||
TenantsModule,
|
||||
ProjectsModule,
|
||||
|
||||
118
backend/src/database/schema-local.sql
Normal file
118
backend/src/database/schema-local.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- Local Development Schema (RLS Disabled for easier testing)
|
||||
-- This is a simplified version for local development
|
||||
-- Production should use schema.sql with RLS enabled
|
||||
|
||||
-- Enable required extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "ltree";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Tenants Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Users Table
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, email)
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Projects Table (Hierarchical)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Hierarchy using ltree
|
||||
path ltree NOT NULL,
|
||||
parent_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
||||
|
||||
-- Flexible metadata
|
||||
attributes JSONB DEFAULT '{}'::jsonb,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indexes (Create only if they don't exist)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- GIST index for fast ltree operations (ancestor/descendant checks)
|
||||
CREATE INDEX IF NOT EXISTS projects_path_gist_idx ON projects USING GIST (path);
|
||||
|
||||
-- B-Tree index for path to speed up exact matches and sorting
|
||||
CREATE INDEX IF NOT EXISTS projects_path_btree_idx ON projects USING btree (path);
|
||||
|
||||
-- GIN index for JSONB searching
|
||||
CREATE INDEX IF NOT EXISTS projects_attributes_gin_idx ON projects USING GIN (attributes);
|
||||
|
||||
-- Tenant ID indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS users_tenant_id_idx ON users (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS projects_tenant_id_idx ON projects (tenant_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security (DISABLED for local development)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- RLS is disabled for local development to simplify testing
|
||||
-- Production deployments should use schema.sql which enables RLS
|
||||
ALTER TABLE tenants DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE users DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE projects DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Helper Function for updating paths (Move Subtree)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION move_project_subtree(
|
||||
p_project_id UUID,
|
||||
p_new_parent_id UUID
|
||||
) RETURNS VOID AS $$
|
||||
DECLARE
|
||||
v_old_path ltree;
|
||||
v_new_path ltree;
|
||||
v_new_parent_path ltree;
|
||||
BEGIN
|
||||
-- Get the old path
|
||||
SELECT path INTO v_old_path FROM projects WHERE id = p_project_id;
|
||||
|
||||
-- Calculate new path
|
||||
IF p_new_parent_id IS NULL THEN
|
||||
v_new_path := text2ltree(replace(p_project_id::text, '-', '_')); -- Root node
|
||||
ELSE
|
||||
SELECT path INTO v_new_parent_path FROM projects WHERE id = p_new_parent_id;
|
||||
v_new_path := v_new_parent_path || text2ltree(replace(p_project_id::text, '-', '_'));
|
||||
END IF;
|
||||
|
||||
-- Update the project and all descendants
|
||||
UPDATE projects
|
||||
SET path = v_new_path || subpath(path, nlevel(v_old_path))
|
||||
WHERE path <@ v_old_path;
|
||||
|
||||
-- Update parent_id for the moved node specifically
|
||||
UPDATE projects SET parent_id = p_new_parent_id WHERE id = p_project_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Create a default tenant for local development
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO tenants (id, name)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Default Local Tenant')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
@@ -3,6 +3,13 @@ import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for frontend
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:80',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Body, Param, Put } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Param, Put, Delete, Patch } from '@nestjs/common';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { Project } from './project.entity';
|
||||
|
||||
@@ -21,6 +21,16 @@ export class ProjectsController {
|
||||
return this.projectsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() data: Partial<Project>): Promise<Project> {
|
||||
return this.projectsService.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string): Promise<void> {
|
||||
return this.projectsService.remove(id);
|
||||
}
|
||||
|
||||
@Put(':id/move')
|
||||
move(@Param('id') id: string, @Body('newParentId') newParentId: string | null): Promise<void> {
|
||||
return this.projectsService.moveSubtree(id, newParentId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Project } from './project.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,16 +13,35 @@ export class ProjectsService {
|
||||
) { }
|
||||
|
||||
async create(data: Partial<Project>): Promise<Project> {
|
||||
// Logic to calculate path based on parent_id would go here
|
||||
// For now, assuming path is provided or calculated by caller/DB trigger if we had one
|
||||
// But typically we calculate it in service:
|
||||
// Generate UUID if not provided
|
||||
const projectId = data.id || randomUUID();
|
||||
|
||||
let path = data.id ? data.id.replace(/-/g, '_') : 'temp'; // ID is not generated yet...
|
||||
// Actually, we need to save first to get ID, or generate UUID manually.
|
||||
// Better to generate UUID in code if we need it for path.
|
||||
// Calculate ltree path based on parent_id
|
||||
let path: string;
|
||||
if (!data.parent_id) {
|
||||
// Root node: path is just the project ID (with dashes replaced by underscores for ltree)
|
||||
path = projectId.replace(/-/g, '_');
|
||||
} else {
|
||||
// Get parent's path and append this project's ID
|
||||
const parent = await this.projectsRepository.findOne({
|
||||
where: { id: data.parent_id },
|
||||
select: ['path'],
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
throw new Error(`Parent project with id ${data.parent_id} not found`);
|
||||
}
|
||||
|
||||
// Append this project's ID to parent path
|
||||
path = `${parent.path}.${projectId.replace(/-/g, '_')}`;
|
||||
}
|
||||
|
||||
const project = this.projectsRepository.create({
|
||||
...data,
|
||||
id: projectId,
|
||||
path: path as any, // TypeORM will handle ltree conversion
|
||||
});
|
||||
|
||||
// Simplified for this step: just save what we get, assuming path handling is done or we do it in a transaction.
|
||||
const project = this.projectsRepository.create(data);
|
||||
return this.projectsRepository.save(project);
|
||||
}
|
||||
|
||||
@@ -33,6 +53,45 @@ export class ProjectsService {
|
||||
return this.projectsRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Project>): Promise<Project> {
|
||||
const project = await this.projectsRepository.findOneBy({ id });
|
||||
if (!project) {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
|
||||
// If parent_id is being updated, recalculate path
|
||||
if (data.parent_id !== undefined && data.parent_id !== project.parent_id) {
|
||||
// Use the move function to handle path recalculation
|
||||
await this.moveSubtree(id, data.parent_id);
|
||||
// Remove parent_id from data since moveSubtree handles it
|
||||
const { parent_id, ...updateData } = data;
|
||||
Object.assign(project, updateData);
|
||||
} else {
|
||||
Object.assign(project, data);
|
||||
}
|
||||
|
||||
return this.projectsRepository.save(project);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const project = await this.projectsRepository.findOneBy({ id });
|
||||
if (!project) {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
|
||||
// Check if project has children
|
||||
const children = await this.projectsRepository
|
||||
.createQueryBuilder('project')
|
||||
.where('project.parent_id = :id', { id })
|
||||
.getCount();
|
||||
|
||||
if (children > 0) {
|
||||
throw new Error('Cannot delete project with children. Please delete or move children first.');
|
||||
}
|
||||
|
||||
await this.projectsRepository.remove(project);
|
||||
}
|
||||
|
||||
async moveSubtree(projectId: string, newParentId: string | null): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
'SELECT move_project_subtree($1, $2)',
|
||||
|
||||
@@ -1,52 +1,114 @@
|
||||
# Local Development with Docker Swarm
|
||||
# Local Development with Docker Compose
|
||||
|
||||
This directory contains the configuration to run the Evrak application locally using Docker Swarm on Windows 11.
|
||||
This directory contains the configuration to run the Evrak application locally using Docker Compose.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Docker Desktop**: Ensure Docker Desktop is installed and running.
|
||||
2. **Swarm Mode**: Enable Swarm mode if not already enabled:
|
||||
```powershell
|
||||
docker swarm init
|
||||
```
|
||||
1. **Docker Desktop**: Ensure Docker Desktop is installed and running on your machine.
|
||||
2. **Ports**: Make sure ports 80, 3000, and 5432 are available on your host machine.
|
||||
|
||||
## How to Deploy
|
||||
## Quick Start
|
||||
|
||||
### 1. Build Images
|
||||
First, build the Docker images locally. `docker stack deploy` does not build images, so this step is required.
|
||||
### 1. Build and Start Services
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/local/docker-compose.yml build
|
||||
From the project root directory, run:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/local/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
### 2. Deploy to Swarm
|
||||
Deploy the stack to your local Swarm cluster.
|
||||
|
||||
Or in PowerShell:
|
||||
```powershell
|
||||
docker stack deploy -c deploy/local/docker-compose.yml evrak
|
||||
docker compose -f deploy/local/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
### 3. Verify
|
||||
Check if the services are running:
|
||||
This will:
|
||||
- Build the Docker images for backend and frontend
|
||||
- Start PostgreSQL database
|
||||
- Start the backend API server
|
||||
- Start the frontend web server
|
||||
|
||||
```powershell
|
||||
docker service ls
|
||||
docker stack ps evrak
|
||||
```
|
||||
### 2. Access the Application
|
||||
|
||||
Once all services are running, access the application at:
|
||||
|
||||
Access the application:
|
||||
* **Frontend**: http://localhost
|
||||
* **Backend API**: http://localhost:3000
|
||||
* **Database**: localhost:5432
|
||||
* **Database**: localhost:5432 (user: `evrak_user`, password: `evrak_password`, database: `evrak`)
|
||||
|
||||
### 4. Remove Stack
|
||||
To stop and remove the application:
|
||||
### 3. Stop Services
|
||||
|
||||
```powershell
|
||||
docker stack rm evrak
|
||||
To stop all services:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/local/docker-compose.yml down
|
||||
```
|
||||
|
||||
To stop and remove volumes (database data):
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/local/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running in Detached Mode
|
||||
|
||||
To run services in the background:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/local/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
View logs from all services:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/local/docker-compose.yml logs -f
|
||||
```
|
||||
|
||||
View logs from a specific service:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/local/docker-compose.yml logs -f backend
|
||||
docker compose -f deploy/local/docker-compose.yml logs -f frontend
|
||||
docker compose -f deploy/local/docker-compose.yml logs -f postgres
|
||||
```
|
||||
|
||||
### Rebuild After Code Changes
|
||||
|
||||
If you make changes to the code, rebuild the affected service:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/local/docker-compose.yml up --build backend
|
||||
docker compose -f deploy/local/docker-compose.yml up --build frontend
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
* **Image not found**: Make sure you ran the build step.
|
||||
* **Ports occupied**: Ensure ports 80, 3000, and 5432 are free on your host machine.
|
||||
* **Build errors**: Make sure all dependencies are properly installed. Try removing `node_modules` and rebuilding.
|
||||
* **Database connection issues**: Wait a few seconds after starting services for the database to initialize.
|
||||
* **CORS errors**: The backend is configured to allow requests from `http://localhost:80`. If you're accessing from a different port, update the `FRONTEND_URL` environment variable in `docker-compose.yml`.
|
||||
|
||||
## Service Details
|
||||
|
||||
- **PostgreSQL**: Database service with automatic health checks and schema initialization
|
||||
- The database schema (including ltree extension, indexes, and helper functions) is automatically initialized on first startup
|
||||
- Row Level Security (RLS) is **disabled** for local development to simplify testing
|
||||
- A default tenant is created automatically: `00000000-0000-0000-0000-000000000001`
|
||||
- **Backend**: NestJS API server running on port 3000
|
||||
- Supports hierarchical project structure using PostgreSQL ltree
|
||||
- Multi-tenant architecture (RLS disabled for local dev)
|
||||
- **Frontend**: React application served via Nginx on port 80, with API proxying configured
|
||||
|
||||
## Database Schema
|
||||
|
||||
The database is automatically initialized with:
|
||||
- PostgreSQL extensions: `uuid-ossp`, `ltree`, `pg_trgm`
|
||||
- Tables: `tenants`, `users`, `projects`
|
||||
- Indexes: GIST for ltree paths, GIN for JSONB attributes
|
||||
- Helper function: `move_project_subtree()` for moving project hierarchies
|
||||
|
||||
**Note**: For production deployments, use `backend/src/database/schema.sql` which enables Row Level Security (RLS) for proper tenant isolation.
|
||||
|
||||
111
deploy/local/TEST.md
Normal file
111
deploy/local/TEST.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Testing Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Start Docker Desktop** - Make sure Docker Desktop is running on your Windows machine
|
||||
2. **Verify Docker is running**:
|
||||
```powershell
|
||||
docker ps
|
||||
```
|
||||
This should not show an error.
|
||||
|
||||
## Step 1: Start the Services
|
||||
|
||||
From the project root directory:
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/local/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
Or to run in detached mode (background):
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/local/docker-compose.yml up --build -d
|
||||
```
|
||||
|
||||
## Step 2: Verify Services are Running
|
||||
|
||||
Check that all containers are running:
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/local/docker-compose.yml ps
|
||||
```
|
||||
|
||||
You should see three services:
|
||||
- `evrak-postgres` (healthy)
|
||||
- `evrak-backend` (running)
|
||||
- `evrak-frontend` (running)
|
||||
|
||||
## Step 3: Check Logs
|
||||
|
||||
View logs to ensure everything started correctly:
|
||||
|
||||
```powershell
|
||||
# All services
|
||||
docker compose -f deploy/local/docker-compose.yml logs
|
||||
|
||||
# Specific service
|
||||
docker compose -f deploy/local/docker-compose.yml logs backend
|
||||
docker compose -f deploy/local/docker-compose.yml logs postgres
|
||||
```
|
||||
|
||||
## Step 4: Test the API
|
||||
|
||||
### Test Backend Health
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
```
|
||||
|
||||
### Create a Tenant
|
||||
```powershell
|
||||
curl -X POST http://localhost:3000/tenants -H "Content-Type: application/json" -d "{\"name\":\"Test Tenant\"}"
|
||||
```
|
||||
|
||||
### Create a Project
|
||||
```powershell
|
||||
curl -X POST http://localhost:3000/projects -H "Content-Type: application/json" -d "{\"name\":\"Test Project\",\"tenant_id\":\"00000000-0000-0000-0000-000000000001\",\"path\":\"test_project\"}"
|
||||
```
|
||||
|
||||
### Get All Projects
|
||||
```powershell
|
||||
curl http://localhost:3000/projects
|
||||
```
|
||||
|
||||
## Step 5: Access the Frontend
|
||||
|
||||
Open your browser and navigate to:
|
||||
- **Frontend**: http://localhost
|
||||
- **Backend API**: http://localhost:3000
|
||||
|
||||
## Step 6: Stop Services
|
||||
|
||||
When done testing:
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/local/docker-compose.yml down
|
||||
```
|
||||
|
||||
To also remove volumes (database data):
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/local/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker Desktop Not Running
|
||||
- Start Docker Desktop from Windows Start Menu
|
||||
- Wait for it to fully start (whale icon in system tray should be steady)
|
||||
|
||||
### Port Already in Use
|
||||
- Check if ports 80, 3000, or 5432 are already in use
|
||||
- Stop conflicting services or change ports in docker-compose.yml
|
||||
|
||||
### Database Connection Issues
|
||||
- Wait a few seconds after starting for database to initialize
|
||||
- Check postgres logs: `docker compose -f deploy/local/docker-compose.yml logs postgres`
|
||||
|
||||
### Build Errors
|
||||
- Make sure you're in the project root directory
|
||||
- Try: `docker compose -f deploy/local/docker-compose.yml build --no-cache`
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
container_name: evrak-postgres
|
||||
environment:
|
||||
POSTGRES_USER: evrak_user
|
||||
POSTGRES_PASSWORD: evrak_password
|
||||
@@ -11,19 +10,22 @@ services:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
- ../../backend/src/database/schema-local.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||
networks:
|
||||
- evrak-net
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U evrak_user -d evrak"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
# Note: 'docker stack deploy' ignores 'build'. You must run 'docker compose build' first.
|
||||
build:
|
||||
context: ../../backend
|
||||
dockerfile: Dockerfile
|
||||
image: evrak-backend:local
|
||||
container_name: evrak-backend
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
@@ -34,35 +36,29 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- evrak-net
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../../frontend
|
||||
dockerfile: Dockerfile
|
||||
image: evrak-frontend:local
|
||||
container_name: evrak-frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- evrak-net
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
|
||||
networks:
|
||||
evrak-net:
|
||||
driver: overlay
|
||||
attachable: true
|
||||
driver: bridge
|
||||
|
||||
@@ -2,6 +2,33 @@ server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy direct backend endpoints (projects, tenants, etc.)
|
||||
location ~ ^/(projects|tenants) {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Serve frontend static files
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
@@ -1,30 +1,218 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactFlow, { Background, Controls, type Node, type Edge } from 'reactflow';
|
||||
import React, { useMemo, useState, useCallback, useRef } from 'react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeMouseHandler,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
type Connection,
|
||||
addEdge,
|
||||
ReactFlowProvider,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
import ProjectModal from './ProjectModal';
|
||||
|
||||
const MindMap: React.FC = () => {
|
||||
const { projects } = useProjectStore();
|
||||
const MindMapContent: React.FC = () => {
|
||||
const { projects, selectedKey, setSelectedKey, updateProject } = useProjectStore();
|
||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
|
||||
// Filter nodes based on selection (e.g., show children of selected node)
|
||||
// For demo, just showing all projects as nodes
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
return projects.map((p, index) => ({
|
||||
id: p.id,
|
||||
position: { x: index * 200, y: index * 100 },
|
||||
data: { label: p.name },
|
||||
}));
|
||||
// Show ALL projects in the map
|
||||
const displayProjects = useMemo(() => {
|
||||
return projects;
|
||||
}, [projects]);
|
||||
|
||||
const edges: Edge[] = []; // Add edges logic based on parentId
|
||||
// Create nodes with proper positioning - use hierarchical layout
|
||||
const projectNodes: Node[] = useMemo(() => {
|
||||
if (displayProjects.length === 0) return [];
|
||||
|
||||
// Build a map of projects by parent
|
||||
const projectsByParent = new Map<string | null, typeof projects>();
|
||||
displayProjects.forEach(project => {
|
||||
const parentId = project.parentId;
|
||||
if (!projectsByParent.has(parentId)) {
|
||||
projectsByParent.set(parentId, []);
|
||||
}
|
||||
projectsByParent.get(parentId)!.push(project);
|
||||
});
|
||||
|
||||
// Calculate positions using hierarchical layout
|
||||
const nodeMap = new Map<string, Node>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
const calculatePosition = (_projectId: string, level: number, index: number): { x: number; y: number } => {
|
||||
const x = level * 300;
|
||||
const y = index * 150;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const buildNodes = (parentId: string | null, level: number = 0, startIndex: number = 0): number => {
|
||||
const children = projectsByParent.get(parentId) || [];
|
||||
let currentIndex = startIndex;
|
||||
|
||||
children.forEach((project) => {
|
||||
if (visited.has(project.id)) return;
|
||||
visited.add(project.id);
|
||||
|
||||
// Use saved position if available, otherwise calculate new position
|
||||
const savedPosition = nodePositionsRef.current.get(project.id);
|
||||
const position = savedPosition || calculatePosition(project.id, level, currentIndex);
|
||||
const isSelected = project.id === selectedKey;
|
||||
|
||||
nodeMap.set(project.id, {
|
||||
id: project.id,
|
||||
position,
|
||||
data: {
|
||||
label: (
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
textAlign: 'center',
|
||||
fontWeight: isSelected ? 'bold' : 'normal',
|
||||
}}>
|
||||
{project.name}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
border: isSelected ? '3px solid #1890ff' : '2px solid #d9d9d9',
|
||||
borderRadius: '8px',
|
||||
background: isSelected ? '#e6f7ff' : '#fff',
|
||||
width: 180,
|
||||
},
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
currentIndex++;
|
||||
// Recursively process children
|
||||
const childCount = buildNodes(project.id, level + 1, currentIndex);
|
||||
currentIndex += childCount;
|
||||
});
|
||||
|
||||
return children.length;
|
||||
};
|
||||
|
||||
buildNodes(null, 0, 0);
|
||||
|
||||
return Array.from(nodeMap.values());
|
||||
}, [displayProjects, selectedKey]);
|
||||
|
||||
// Update nodes when projects change (but preserve positions)
|
||||
React.useEffect(() => {
|
||||
if (projectNodes.length > 0) {
|
||||
setNodes(projectNodes);
|
||||
} else if (displayProjects.length === 0) {
|
||||
setNodes([]);
|
||||
}
|
||||
}, [projectNodes, displayProjects.length]);
|
||||
|
||||
// Create edges based on parent-child relationships
|
||||
const projectEdges: Edge[] = useMemo(() => {
|
||||
const edgeList: Edge[] = [];
|
||||
displayProjects.forEach(project => {
|
||||
if (project.parentId) {
|
||||
const parentExists = displayProjects.find(p => p.id === project.parentId);
|
||||
if (parentExists) {
|
||||
edgeList.push({
|
||||
id: `${project.parentId}-${project.id}`,
|
||||
source: project.parentId,
|
||||
target: project.id,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return edgeList;
|
||||
}, [displayProjects]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEdges(projectEdges);
|
||||
}, [projectEdges]);
|
||||
|
||||
// Handle node changes (drag)
|
||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||
}, []);
|
||||
|
||||
// Handle edge changes
|
||||
const onEdgesChange: OnEdgesChange = useCallback((changes) => {
|
||||
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||
}, []);
|
||||
|
||||
// Handle node position changes (when dragging ends)
|
||||
const onNodeDragStop = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
// Save the position for future renders
|
||||
nodePositionsRef.current.set(node.id, node.position);
|
||||
// Update the node position
|
||||
setNodes((nds) => nds.map((n) => (n.id === node.id ? { ...n, position: node.position } : n)));
|
||||
}, []);
|
||||
|
||||
// Handle connecting nodes (creating parent-child relationship)
|
||||
const onConnect = useCallback(async (params: Connection) => {
|
||||
if (params.source && params.target) {
|
||||
try {
|
||||
await updateProject(params.target, { parentId: params.source });
|
||||
setEdges((eds) => addEdge(params, eds));
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update project parent:', error);
|
||||
}
|
||||
}
|
||||
}, [updateProject]);
|
||||
|
||||
const onNodeDoubleClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||
setEditingProjectId(node.id);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||
setSelectedKey(node.id);
|
||||
}, [setSelectedKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<ReactFlow nodes={nodes} edges={edges} fitView>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onConnect={onConnect}
|
||||
fitView
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onNodeClick={onNodeClick}
|
||||
snapToGrid={true}
|
||||
snapGrid={[20, 20]}
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<ProjectModal
|
||||
visible={modalVisible}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
setEditingProjectId(null);
|
||||
}}
|
||||
projectId={editingProjectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MindMap: React.FC = () => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<MindMapContent />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
326
frontend/src/components/ProjectDetailPanel.tsx
Normal file
326
frontend/src/components/ProjectDetailPanel.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Form, Input, Button, message, Typography, Divider } from 'antd';
|
||||
import { SaveOutlined, DeleteOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ProjectDetailPanelProps {
|
||||
projectId: string | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onClose }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { projects, updateProject, deleteProject, fetchProjects } = useProjectStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
const project = projectId ? projects.find(p => p.id === projectId) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
form.setFieldsValue({
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
attributes: project.attributes ? JSON.stringify(project.attributes, null, 2) : '{}',
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [project, form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
|
||||
// Validate JSON if provided
|
||||
if (values.attributes) {
|
||||
try {
|
||||
JSON.parse(values.attributes);
|
||||
} catch (e) {
|
||||
message.error('Invalid JSON in attributes field');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateProject(project.id, {
|
||||
name: values.name,
|
||||
description: values.description || null,
|
||||
});
|
||||
|
||||
// Note: attributes update would need backend support
|
||||
message.success('Project updated successfully');
|
||||
await fetchProjects();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || 'Failed to update project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await deleteProject(project.id);
|
||||
message.success('Project deleted successfully');
|
||||
if (onClose) onClose();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || 'Failed to delete project');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#999'
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontSize: '16px', marginBottom: '8px' }}>No project selected</p>
|
||||
<p style={{ fontSize: '13px' }}>Select a project from the tree to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const children = projects.filter(p => p.parentId === project.id);
|
||||
const parent = project.parentId ? projects.find(p => p.id === project.parentId) : null;
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#fff' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
|
||||
{onClose && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
style={{
|
||||
padding: '0 4px',
|
||||
minWidth: 'auto'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text strong style={{ fontSize: '15px', fontWeight: 600 }}>Project Details</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
size="small"
|
||||
style={{
|
||||
minWidth: '70px',
|
||||
height: '28px',
|
||||
fontSize: '13px',
|
||||
padding: '0 12px'
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{project && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '8px 12px',
|
||||
background: '#fff',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '4px' }}>Current Project</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{project.name}</Text>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteLoading}
|
||||
onClick={handleDelete}
|
||||
size="small"
|
||||
block
|
||||
style={{
|
||||
height: '28px',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px' }}>
|
||||
<Card bordered={false} style={{ boxShadow: 'none', padding: 0 }}>
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: '100%' }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={<Text strong style={{ fontSize: '13px', marginBottom: '6px', display: 'block' }}>Project Name</Text>}
|
||||
rules={[{ required: true, message: 'Please enter project name' }]}
|
||||
style={{ marginBottom: '18px' }}
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter project name"
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={<Text strong style={{ fontSize: '13px', marginBottom: '6px', display: 'block' }}>Description</Text>}
|
||||
style={{ marginBottom: '20px' }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder="Enter project description (optional)"
|
||||
showCount
|
||||
maxLength={500}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ marginTop: '8px', marginBottom: '24px' }}>
|
||||
<Text strong style={{ fontSize: '15px' }}>Hierarchy</Text>
|
||||
</Divider>
|
||||
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: '10px', fontWeight: 500 }}>Parent</Text>
|
||||
{parent ? (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#e6f7ff',
|
||||
border: '1px solid #91d5ff',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#bae7ff';
|
||||
e.currentTarget.style.borderColor = '#69c0ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#e6f7ff';
|
||||
e.currentTarget.style.borderColor = '#91d5ff';
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ color: '#1890ff', fontSize: '14px' }}>{parent.name}</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontStyle: 'italic', fontSize: '13px' }}>Root project (no parent)</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: '10px', fontWeight: 500 }}>Children ({children.length})</Text>
|
||||
{children.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{children.map(child => (
|
||||
<div
|
||||
key={child.id}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: '#f5f5f5',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#e6f7ff';
|
||||
e.currentTarget.style.borderColor = '#91d5ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#f5f5f5';
|
||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: '14px' }}>{child.name}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontStyle: 'italic', fontSize: '13px' }}>No child projects</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ marginTop: '8px', marginBottom: '24px' }}>
|
||||
<Text strong style={{ fontSize: '15px' }}>Metadata</Text>
|
||||
</Divider>
|
||||
|
||||
<Form.Item
|
||||
name="attributes"
|
||||
label={<Text strong style={{ fontSize: '14px', marginBottom: '8px', display: 'block' }}>Attributes (JSON)</Text>}
|
||||
tooltip='Custom attributes in JSON format. Example: {"key": "value"}'
|
||||
style={{ marginBottom: '32px' }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
placeholder='{"key": "value", "anotherKey": "anotherValue"}'
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ marginTop: '8px', marginBottom: '24px' }} />
|
||||
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
background: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '8px', fontWeight: 500 }}>Project ID</Text>
|
||||
<Text code style={{ fontSize: '12px', wordBreak: 'break-all', padding: '4px 8px', background: '#fff', borderRadius: '4px' }}>{project.id}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '8px', fontWeight: 500 }}>Path</Text>
|
||||
<Text code style={{ fontSize: '12px', wordBreak: 'break-all', padding: '4px 8px', background: '#fff', borderRadius: '4px' }}>{project.path}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetailPanel;
|
||||
|
||||
82
frontend/src/components/ProjectModal.tsx
Normal file
82
frontend/src/components/ProjectModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Modal, Form, Input, message } from 'antd';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
|
||||
interface ProjectModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
const ProjectModal: React.FC<ProjectModalProps> = ({ visible, onCancel, projectId, parentId }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { projects, createProject, updateProject } = useProjectStore();
|
||||
const isEdit = !!projectId;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (isEdit) {
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
if (project) {
|
||||
form.setFieldsValue({
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
}, [visible, projectId, projects, form, isEdit]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (isEdit) {
|
||||
await updateProject(projectId!, values);
|
||||
message.success('Project updated successfully');
|
||||
} else {
|
||||
await createProject({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
parentId: parentId || null,
|
||||
});
|
||||
message.success('Project created successfully');
|
||||
}
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || (isEdit ? 'Failed to update project' : 'Failed to create project'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? 'Edit Project' : 'Create New Project'}
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
okText={isEdit ? 'Update' : 'Create'}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Project Name"
|
||||
rules={[{ required: true, message: 'Please enter project name' }]}
|
||||
>
|
||||
<Input placeholder="Enter project name" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="Description"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="Enter project description (optional)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectModal;
|
||||
|
||||
@@ -1,18 +1,86 @@
|
||||
import React from 'react';
|
||||
import { Tree } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { Tree, Button, Dropdown, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
import type { TreeDataNode } from 'antd';
|
||||
import ProjectModal from './ProjectModal';
|
||||
import ProjectDetailPanel from './ProjectDetailPanel';
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const { projects, expandedKeys, selectedKey, setExpandedKeys, setSelectedKey } = useProjectStore();
|
||||
const { projects, expandedKeys, selectedKey, setExpandedKeys, setSelectedKey, deleteProject } = useProjectStore();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
const [parentIdForNew, setParentIdForNew] = useState<string | null>(null);
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||
|
||||
// Transform projects to AntD Tree DataNode format
|
||||
// This is a simplified transformation. In real app, you'd handle hierarchy properly.
|
||||
const treeData: TreeDataNode[] = projects.map((p) => ({
|
||||
title: p.name,
|
||||
key: p.id,
|
||||
isLeaf: false, // Assuming all can have children for now
|
||||
// Build hierarchy tree from flat list
|
||||
const buildTree = (items: typeof projects, parentId: string | null = null): TreeDataNode[] => {
|
||||
return items
|
||||
.filter(item => item.parentId === parentId)
|
||||
.map(item => ({
|
||||
title: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<span>{item.name}</span>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'add',
|
||||
label: 'Add Child',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => {
|
||||
setParentIdForNew(item.id);
|
||||
setEditingProjectId(null);
|
||||
setModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setEditingProjectId(item.id);
|
||||
setParentIdForNew(null);
|
||||
setModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDelete(item.id),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<MoreOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
),
|
||||
key: item.id,
|
||||
children: buildTree(items, item.id),
|
||||
}));
|
||||
};
|
||||
|
||||
const treeData = buildTree(projects);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteProject(id);
|
||||
message.success('Project deleted successfully');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || 'Failed to delete project');
|
||||
}
|
||||
};
|
||||
|
||||
const onExpand = (newExpandedKeys: React.Key[]) => {
|
||||
setExpandedKeys(newExpandedKeys as string[]);
|
||||
@@ -20,18 +88,84 @@ const Sidebar: React.FC = () => {
|
||||
|
||||
const onSelect = (newSelectedKeys: React.Key[]) => {
|
||||
if (newSelectedKeys.length > 0) {
|
||||
setSelectedKey(newSelectedKeys[0] as string);
|
||||
const key = newSelectedKeys[0] as string;
|
||||
setSelectedKey(key);
|
||||
setShowDetailPanel(true);
|
||||
} else {
|
||||
setShowDetailPanel(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', borderRight: '1px solid #ddd', padding: '10px' }}>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', borderRight: '1px solid #f0f0f0', background: '#fff' }}>
|
||||
{!showDetailPanel ? (
|
||||
<>
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
block
|
||||
onClick={() => {
|
||||
setParentIdForNew(null);
|
||||
setEditingProjectId(null);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
style={{
|
||||
height: '36px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||
{treeData.length > 0 ? (
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||
onExpand={onExpand}
|
||||
onSelect={onSelect}
|
||||
showLine={{ showLeafIcon: false }}
|
||||
defaultExpandAll={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
color: '#999'
|
||||
}}>
|
||||
<p>No projects yet</p>
|
||||
<p style={{ fontSize: '12px', marginTop: '8px' }}>
|
||||
Click "New Project" to get started
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ProjectDetailPanel
|
||||
projectId={selectedKey}
|
||||
onClose={() => {
|
||||
setShowDetailPanel(false);
|
||||
setSelectedKey(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ProjectModal
|
||||
visible={modalVisible}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
setEditingProjectId(null);
|
||||
setParentIdForNew(null);
|
||||
}}
|
||||
projectId={editingProjectId}
|
||||
parentId={parentIdForNew}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,68 +1,12 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ interface Project {
|
||||
name: string;
|
||||
path: string;
|
||||
parentId: string | null;
|
||||
description?: string | null;
|
||||
attributes?: Record<string, any>;
|
||||
children?: Project[];
|
||||
}
|
||||
|
||||
@@ -17,6 +19,9 @@ interface ProjectState {
|
||||
setSelectedKey: (key: string | null) => void;
|
||||
expandNode: (key: string) => void;
|
||||
fetchProjects: () => Promise<void>;
|
||||
createProject: (data: { name: string; parentId?: string | null; description?: string }) => Promise<void>;
|
||||
updateProject: (id: string, data: { name?: string; description?: string; parentId?: string | null }) => Promise<void>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DUMMY_PROJECTS: Project[] = [
|
||||
@@ -42,10 +47,17 @@ export const useProjectStore = create<ProjectState>((set) => ({
|
||||
})),
|
||||
fetchProjects: async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/projects');
|
||||
// Use relative path when proxied through nginx, or environment variable
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/projects';
|
||||
const response = await fetch(apiUrl);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
set({ projects: data });
|
||||
// Map API response to frontend format (parent_id -> parentId)
|
||||
const mappedData = data.map((p: any) => ({
|
||||
...p,
|
||||
parentId: p.parent_id || null,
|
||||
}));
|
||||
set({ projects: mappedData });
|
||||
} else {
|
||||
console.error('Failed to fetch projects');
|
||||
set({ projects: DUMMY_PROJECTS });
|
||||
@@ -55,4 +67,73 @@ export const useProjectStore = create<ProjectState>((set) => ({
|
||||
set({ projects: DUMMY_PROJECTS });
|
||||
}
|
||||
},
|
||||
createProject: async (data) => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/projects';
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
parent_id: data.parentId || null,
|
||||
tenant_id: '00000000-0000-0000-0000-000000000001', // Default tenant for local dev
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
await useProjectStore.getState().fetchProjects();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to create project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
updateProject: async (id, data) => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/projects';
|
||||
const response = await fetch(`${apiUrl}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...(data.name && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description }),
|
||||
...(data.parentId !== undefined && { parent_id: data.parentId }),
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
await useProjectStore.getState().fetchProjects();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to update project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteProject: async (id) => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/projects';
|
||||
const response = await fetch(`${apiUrl}/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
await useProjectStore.getState().fetchProjects();
|
||||
// Clear selection if deleted project was selected
|
||||
const state = useProjectStore.getState();
|
||||
if (state.selectedKey === id) {
|
||||
state.setSelectedKey(null);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to delete project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user