feat: add wiki, metadata, and task management
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
This commit is contained in:
@@ -5,6 +5,8 @@ import { TenantsModule } from './tenants/tenants.module';
|
||||
import { ProjectsModule } from './projects/projects.module';
|
||||
import { Tenant } from './tenants/tenant.entity';
|
||||
import { Project } from './projects/project.entity';
|
||||
import { Task } from './tasks/task.entity';
|
||||
import { TasksModule } from './tasks/tasks.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -16,11 +18,12 @@ import { Project } from './projects/project.entity';
|
||||
username: process.env.DB_USERNAME || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_DATABASE || 'evrak',
|
||||
entities: [Tenant, Project],
|
||||
synchronize: false, // Schema is managed by SQL migration files
|
||||
entities: [Tenant, Project, Task],
|
||||
synchronize: true, // Enabled for development
|
||||
}),
|
||||
TenantsModule,
|
||||
ProjectsModule,
|
||||
TasksModule,
|
||||
],
|
||||
})
|
||||
export class AppModule { }
|
||||
|
||||
@@ -19,6 +19,9 @@ export class Project {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
wiki_content: string;
|
||||
|
||||
@Column({ type: 'ltree' })
|
||||
path: string;
|
||||
|
||||
|
||||
44
backend/src/tasks/task.entity.ts
Normal file
44
backend/src/tasks/task.entity.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Project } from '../projects/project.entity';
|
||||
import { Tenant } from '../tenants/tenant.entity';
|
||||
|
||||
@Entity('tasks')
|
||||
export class Task {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
project_id: string;
|
||||
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: Project;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ default: 'todo' })
|
||||
status: string; // todo, in_progress, done
|
||||
|
||||
@Column({ default: 'medium' })
|
||||
priority: string; // low, medium, high
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
due_date: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
29
backend/src/tasks/tasks.controller.ts
Normal file
29
backend/src/tasks/tasks.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('tasks')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) { }
|
||||
|
||||
@Get()
|
||||
findAll(@Query('projectId') projectId: string) {
|
||||
return this.tasksService.findAll(projectId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() createTaskDto: any) {
|
||||
return this.tasksService.create(createTaskDto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateTaskDto: any) {
|
||||
return this.tasksService.update(id, updateTaskDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.tasksService.remove(id);
|
||||
}
|
||||
}
|
||||
12
backend/src/tasks/tasks.module.ts
Normal file
12
backend/src/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { Task } from './task.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Task])],
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule { }
|
||||
49
backend/src/tasks/tasks.service.ts
Normal file
49
backend/src/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Inject, Scope } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Task } from './task.entity';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class TasksService {
|
||||
constructor(
|
||||
@InjectRepository(Task)
|
||||
private tasksRepository: Repository<Task>,
|
||||
@Inject(REQUEST) private request: Request,
|
||||
) { }
|
||||
|
||||
private getTenantId(): string {
|
||||
const user = (this.request as any).user;
|
||||
return user.tenantId;
|
||||
}
|
||||
|
||||
async findAll(projectId: string): Promise<Task[]> {
|
||||
return this.tasksRepository.find({
|
||||
where: {
|
||||
project_id: projectId,
|
||||
tenant_id: this.getTenantId()
|
||||
},
|
||||
order: { createdAt: 'DESC' }
|
||||
});
|
||||
}
|
||||
|
||||
async create(createTaskDto: any): Promise<Task> {
|
||||
return this.tasksRepository.save({
|
||||
...createTaskDto,
|
||||
tenant_id: this.getTenantId()
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, updateTaskDto: any): Promise<Task | null> {
|
||||
await this.tasksRepository.update(
|
||||
{ id, tenant_id: this.getTenantId() },
|
||||
updateTaskDto
|
||||
);
|
||||
return this.tasksRepository.findOne({ where: { id, tenant_id: this.getTenantId() } });
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.tasksRepository.delete({ id, tenant_id: this.getTenantId() });
|
||||
}
|
||||
}
|
||||
2156
frontend/package-lock.json
generated
2156
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"antd": "^6.0.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"i18next": "^25.6.3",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.0",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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 { Card, Form, Input, Button, message, Typography, Divider, Tabs, Select, DatePicker, List, Checkbox, Tag, Space } from 'antd';
|
||||
import { SaveOutlined, DeleteOutlined, ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
import { useTaskStore } from '../store/useTaskStore';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface ProjectDetailPanelProps {
|
||||
projectId: string | null;
|
||||
@@ -13,8 +17,11 @@ interface ProjectDetailPanelProps {
|
||||
const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onClose }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { projects, updateProject, deleteProject, fetchProjects } = useProjectStore();
|
||||
const { tasks, fetchTasks, createTask, updateTask, deleteTask } = useTaskStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [wikiContent, setWikiContent] = useState<string | undefined>('');
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||
|
||||
const project = projectId ? projects.find(p => p.id === projectId) : null;
|
||||
|
||||
@@ -23,12 +30,19 @@ const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onCl
|
||||
form.setFieldsValue({
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
attributes: project.attributes ? JSON.stringify(project.attributes, null, 2) : '{}',
|
||||
status: project.attributes?.status || 'active',
|
||||
priority: project.attributes?.priority || 'medium',
|
||||
startDate: project.attributes?.startDate ? dayjs(project.attributes.startDate) : null,
|
||||
dueDate: project.attributes?.dueDate ? dayjs(project.attributes.dueDate) : null,
|
||||
tags: project.attributes?.tags || [],
|
||||
});
|
||||
setWikiContent(project.wiki_content || '');
|
||||
fetchTasks(project.id);
|
||||
} else {
|
||||
form.resetFields();
|
||||
setWikiContent('');
|
||||
}
|
||||
}, [project, form]);
|
||||
}, [project, form, fetchTasks]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!project) return;
|
||||
@@ -36,23 +50,23 @@ const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onCl
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
...project.attributes,
|
||||
status: values.status,
|
||||
priority: values.priority,
|
||||
startDate: values.startDate ? values.startDate.toISOString() : null,
|
||||
dueDate: values.dueDate ? values.dueDate.toISOString() : null,
|
||||
tags: values.tags,
|
||||
};
|
||||
|
||||
await updateProject(project.id, {
|
||||
name: values.name,
|
||||
description: values.description || null,
|
||||
wiki_content: wikiContent,
|
||||
attributes: attributes,
|
||||
});
|
||||
|
||||
// Note: attributes update would need backend support
|
||||
message.success('Project updated successfully');
|
||||
await fetchProjects();
|
||||
} catch (error: any) {
|
||||
@@ -77,12 +91,24 @@ const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onCl
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTask = async () => {
|
||||
if (!newTaskTitle.trim() || !project) return;
|
||||
await createTask({
|
||||
title: newTaskTitle,
|
||||
project_id: project.id,
|
||||
status: 'todo',
|
||||
priority: 'medium'
|
||||
});
|
||||
setNewTaskTitle('');
|
||||
message.success('Task added');
|
||||
};
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
@@ -96,231 +122,128 @@ const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onCl
|
||||
);
|
||||
}
|
||||
|
||||
const children = projects.filter(p => p.parentId === project.id);
|
||||
const parent = project.parentId ? projects.find(p => p.id === project.parentId) : null;
|
||||
const items = [
|
||||
{
|
||||
key: '1',
|
||||
label: 'Details',
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="Project Name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Divider>Metadata</Divider>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<Form.Item name="status" label="Status">
|
||||
<Select>
|
||||
<Option value="active">Active</Option>
|
||||
<Option value="on_hold">On Hold</Option>
|
||||
<Option value="completed">Completed</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label="Priority">
|
||||
<Select>
|
||||
<Option value="high">High</Option>
|
||||
<Option value="medium">Medium</Option>
|
||||
<Option value="low">Low</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="Start Date">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dueDate" label="Due Date">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="tags" label="Tags">
|
||||
<Select mode="tags" placeholder="Add tags" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'Wiki',
|
||||
children: (
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={wikiContent}
|
||||
onChange={setWikiContent}
|
||||
height={500}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: `Tasks (${tasks.length})`,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||
<Input
|
||||
placeholder="Add a new task..."
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onPressEnter={handleAddTask}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddTask}>Add</Button>
|
||||
</div>
|
||||
<List
|
||||
dataSource={tasks}
|
||||
renderItem={(task) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => deleteTask(task.id)} />
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Checkbox
|
||||
checked={task.status === 'done'}
|
||||
onChange={(e) => updateTask(task.id, { status: e.target.checked ? 'done' : 'todo' })}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Text delete={task.status === 'done'}>
|
||||
{task.title}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Space>
|
||||
<Tag color={task.priority === 'high' ? 'red' : task.priority === 'medium' ? 'orange' : 'green'}>
|
||||
{task.priority}
|
||||
</Tag>
|
||||
{task.due_date && <Text type="secondary" style={{ fontSize: '12px' }}>Due: {dayjs(task.due_date).format('YYYY-MM-DD')}</Text>}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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 style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{onClose && <Button type="text" icon={<ArrowLeftOutlined />} onClick={onClose} />}
|
||||
<Text strong style={{ fontSize: '15px' }}>{project.name}</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button danger icon={<DeleteOutlined />} loading={deleteLoading} onClick={handleDelete}>Delete</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={loading} onClick={handleSave}>Save</Button>
|
||||
</Space>
|
||||
</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>
|
||||
<Tabs defaultActiveKey="1" items={items} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetailPanel;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Project {
|
||||
path: string;
|
||||
parentId: string | null;
|
||||
description?: string | null;
|
||||
wiki_content?: string;
|
||||
attributes?: Record<string, any>;
|
||||
children?: Project[];
|
||||
}
|
||||
@@ -20,7 +21,7 @@ interface ProjectState {
|
||||
expandNode: (key: string) => void;
|
||||
fetchProjects: () => Promise<void>;
|
||||
createProject: (data: { name: string; parentId?: string | null; description?: string }) => Promise<void>;
|
||||
updateProject: (id: string, data: { name?: string; description?: string; parentId?: string | null }) => Promise<void>;
|
||||
updateProject: (id: string, data: { name?: string; description?: string; parentId?: string | null; wiki_content?: string; attributes?: any }) => Promise<void>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -101,6 +102,8 @@ export const useProjectStore = create<ProjectState>((set) => ({
|
||||
...(data.name && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description }),
|
||||
...(data.parentId !== undefined && { parent_id: data.parentId }),
|
||||
...(data.wiki_content !== undefined && { wiki_content: data.wiki_content }),
|
||||
...(data.attributes !== undefined && { attributes: data.attributes }),
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
|
||||
102
frontend/src/store/useTaskStore.ts
Normal file
102
frontend/src/store/useTaskStore.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
due_date?: string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
interface TaskState {
|
||||
tasks: Task[];
|
||||
loading: boolean;
|
||||
fetchTasks: (projectId: string) => Promise<void>;
|
||||
createTask: (task: Partial<Task>) => Promise<void>;
|
||||
updateTask: (id: string, updates: Partial<Task>) => Promise<void>;
|
||||
deleteTask: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useTaskStore = create<TaskState>((set) => ({
|
||||
tasks: [],
|
||||
loading: false,
|
||||
|
||||
fetchTasks: async (projectId: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/tasks?projectId=${projectId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const tasks = await response.json();
|
||||
set({ tasks });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createTask: async (task: Partial<Task>) => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/tasks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(task)
|
||||
});
|
||||
if (response.ok) {
|
||||
const newTask = await response.json();
|
||||
set(state => ({ tasks: [newTask, ...state.tasks] }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
}
|
||||
},
|
||||
|
||||
updateTask: async (id: string, updates: Partial<Task>) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/tasks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
if (response.ok) {
|
||||
const updatedTask = await response.json();
|
||||
set(state => ({
|
||||
tasks: state.tasks.map(t => t.id === id ? updatedTask : t)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update task:', error);
|
||||
}
|
||||
},
|
||||
|
||||
deleteTask: async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
set(state => ({
|
||||
tasks: state.tasks.filter(t => t.id !== id)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -3,11 +3,16 @@
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
@@ -15,14 +20,15 @@
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user