feat: Add full CRUD functionality, project detail panel, and improved UI
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:
gitmuhammedalbayrak
2025-11-27 03:18:48 +03:00
parent 066c16221d
commit b9148cfa4b
15 changed files with 1306 additions and 161 deletions

View File

@@ -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,

View 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;

View File

@@ -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();

View File

@@ -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);

View File

@@ -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:
// 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, '_')}`;
}
let path = data.id ? data.id.replace(/-/g, '_') : 'temp'; // ID is not generated yet... const project = this.projectsRepository.create({
// Actually, we need to save first to get ID, or generate UUID manually. ...data,
// Better to generate UUID in code if we need it for path. 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)',

View File

@@ -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
```
## How to Deploy ## Quick Start
### 1. Build Images ### 1. Build and Start Services
First, build the Docker images locally. `docker stack deploy` does not build images, so this step is required.
```powershell From the project root directory, run:
docker compose -f deploy/local/docker-compose.yml build
```bash
docker compose -f deploy/local/docker-compose.yml up --build
``` ```
### 2. Deploy to Swarm Or in PowerShell:
Deploy the stack to your local Swarm cluster.
```powershell ```powershell
docker stack deploy -c deploy/local/docker-compose.yml evrak docker compose -f deploy/local/docker-compose.yml up --build
``` ```
### 3. Verify This will:
Check if the services are running: - 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 service ls
docker stack ps evrak Once all services are running, access the application at:
```
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
View 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`

View File

@@ -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

View File

@@ -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;

View File

@@ -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%' }}> <>
<ReactFlow nodes={nodes} edges={edges} fitView> <div style={{ height: '100%', width: '100%' }}>
<Background /> <ReactFlow
<Controls /> nodes={nodes}
</ReactFlow> edges={edges}
</div> 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>
); );
}; };

View 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;

View 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;

View File

@@ -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' }}>
<Tree {!showDetailPanel ? (
treeData={treeData} <>
expandedKeys={expandedKeys} <div style={{
selectedKeys={selectedKey ? [selectedKey] : []} padding: '16px 20px',
onExpand={onExpand} borderBottom: '1px solid #f0f0f0',
onSelect={onSelect} 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> </div>
); );

View File

@@ -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;
}
} }

View File

@@ -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;
}
},
})); }));