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',
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
database: process.env.DB_DATABASE || 'evrak',
|
database: process.env.DB_DATABASE || 'evrak',
|
||||||
entities: [Tenant, Project],
|
entities: [Tenant, Project],
|
||||||
synchronize: true, // Disable in production!
|
synchronize: false, // Schema is managed by SQL migration files
|
||||||
}),
|
}),
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
ProjectsModule,
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
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);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
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 { ProjectsService } from './projects.service';
|
||||||
import { Project } from './project.entity';
|
import { Project } from './project.entity';
|
||||||
|
|
||||||
@@ -21,6 +21,16 @@ export class ProjectsController {
|
|||||||
return this.projectsService.findOne(id);
|
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')
|
@Put(':id/move')
|
||||||
move(@Param('id') id: string, @Body('newParentId') newParentId: string | null): Promise<void> {
|
move(@Param('id') id: string, @Body('newParentId') newParentId: string | null): Promise<void> {
|
||||||
return this.projectsService.moveSubtree(id, newParentId);
|
return this.projectsService.moveSubtree(id, newParentId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { Project } from './project.entity';
|
import { Project } from './project.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -12,16 +13,35 @@ export class ProjectsService {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
async create(data: Partial<Project>): Promise<Project> {
|
async create(data: Partial<Project>): Promise<Project> {
|
||||||
// Logic to calculate path based on parent_id would go here
|
// Generate UUID if not provided
|
||||||
// For now, assuming path is provided or calculated by caller/DB trigger if we had one
|
const projectId = data.id || randomUUID();
|
||||||
// But typically we calculate it in service:
|
|
||||||
|
|
||||||
let path = data.id ? data.id.replace(/-/g, '_') : 'temp'; // ID is not generated yet...
|
// Calculate ltree path based on parent_id
|
||||||
// Actually, we need to save first to get ID, or generate UUID manually.
|
let path: string;
|
||||||
// Better to generate UUID in code if we need it for path.
|
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);
|
return this.projectsRepository.save(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +53,45 @@ export class ProjectsService {
|
|||||||
return this.projectsRepository.findOneBy({ id });
|
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> {
|
async moveSubtree(projectId: string, newParentId: string | null): Promise<void> {
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
'SELECT move_project_subtree($1, $2)',
|
'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
|
## Prerequisites
|
||||||
|
|
||||||
1. **Docker Desktop**: Ensure Docker Desktop is installed and running.
|
1. **Docker Desktop**: Ensure Docker Desktop is installed and running on your machine.
|
||||||
2. **Swarm Mode**: Enable Swarm mode if not already enabled:
|
2. **Ports**: Make sure ports 80, 3000, and 5432 are available on your host machine.
|
||||||
```powershell
|
|
||||||
docker swarm init
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Build and Start Services
|
||||||
|
|
||||||
|
From the project root directory, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/local/docker-compose.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## How to Deploy
|
Or in PowerShell:
|
||||||
|
|
||||||
### 1. Build Images
|
|
||||||
First, build the Docker images locally. `docker stack deploy` does not build images, so this step is required.
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose -f deploy/local/docker-compose.yml build
|
docker compose -f deploy/local/docker-compose.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Deploy to Swarm
|
This will:
|
||||||
Deploy the stack to your local Swarm cluster.
|
- Build the Docker images for backend and frontend
|
||||||
|
- Start PostgreSQL database
|
||||||
|
- Start the backend API server
|
||||||
|
- Start the frontend web server
|
||||||
|
|
||||||
```powershell
|
### 2. Access the Application
|
||||||
docker stack deploy -c deploy/local/docker-compose.yml evrak
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Verify
|
Once all services are running, access the application at:
|
||||||
Check if the services are running:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker service ls
|
|
||||||
docker stack ps evrak
|
|
||||||
```
|
|
||||||
|
|
||||||
Access the application:
|
|
||||||
* **Frontend**: http://localhost
|
* **Frontend**: http://localhost
|
||||||
* **Backend API**: http://localhost:3000
|
* **Backend API**: http://localhost:3000
|
||||||
* **Database**: localhost:5432
|
* **Database**: localhost:5432 (user: `evrak_user`, password: `evrak_password`, database: `evrak`)
|
||||||
|
|
||||||
### 4. Remove Stack
|
### 3. Stop Services
|
||||||
To stop and remove the application:
|
|
||||||
|
|
||||||
```powershell
|
To stop all services:
|
||||||
docker stack rm evrak
|
|
||||||
|
```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
|
## 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.
|
* **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:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:14-alpine
|
image: postgres:14-alpine
|
||||||
|
container_name: evrak-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: evrak_user
|
POSTGRES_USER: evrak_user
|
||||||
POSTGRES_PASSWORD: evrak_password
|
POSTGRES_PASSWORD: evrak_password
|
||||||
@@ -11,19 +10,22 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ../../backend/src/database/schema-local.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||||
networks:
|
networks:
|
||||||
- evrak-net
|
- evrak-net
|
||||||
deploy:
|
restart: unless-stopped
|
||||||
replicas: 1
|
healthcheck:
|
||||||
restart_policy:
|
test: ["CMD-SHELL", "pg_isready -U evrak_user -d evrak"]
|
||||||
condition: on-failure
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
# Note: 'docker stack deploy' ignores 'build'. You must run 'docker compose build' first.
|
|
||||||
build:
|
build:
|
||||||
context: ../../backend
|
context: ../../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: evrak-backend:local
|
image: evrak-backend:local
|
||||||
|
container_name: evrak-backend
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: postgres
|
DB_HOST: postgres
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
@@ -34,35 +36,29 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- evrak-net
|
- evrak-net
|
||||||
deploy:
|
restart: unless-stopped
|
||||||
replicas: 1
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ../../frontend
|
context: ../../frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: evrak-frontend:local
|
image: evrak-frontend:local
|
||||||
|
container_name: evrak-frontend
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
- evrak-net
|
- evrak-net
|
||||||
deploy:
|
restart: unless-stopped
|
||||||
replicas: 1
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
evrak-net:
|
evrak-net:
|
||||||
driver: overlay
|
driver: bridge
|
||||||
attachable: true
|
|
||||||
|
|||||||
@@ -2,6 +2,33 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
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 / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
|||||||
@@ -1,30 +1,218 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState, useCallback, useRef } from 'react';
|
||||||
import ReactFlow, { Background, Controls, type Node, type Edge } from 'reactflow';
|
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 'reactflow/dist/style.css';
|
||||||
import { useProjectStore } from '../store/useProjectStore';
|
import { useProjectStore } from '../store/useProjectStore';
|
||||||
|
import ProjectModal from './ProjectModal';
|
||||||
|
|
||||||
const MindMap: React.FC = () => {
|
const MindMapContent: React.FC = () => {
|
||||||
const { projects } = useProjectStore();
|
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)
|
// Show ALL projects in the map
|
||||||
// For demo, just showing all projects as nodes
|
const displayProjects = useMemo(() => {
|
||||||
const nodes: Node[] = useMemo(() => {
|
return projects;
|
||||||
return projects.map((p, index) => ({
|
|
||||||
id: p.id,
|
|
||||||
position: { x: index * 200, y: index * 100 },
|
|
||||||
data: { label: p.name },
|
|
||||||
}));
|
|
||||||
}, [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 (
|
return (
|
||||||
|
<>
|
||||||
<div style={{ height: '100%', width: '100%' }}>
|
<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 />
|
<Background />
|
||||||
<Controls />
|
<Controls />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</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 React, { useState } from 'react';
|
||||||
import { Tree } from 'antd';
|
import { Tree, Button, Dropdown, message } from 'antd';
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, MoreOutlined } from '@ant-design/icons';
|
||||||
import { useProjectStore } from '../store/useProjectStore';
|
import { useProjectStore } from '../store/useProjectStore';
|
||||||
import type { TreeDataNode } from 'antd';
|
import type { TreeDataNode } from 'antd';
|
||||||
|
import ProjectModal from './ProjectModal';
|
||||||
|
import ProjectDetailPanel from './ProjectDetailPanel';
|
||||||
|
|
||||||
const Sidebar: React.FC = () => {
|
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
|
// Build hierarchy tree from flat list
|
||||||
// This is a simplified transformation. In real app, you'd handle hierarchy properly.
|
const buildTree = (items: typeof projects, parentId: string | null = null): TreeDataNode[] => {
|
||||||
const treeData: TreeDataNode[] = projects.map((p) => ({
|
return items
|
||||||
title: p.name,
|
.filter(item => item.parentId === parentId)
|
||||||
key: p.id,
|
.map(item => ({
|
||||||
isLeaf: false, // Assuming all can have children for now
|
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[]) => {
|
const onExpand = (newExpandedKeys: React.Key[]) => {
|
||||||
setExpandedKeys(newExpandedKeys as string[]);
|
setExpandedKeys(newExpandedKeys as string[]);
|
||||||
@@ -20,18 +88,84 @@ const Sidebar: React.FC = () => {
|
|||||||
|
|
||||||
const onSelect = (newSelectedKeys: React.Key[]) => {
|
const onSelect = (newSelectedKeys: React.Key[]) => {
|
||||||
if (newSelectedKeys.length > 0) {
|
if (newSelectedKeys.length > 0) {
|
||||||
setSelectedKey(newSelectedKeys[0] as string);
|
const key = newSelectedKeys[0] as string;
|
||||||
|
setSelectedKey(key);
|
||||||
|
setShowDetailPanel(true);
|
||||||
|
} else {
|
||||||
|
setShowDetailPanel(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<Tree
|
||||||
treeData={treeData}
|
treeData={treeData}
|
||||||
expandedKeys={expandedKeys}
|
expandedKeys={expandedKeys}
|
||||||
selectedKeys={selectedKey ? [selectedKey] : []}
|
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||||
onExpand={onExpand}
|
onExpand={onExpand}
|
||||||
onSelect={onSelect}
|
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>
|
</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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
padding: 0;
|
||||||
place-items: center;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
min-width: 320px;
|
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
min-height: 100vh;
|
'Noto Color Emoji';
|
||||||
|
background-color: #f0f2f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
#root {
|
||||||
font-size: 3.2em;
|
height: 100vh;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface Project {
|
|||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
attributes?: Record<string, any>;
|
||||||
children?: Project[];
|
children?: Project[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +19,9 @@ interface ProjectState {
|
|||||||
setSelectedKey: (key: string | null) => void;
|
setSelectedKey: (key: string | null) => void;
|
||||||
expandNode: (key: string) => void;
|
expandNode: (key: string) => void;
|
||||||
fetchProjects: () => Promise<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[] = [
|
const DUMMY_PROJECTS: Project[] = [
|
||||||
@@ -42,10 +47,17 @@ export const useProjectStore = create<ProjectState>((set) => ({
|
|||||||
})),
|
})),
|
||||||
fetchProjects: async () => {
|
fetchProjects: async () => {
|
||||||
try {
|
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) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
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 {
|
} else {
|
||||||
console.error('Failed to fetch projects');
|
console.error('Failed to fetch projects');
|
||||||
set({ projects: DUMMY_PROJECTS });
|
set({ projects: DUMMY_PROJECTS });
|
||||||
@@ -55,4 +67,73 @@ export const useProjectStore = create<ProjectState>((set) => ({
|
|||||||
set({ projects: DUMMY_PROJECTS });
|
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