mirror of
https://github.com/OHV-IT/collabrix.git
synced 2025-12-15 08:38:36 +01:00
Beta Release: Complete Kanban system with auto-save, route persistence, and UI improvements
- Added complete Kanban board functionality with drag-and-drop - Implemented auto-save for Kanban card editing (no more edit button) - Added route persistence to remember last visited page on reload - Improved Kanban UI design with slimmer borders and compact layout - Added checklist functionality for Kanban cards - Enhanced file upload and direct messaging features - Improved authentication and user management - Added toast notifications system - Various UI/UX improvements and bug fixes
This commit is contained in:
parent
5966b9b3f3
commit
a7ff948e7e
@ -4,7 +4,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
from app.database import create_db_and_tables
|
||||
from app.config import get_settings
|
||||
from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages
|
||||
from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@ -53,6 +53,7 @@ app.include_router(messages.router)
|
||||
app.include_router(direct_messages.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(snippets.router)
|
||||
app.include_router(kanban.router)
|
||||
app.include_router(websocket.router)
|
||||
|
||||
|
||||
|
||||
@ -104,6 +104,7 @@ class Channel(SQLModel, table=True):
|
||||
# Relationships
|
||||
department: Department = Relationship(back_populates="channels")
|
||||
messages: List["Message"] = Relationship(back_populates="channel")
|
||||
kanban_board: Optional["KanbanBoard"] = Relationship(back_populates="channel")
|
||||
|
||||
|
||||
class Message(SQLModel, table=True):
|
||||
@ -199,3 +200,85 @@ class Snippet(SQLModel, table=True):
|
||||
owner: User = Relationship(back_populates="snippets")
|
||||
department: Optional[Department] = Relationship()
|
||||
allowed_departments: List["Department"] = Relationship(link_model=SnippetDepartmentLink)
|
||||
|
||||
|
||||
# Kanban Board Models
|
||||
class KanbanBoard(SQLModel, table=True):
|
||||
__tablename__ = "kanban_board"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
channel_id: int = Field(foreign_key="channel.id")
|
||||
name: str = Field(default="Kanban Board")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
channel: Channel = Relationship(back_populates="kanban_board")
|
||||
columns: List["KanbanColumn"] = Relationship(back_populates="board")
|
||||
|
||||
|
||||
class KanbanColumn(SQLModel, table=True):
|
||||
__tablename__ = "kanban_column"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
board_id: int = Field(foreign_key="kanban_board.id")
|
||||
name: str
|
||||
position: int = Field(default=0) # For ordering columns
|
||||
color: Optional[str] = Field(default=None) # Hex color for the column
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
board: KanbanBoard = Relationship(back_populates="columns")
|
||||
cards: List["KanbanCard"] = Relationship(back_populates="column")
|
||||
|
||||
|
||||
class KanbanCard(SQLModel, table=True):
|
||||
__tablename__ = "kanban_card"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
column_id: int = Field(foreign_key="kanban_column.id")
|
||||
title: str
|
||||
description: Optional[str] = Field(default=None)
|
||||
assignee_id: Optional[int] = Field(default=None, foreign_key="user.id")
|
||||
position: int = Field(default=0) # For ordering cards within a column
|
||||
due_date: Optional[datetime] = Field(default=None)
|
||||
priority: Optional[str] = Field(default="medium") # low, medium, high
|
||||
labels: Optional[str] = Field(default=None) # JSON string for labels/tags
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
column: KanbanColumn = Relationship(back_populates="cards")
|
||||
assignee: Optional[User] = Relationship()
|
||||
checklists: List["KanbanChecklist"] = Relationship(back_populates="card")
|
||||
|
||||
|
||||
class KanbanChecklist(SQLModel, table=True):
|
||||
__tablename__ = "kanban_checklist"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
card_id: int = Field(foreign_key="kanban_card.id")
|
||||
title: str
|
||||
position: int = Field(default=0) # For ordering checklists within a card
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
card: KanbanCard = Relationship(back_populates="checklists")
|
||||
items: List["KanbanChecklistItem"] = Relationship(back_populates="checklist")
|
||||
|
||||
|
||||
class KanbanChecklistItem(SQLModel, table=True):
|
||||
__tablename__ = "kanban_checklist_item"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
checklist_id: int = Field(foreign_key="kanban_checklist.id")
|
||||
title: str
|
||||
is_completed: bool = Field(default=False)
|
||||
position: int = Field(default=0) # For ordering items within a checklist
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
checklist: KanbanChecklist = Relationship(back_populates="items")
|
||||
|
||||
@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
from typing import List
|
||||
from app.database import get_session
|
||||
from app.models import Channel, Department, User
|
||||
from app.models import Channel, Department, User, KanbanBoard, KanbanColumn
|
||||
from app.schemas import ChannelCreate, ChannelResponse
|
||||
from app.auth import get_current_user
|
||||
|
||||
@ -34,6 +34,34 @@ def create_channel(
|
||||
session.commit()
|
||||
session.refresh(new_channel)
|
||||
|
||||
# Automatically create a Kanban board for the new channel
|
||||
kanban_board = KanbanBoard(
|
||||
channel_id=new_channel.id,
|
||||
name=f"Kanban Board"
|
||||
)
|
||||
|
||||
session.add(kanban_board)
|
||||
session.commit()
|
||||
session.refresh(kanban_board)
|
||||
|
||||
# Create the 4 standard columns
|
||||
default_columns = [
|
||||
("ToDo", 0),
|
||||
("In Progress", 1),
|
||||
("Waiting", 2),
|
||||
("Done", 3)
|
||||
]
|
||||
|
||||
for name, position in default_columns:
|
||||
column = KanbanColumn(
|
||||
board_id=kanban_board.id,
|
||||
name=name,
|
||||
position=position
|
||||
)
|
||||
session.add(column)
|
||||
|
||||
session.commit()
|
||||
|
||||
return new_channel
|
||||
|
||||
|
||||
|
||||
@ -97,10 +97,18 @@ async def upload_file(
|
||||
session.refresh(file_attachment)
|
||||
|
||||
# Build response with can_edit flag
|
||||
response = FileAttachmentResponse.model_validate(file_attachment)
|
||||
response.permission = permission
|
||||
response.uploader_id = current_user.id
|
||||
response.can_edit = (permission == "write")
|
||||
response = FileAttachmentResponse(
|
||||
id=file_attachment.id,
|
||||
filename=file_attachment.filename,
|
||||
original_filename=file_attachment.original_filename,
|
||||
mime_type=file_attachment.mime_type,
|
||||
file_size=file_attachment.file_size,
|
||||
uploaded_at=file_attachment.uploaded_at,
|
||||
message_id=file_attachment.message_id,
|
||||
upload_permission=permission,
|
||||
uploader_id=current_user.id,
|
||||
is_editable=(permission == "write")
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@ -268,9 +276,10 @@ async def upload_file_with_message(
|
||||
"mime_type": file_attachment.mime_type,
|
||||
"file_size": file_attachment.file_size,
|
||||
"uploaded_at": file_attachment.uploaded_at.isoformat(),
|
||||
"permission": file_attachment.upload_permission,
|
||||
"message_id": file_attachment.message_id,
|
||||
"upload_permission": file_attachment.upload_permission,
|
||||
"uploader_id": file_attachment.uploader_id,
|
||||
"can_edit": file_attachment.is_editable
|
||||
"is_editable": file_attachment.is_editable
|
||||
}
|
||||
|
||||
response_data = {
|
||||
@ -374,3 +383,71 @@ def get_office_uri(
|
||||
"file_url": file_url,
|
||||
"app": app_protocol
|
||||
})
|
||||
|
||||
|
||||
@router.put("/{file_id}/permission", response_model=FileAttachmentResponse)
|
||||
async def update_file_permission(
|
||||
file_id: int,
|
||||
permission: str = Form(...),
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update the permission of a file (only uploader or admin can change)"""
|
||||
# Validate permission
|
||||
if permission not in ["read", "write"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Permission must be 'read' or 'write'"
|
||||
)
|
||||
|
||||
# Get file attachment
|
||||
file_attachment = session.get(FileAttachment, file_id)
|
||||
if not file_attachment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check if user is the uploader or an admin
|
||||
if file_attachment.uploader_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the uploader or an admin can change file permissions"
|
||||
)
|
||||
|
||||
# Update permission
|
||||
file_attachment.upload_permission = permission
|
||||
file_attachment.is_editable = (permission == "write")
|
||||
session.commit()
|
||||
session.refresh(file_attachment)
|
||||
|
||||
# Get message for channel access check
|
||||
message = session.get(Message, file_attachment.message_id)
|
||||
if not message:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Associated message not found"
|
||||
)
|
||||
|
||||
# Check if user still has access to the channel
|
||||
if not user_has_channel_access(current_user, message.channel_id, session):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No access to this channel"
|
||||
)
|
||||
|
||||
# Create response
|
||||
response = FileAttachmentResponse(
|
||||
id=file_attachment.id,
|
||||
filename=file_attachment.filename,
|
||||
original_filename=file_attachment.original_filename,
|
||||
mime_type=file_attachment.mime_type,
|
||||
file_size=file_attachment.file_size,
|
||||
uploaded_at=file_attachment.uploaded_at,
|
||||
message_id=file_attachment.message_id,
|
||||
uploader_id=file_attachment.uploader_id,
|
||||
upload_permission=file_attachment.upload_permission,
|
||||
is_editable=file_attachment.is_editable
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
705
backend/app/routers/kanban.py
Normal file
705
backend/app/routers/kanban.py
Normal file
@ -0,0 +1,705 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
from typing import List
|
||||
from app.database import get_session
|
||||
from app.models import (
|
||||
KanbanBoard, KanbanColumn, KanbanCard, Channel, User,
|
||||
KanbanChecklist, KanbanChecklistItem
|
||||
)
|
||||
from app.schemas import (
|
||||
KanbanBoardCreate, KanbanBoardUpdate, KanbanBoardResponse,
|
||||
KanbanColumnCreate, KanbanColumnUpdate, KanbanColumnResponse,
|
||||
KanbanCardCreate, KanbanCardUpdate, KanbanCardResponse,
|
||||
KanbanBoardWithColumns, KanbanColumnWithCards,
|
||||
KanbanChecklistCreate, KanbanChecklistUpdate, KanbanChecklistResponse,
|
||||
KanbanChecklistItemCreate, KanbanChecklistItemUpdate, KanbanChecklistItemResponse,
|
||||
KanbanChecklistWithItems, KanbanCardWithChecklists
|
||||
)
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/kanban", tags=["Kanban"])
|
||||
|
||||
|
||||
# Board endpoints
|
||||
@router.post("/boards", response_model=KanbanBoardResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_board(
|
||||
board_data: KanbanBoardCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new kanban board for a channel"""
|
||||
# Check if channel exists and user has access
|
||||
channel = session.get(Channel, board_data.channel_id)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Channel not found"
|
||||
)
|
||||
|
||||
# Check if user has access to the channel's department
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this channel"
|
||||
)
|
||||
|
||||
# Check if board already exists for this channel
|
||||
existing_board = session.exec(
|
||||
select(KanbanBoard).where(KanbanBoard.channel_id == board_data.channel_id)
|
||||
).first()
|
||||
if existing_board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Board already exists for this channel"
|
||||
)
|
||||
|
||||
new_board = KanbanBoard(
|
||||
channel_id=board_data.channel_id,
|
||||
name=board_data.name
|
||||
)
|
||||
|
||||
session.add(new_board)
|
||||
session.commit()
|
||||
session.refresh(new_board)
|
||||
|
||||
return new_board
|
||||
|
||||
|
||||
@router.get("/boards/{channel_id}", response_model=KanbanBoardWithColumns)
|
||||
def get_board_by_channel(
|
||||
channel_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get kanban board for a specific channel"""
|
||||
# Check if channel exists and user has access
|
||||
channel = session.get(Channel, channel_id)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Channel not found"
|
||||
)
|
||||
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this channel"
|
||||
)
|
||||
|
||||
# Get board with columns and cards
|
||||
board = session.exec(
|
||||
select(KanbanBoard).where(KanbanBoard.channel_id == channel_id)
|
||||
).first()
|
||||
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No kanban board found for this channel"
|
||||
)
|
||||
|
||||
# Load columns with cards
|
||||
columns = session.exec(
|
||||
select(KanbanColumn)
|
||||
.where(KanbanColumn.board_id == board.id)
|
||||
.order_by(KanbanColumn.position)
|
||||
).all()
|
||||
|
||||
board_data = KanbanBoardWithColumns.from_orm(board)
|
||||
board_data.columns = []
|
||||
|
||||
for column in columns:
|
||||
cards = session.exec(
|
||||
select(KanbanCard)
|
||||
.where(KanbanCard.column_id == column.id)
|
||||
.order_by(KanbanCard.position)
|
||||
).all()
|
||||
|
||||
column_data = KanbanColumnWithCards.from_orm(column)
|
||||
column_data.cards = [KanbanCardResponse.from_orm(card) for card in cards]
|
||||
board_data.columns.append(column_data)
|
||||
|
||||
return board_data
|
||||
|
||||
|
||||
@router.put("/boards/{board_id}", response_model=KanbanBoardResponse)
|
||||
def update_board(
|
||||
board_id: int,
|
||||
board_data: KanbanBoardUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a kanban board"""
|
||||
board = session.get(KanbanBoard, board_id)
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found"
|
||||
)
|
||||
|
||||
# Check access via channel
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
update_data = board_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(board, field, value)
|
||||
|
||||
session.commit()
|
||||
session.refresh(board)
|
||||
return board
|
||||
|
||||
|
||||
# Column endpoints
|
||||
@router.post("/columns", response_model=KanbanColumnResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_column(
|
||||
column_data: KanbanColumnCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new kanban column"""
|
||||
# Check if board exists and user has access
|
||||
board = session.get(KanbanBoard, column_data.board_id)
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found"
|
||||
)
|
||||
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
new_column = KanbanColumn(
|
||||
board_id=column_data.board_id,
|
||||
name=column_data.name,
|
||||
position=column_data.position,
|
||||
color=column_data.color
|
||||
)
|
||||
|
||||
session.add(new_column)
|
||||
session.commit()
|
||||
session.refresh(new_column)
|
||||
|
||||
return new_column
|
||||
|
||||
|
||||
@router.put("/columns/{column_id}", response_model=KanbanColumnResponse)
|
||||
def update_column(
|
||||
column_id: int,
|
||||
column_data: KanbanColumnUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a kanban column"""
|
||||
column = session.get(KanbanColumn, column_id)
|
||||
if not column:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Column not found"
|
||||
)
|
||||
|
||||
# Check access via board and channel
|
||||
board = session.get(KanbanBoard, column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
update_data = column_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(column, field, value)
|
||||
|
||||
session.commit()
|
||||
session.refresh(column)
|
||||
return column
|
||||
|
||||
|
||||
@router.delete("/columns/{column_id}")
|
||||
def delete_column(
|
||||
column_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a kanban column"""
|
||||
column = session.get(KanbanColumn, column_id)
|
||||
if not column:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Column not found"
|
||||
)
|
||||
|
||||
# Check access via board and channel
|
||||
board = session.get(KanbanBoard, column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
session.delete(column)
|
||||
session.commit()
|
||||
return {"message": "Column deleted successfully"}
|
||||
|
||||
|
||||
# Card endpoints
|
||||
@router.post("/cards", response_model=KanbanCardResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_card(
|
||||
card_data: KanbanCardCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new kanban card"""
|
||||
# Check if column exists and user has access
|
||||
column = session.get(KanbanColumn, card_data.column_id)
|
||||
if not column:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Column not found"
|
||||
)
|
||||
|
||||
board = session.get(KanbanBoard, column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
new_card = KanbanCard(
|
||||
column_id=card_data.column_id,
|
||||
title=card_data.title,
|
||||
description=card_data.description,
|
||||
assignee_id=card_data.assignee_id,
|
||||
position=card_data.position,
|
||||
due_date=card_data.due_date,
|
||||
priority=card_data.priority,
|
||||
labels=card_data.labels
|
||||
)
|
||||
|
||||
session.add(new_card)
|
||||
session.commit()
|
||||
session.refresh(new_card)
|
||||
|
||||
return new_card
|
||||
|
||||
|
||||
@router.put("/cards/{card_id}", response_model=KanbanCardResponse)
|
||||
def update_card(
|
||||
card_id: int,
|
||||
card_data: KanbanCardUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a kanban card"""
|
||||
card = session.get(KanbanCard, card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Card not found"
|
||||
)
|
||||
|
||||
# Check access via column, board and channel
|
||||
column = session.get(KanbanColumn, card.column_id)
|
||||
board = session.get(KanbanBoard, column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
update_data = card_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(card, field, value)
|
||||
|
||||
session.commit()
|
||||
session.refresh(card)
|
||||
return card
|
||||
|
||||
|
||||
@router.delete("/cards/{card_id}")
|
||||
def delete_card(
|
||||
card_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a kanban card"""
|
||||
card = session.get(KanbanCard, card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Card not found"
|
||||
)
|
||||
|
||||
# Check access via column, board and channel
|
||||
column = session.get(KanbanColumn, card.column_id)
|
||||
board = session.get(KanbanBoard, column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
session.delete(card)
|
||||
session.commit()
|
||||
return {"message": "Card deleted successfully"}
|
||||
|
||||
|
||||
@router.put("/cards/{card_id}/move")
|
||||
def move_card(
|
||||
card_id: int,
|
||||
target_column_id: int,
|
||||
new_position: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Move a card to a different column and/or position"""
|
||||
card = session.get(KanbanCard, card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Card not found"
|
||||
)
|
||||
|
||||
target_column = session.get(KanbanColumn, target_column_id)
|
||||
if not target_column:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Target column not found"
|
||||
)
|
||||
|
||||
# Check access for both source and target
|
||||
source_column = session.get(KanbanColumn, card.column_id)
|
||||
source_board = session.get(KanbanBoard, source_column.board_id)
|
||||
target_board = session.get(KanbanBoard, target_column.board_id)
|
||||
|
||||
if source_board.id != target_board.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot move cards between different boards"
|
||||
)
|
||||
|
||||
channel = session.get(Channel, source_board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Update card position
|
||||
card.column_id = target_column_id
|
||||
card.position = new_position
|
||||
|
||||
session.commit()
|
||||
session.refresh(card)
|
||||
return {"message": "Card moved successfully"}
|
||||
|
||||
|
||||
# Checklist endpoints
|
||||
@router.post("/checklists", response_model=KanbanChecklistResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_checklist(
|
||||
checklist_data: KanbanChecklistCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new checklist for a card"""
|
||||
# Check if card exists and user has access
|
||||
card = session.get(KanbanCard, checklist_data.card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Card not found"
|
||||
)
|
||||
|
||||
# Check access via board -> channel -> department
|
||||
board = session.get(KanbanBoard, card.column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
new_checklist = KanbanChecklist(
|
||||
card_id=checklist_data.card_id,
|
||||
title=checklist_data.title,
|
||||
position=checklist_data.position
|
||||
)
|
||||
|
||||
session.add(new_checklist)
|
||||
session.commit()
|
||||
session.refresh(new_checklist)
|
||||
|
||||
return new_checklist
|
||||
|
||||
|
||||
@router.get("/checklists/{checklist_id}", response_model=KanbanChecklistWithItems)
|
||||
def get_checklist(
|
||||
checklist_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a checklist with its items"""
|
||||
checklist = session.get(KanbanChecklist, checklist_id)
|
||||
if not checklist:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Checklist not found"
|
||||
)
|
||||
|
||||
# Check access via card -> board -> channel -> department
|
||||
board = session.get(KanbanBoard, checklist.card.column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
return checklist
|
||||
|
||||
|
||||
@router.put("/checklists/{checklist_id}", response_model=KanbanChecklistResponse)
|
||||
def update_checklist(
|
||||
checklist_id: int,
|
||||
checklist_data: KanbanChecklistUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a checklist"""
|
||||
checklist = session.get(KanbanChecklist, checklist_id)
|
||||
if not checklist:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Checklist not found"
|
||||
)
|
||||
|
||||
# Check access
|
||||
board = session.get(KanbanBoard, checklist.card.column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if checklist_data.title is not None:
|
||||
checklist.title = checklist_data.title
|
||||
if checklist_data.position is not None:
|
||||
checklist.position = checklist_data.position
|
||||
|
||||
session.commit()
|
||||
session.refresh(checklist)
|
||||
|
||||
return checklist
|
||||
|
||||
|
||||
@router.delete("/checklists/{checklist_id}")
|
||||
def delete_checklist(
|
||||
checklist_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a checklist"""
|
||||
checklist = session.get(KanbanChecklist, checklist_id)
|
||||
if not checklist:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Checklist not found"
|
||||
)
|
||||
|
||||
# Check access
|
||||
board = session.get(KanbanBoard, checklist.card.column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
session.delete(checklist)
|
||||
session.commit()
|
||||
|
||||
return {"message": "Checklist deleted successfully"}
|
||||
|
||||
|
||||
# Checklist Item endpoints
|
||||
@router.post("/checklist-items", response_model=KanbanChecklistItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_checklist_item(
|
||||
item_data: KanbanChecklistItemCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new checklist item"""
|
||||
# Check if checklist exists and user has access
|
||||
checklist = session.get(KanbanChecklist, item_data.checklist_id)
|
||||
if not checklist:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Checklist not found"
|
||||
)
|
||||
|
||||
# Check access via checklist -> card -> board -> channel -> department
|
||||
board = session.get(KanbanBoard, checklist.card.column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
new_item = KanbanChecklistItem(
|
||||
checklist_id=item_data.checklist_id,
|
||||
title=item_data.title,
|
||||
is_completed=item_data.is_completed,
|
||||
position=item_data.position
|
||||
)
|
||||
|
||||
session.add(new_item)
|
||||
session.commit()
|
||||
session.refresh(new_item)
|
||||
|
||||
return new_item
|
||||
|
||||
|
||||
@router.put("/checklist-items/{item_id}", response_model=KanbanChecklistItemResponse)
|
||||
def update_checklist_item(
|
||||
item_id: int,
|
||||
item_data: KanbanChecklistItemUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a checklist item"""
|
||||
item = session.get(KanbanChecklistItem, item_id)
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Checklist item not found"
|
||||
)
|
||||
|
||||
# Check access via item -> checklist -> card -> board -> channel -> department
|
||||
board = session.get(KanbanBoard, item.checklist.card.column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if item_data.title is not None:
|
||||
item.title = item_data.title
|
||||
if item_data.is_completed is not None:
|
||||
item.is_completed = item_data.is_completed
|
||||
if item_data.position is not None:
|
||||
item.position = item_data.position
|
||||
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/checklist-items/{item_id}")
|
||||
def delete_checklist_item(
|
||||
item_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a checklist item"""
|
||||
item = session.get(KanbanChecklistItem, item_id)
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Checklist item not found"
|
||||
)
|
||||
|
||||
# Check access
|
||||
board = session.get(KanbanBoard, item.checklist.card.column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
|
||||
return {"message": "Checklist item deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/cards/{card_id}/checklists", response_model=List[KanbanChecklistWithItems])
|
||||
def get_card_checklists(
|
||||
card_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all checklists for a specific card"""
|
||||
# Check if card exists and user has access
|
||||
card = session.get(KanbanCard, card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Card not found"
|
||||
)
|
||||
|
||||
# Check access via board -> channel -> department
|
||||
board = session.get(KanbanBoard, card.column.board_id)
|
||||
channel = session.get(Channel, board.channel_id)
|
||||
user_departments = [dept.id for dept in current_user.departments]
|
||||
if channel.department_id not in user_departments and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Get all checklists for the card with their items
|
||||
checklists = session.exec(
|
||||
select(KanbanChecklist)
|
||||
.where(KanbanChecklist.card_id == card_id)
|
||||
.order_by(KanbanChecklist.position)
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for checklist in checklists:
|
||||
items = session.exec(
|
||||
select(KanbanChecklistItem)
|
||||
.where(KanbanChecklistItem.checklist_id == checklist.id)
|
||||
.order_by(KanbanChecklistItem.position)
|
||||
).all()
|
||||
|
||||
checklist_data = KanbanChecklistWithItems.from_orm(checklist)
|
||||
checklist_data.items = [KanbanChecklistItemResponse.from_orm(item) for item in items]
|
||||
result.append(checklist_data)
|
||||
|
||||
return result
|
||||
@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlmodel import Session, select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from typing import List
|
||||
import os
|
||||
from app.database import get_session
|
||||
@ -122,11 +123,12 @@ def get_channel_messages(
|
||||
statement = (
|
||||
select(Message)
|
||||
.where(Message.channel_id == channel_id)
|
||||
.options(joinedload(Message.attachments))
|
||||
.order_by(Message.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
messages = session.exec(statement).all()
|
||||
messages = session.exec(statement).unique().all()
|
||||
|
||||
# Add sender usernames and reply_to info
|
||||
responses = []
|
||||
|
||||
@ -158,9 +158,10 @@ class FileAttachmentResponse(BaseModel):
|
||||
mime_type: str
|
||||
file_size: int
|
||||
uploaded_at: datetime
|
||||
permission: Optional[str] = "read"
|
||||
message_id: int
|
||||
upload_permission: Optional[str] = "read"
|
||||
uploader_id: Optional[int] = None
|
||||
can_edit: bool = False # Computed: whether current user can edit
|
||||
is_editable: bool = False # Computed: whether current user can edit
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@ -237,3 +238,153 @@ class TranslationUpdateRequest(BaseModel):
|
||||
translation_id: int
|
||||
value: str
|
||||
|
||||
|
||||
# Kanban Schemas
|
||||
class KanbanBoardBase(BaseModel):
|
||||
name: str = "Kanban Board"
|
||||
|
||||
|
||||
class KanbanBoardCreate(KanbanBoardBase):
|
||||
channel_id: int
|
||||
|
||||
|
||||
class KanbanBoardUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class KanbanBoardResponse(KanbanBoardBase):
|
||||
id: int
|
||||
channel_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KanbanColumnBase(BaseModel):
|
||||
name: str
|
||||
position: int = 0
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class KanbanColumnCreate(KanbanColumnBase):
|
||||
board_id: int
|
||||
|
||||
|
||||
class KanbanColumnUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
position: Optional[int] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class KanbanColumnResponse(KanbanColumnBase):
|
||||
id: int
|
||||
board_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KanbanCardBase(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
assignee_id: Optional[int] = None
|
||||
position: int = 0
|
||||
due_date: Optional[datetime] = None
|
||||
priority: Optional[str] = "medium"
|
||||
labels: Optional[str] = None
|
||||
|
||||
|
||||
class KanbanCardCreate(KanbanCardBase):
|
||||
column_id: int
|
||||
|
||||
|
||||
class KanbanCardUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
assignee_id: Optional[int] = None
|
||||
position: Optional[int] = None
|
||||
due_date: Optional[datetime] = None
|
||||
priority: Optional[str] = None
|
||||
labels: Optional[str] = None
|
||||
|
||||
|
||||
class KanbanCardResponse(KanbanCardBase):
|
||||
id: int
|
||||
column_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KanbanBoardWithColumns(KanbanBoardResponse):
|
||||
columns: List["KanbanColumnWithCards"] = []
|
||||
|
||||
|
||||
class KanbanColumnWithCards(KanbanColumnResponse):
|
||||
cards: List[KanbanCardResponse] = []
|
||||
|
||||
|
||||
# Checklist Schemas
|
||||
class KanbanChecklistBase(BaseModel):
|
||||
title: str
|
||||
position: int = 0
|
||||
|
||||
|
||||
class KanbanChecklistCreate(KanbanChecklistBase):
|
||||
card_id: int
|
||||
|
||||
|
||||
class KanbanChecklistUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
class KanbanChecklistResponse(KanbanChecklistBase):
|
||||
id: int
|
||||
card_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KanbanChecklistItemBase(BaseModel):
|
||||
title: str
|
||||
is_completed: bool = False
|
||||
position: int = 0
|
||||
|
||||
|
||||
class KanbanChecklistItemCreate(KanbanChecklistItemBase):
|
||||
checklist_id: int
|
||||
|
||||
|
||||
class KanbanChecklistItemUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
is_completed: Optional[bool] = None
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
class KanbanChecklistItemResponse(KanbanChecklistItemBase):
|
||||
id: int
|
||||
checklist_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KanbanChecklistWithItems(KanbanChecklistResponse):
|
||||
items: List[KanbanChecklistItemResponse] = []
|
||||
|
||||
|
||||
class KanbanCardWithChecklists(KanbanCardResponse):
|
||||
checklists: List[KanbanChecklistWithItems] = []
|
||||
|
||||
|
||||
70
backend/scripts/create_kanban_boards_for_channels.py
Executable file
70
backend/scripts/create_kanban_boards_for_channels.py
Executable file
@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to create Kanban boards for all existing channels that don't have one yet.
|
||||
This ensures all channels have their own Kanban board with the 4 standard columns.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from app.database import engine
|
||||
from app.models import Channel, KanbanBoard, KanbanColumn
|
||||
|
||||
def create_kanban_boards_for_existing_channels():
|
||||
"""Create Kanban boards for all channels that don't have one"""
|
||||
with Session(engine) as session:
|
||||
# Get all channels
|
||||
channels = session.exec(select(Channel)).all()
|
||||
|
||||
print(f"Found {len(channels)} channels")
|
||||
|
||||
# Check which channels already have boards
|
||||
boards = session.exec(select(KanbanBoard)).all()
|
||||
channels_with_boards = set([board.channel_id for board in boards])
|
||||
|
||||
channels_without_boards = [
|
||||
channel for channel in channels
|
||||
if channel.id not in channels_with_boards
|
||||
]
|
||||
|
||||
print(f"Found {len(channels_without_boards)} channels without Kanban boards")
|
||||
|
||||
default_columns = [
|
||||
("ToDo", 0),
|
||||
("In Progress", 1),
|
||||
("Waiting", 2),
|
||||
("Done", 3)
|
||||
]
|
||||
|
||||
for channel in channels_without_boards:
|
||||
print(f"Creating Kanban board for channel: {channel.name} (ID: {channel.id})")
|
||||
|
||||
# Create the board
|
||||
kanban_board = KanbanBoard(
|
||||
channel_id=channel.id,
|
||||
name=f"Kanban Board"
|
||||
)
|
||||
|
||||
session.add(kanban_board)
|
||||
session.commit()
|
||||
session.refresh(kanban_board)
|
||||
|
||||
# Create the 4 standard columns
|
||||
for name, position in default_columns:
|
||||
column = KanbanColumn(
|
||||
board_id=kanban_board.id,
|
||||
name=name,
|
||||
position=position
|
||||
)
|
||||
session.add(column)
|
||||
|
||||
session.commit()
|
||||
print(f" ✓ Created Kanban board with 4 standard columns for channel {channel.name}")
|
||||
|
||||
print(f"\n✅ Successfully created Kanban boards for {len(channels_without_boards)} channels")
|
||||
print("All channels now have their own Kanban board with the 4 standard columns: ToDo, In Progress, Waiting, Done")
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_kanban_boards_for_existing_channels()
|
||||
71
backend/scripts/create_kanban_checklist_tables.py
Normal file
71
backend/scripts/create_kanban_checklist_tables.py
Normal file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to create Kanban checklist tables
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path to import app modules
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.database import engine
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Create Kanban checklist tables"""
|
||||
with engine.connect() as conn:
|
||||
# Check if tables already exist
|
||||
result = conn.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('kanban_checklist', 'kanban_checklist_item')
|
||||
"""))
|
||||
|
||||
existing_tables = [row[0] for row in result.fetchall()]
|
||||
|
||||
if 'kanban_checklist' in existing_tables:
|
||||
print("✅ Kanban checklist tables already exist")
|
||||
return
|
||||
|
||||
# Create kanban_checklist table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE kanban_checklist (
|
||||
id SERIAL PRIMARY KEY,
|
||||
card_id INTEGER NOT NULL REFERENCES kanban_card(id) ON DELETE CASCADE,
|
||||
title VARCHAR NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create kanban_checklist_item table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE kanban_checklist_item (
|
||||
id SERIAL PRIMARY KEY,
|
||||
checklist_id INTEGER NOT NULL REFERENCES kanban_checklist(id) ON DELETE CASCADE,
|
||||
title VARCHAR NOT NULL,
|
||||
is_completed BOOLEAN DEFAULT FALSE,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create indexes
|
||||
conn.execute(text("""
|
||||
CREATE INDEX idx_kanban_checklist_card_id ON kanban_checklist(card_id)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE INDEX idx_kanban_checklist_item_checklist_id ON kanban_checklist_item(checklist_id)
|
||||
"""))
|
||||
|
||||
conn.commit()
|
||||
print("✅ Kanban checklist tables created successfully")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
115
backend/scripts/create_kanban_tables.py
Normal file
115
backend/scripts/create_kanban_tables.py
Normal file
@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to create Kanban tables
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path to import app modules
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.database import engine
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Create Kanban tables"""
|
||||
with engine.connect() as conn:
|
||||
# Check if tables already exist
|
||||
result = conn.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('kanban_board', 'kanban_column', 'kanban_card')
|
||||
"""))
|
||||
|
||||
existing_tables = [row[0] for row in result.fetchall()]
|
||||
|
||||
if 'kanban_board' in existing_tables:
|
||||
print("✅ Kanban tables already exist")
|
||||
return
|
||||
|
||||
print("🚀 Creating Kanban tables...")
|
||||
|
||||
# Create kanban_board table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE kanban_board (
|
||||
id SERIAL PRIMARY KEY,
|
||||
channel_id INTEGER NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL DEFAULT 'Kanban Board',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create kanban_column table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE kanban_column (
|
||||
id SERIAL PRIMARY KEY,
|
||||
board_id INTEGER NOT NULL REFERENCES kanban_board(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create kanban_card table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE kanban_card (
|
||||
id SERIAL PRIMARY KEY,
|
||||
column_id INTEGER NOT NULL REFERENCES kanban_column(id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
assignee_id INTEGER REFERENCES "user"(id),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
due_date TIMESTAMP WITH TIME ZONE,
|
||||
priority VARCHAR(20) DEFAULT 'medium',
|
||||
labels TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create indexes for better performance
|
||||
conn.execute(text("CREATE INDEX idx_kanban_board_channel_id ON kanban_board(channel_id)"))
|
||||
conn.execute(text("CREATE INDEX idx_kanban_column_board_id ON kanban_column(board_id)"))
|
||||
conn.execute(text("CREATE INDEX idx_kanban_card_column_id ON kanban_card(column_id)"))
|
||||
conn.execute(text("CREATE INDEX idx_kanban_card_assignee_id ON kanban_card(assignee_id)"))
|
||||
|
||||
# Create trigger to update updated_at timestamp
|
||||
conn.execute(text("""
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TRIGGER update_kanban_board_updated_at
|
||||
BEFORE UPDATE ON kanban_board
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TRIGGER update_kanban_column_updated_at
|
||||
BEFORE UPDATE ON kanban_column
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TRIGGER update_kanban_card_updated_at
|
||||
BEFORE UPDATE ON kanban_card
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
"""))
|
||||
|
||||
conn.commit()
|
||||
print("✅ Kanban tables created successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
69
backend/scripts/standardize_kanban_boards.py
Executable file
69
backend/scripts/standardize_kanban_boards.py
Executable file
@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to standardize all Kanban boards with the 4 default columns:
|
||||
- ToDo
|
||||
- In Progress
|
||||
- Waiting
|
||||
- Done
|
||||
|
||||
This script will:
|
||||
1. Find all existing Kanban boards
|
||||
2. Delete all existing columns for each board
|
||||
3. Create the 4 standard columns in the correct order
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from app.database import engine
|
||||
from app.models import KanbanBoard, KanbanColumn
|
||||
|
||||
def standardize_kanban_boards():
|
||||
"""Standardize all Kanban boards with default columns"""
|
||||
with Session(engine) as session:
|
||||
# Get all boards
|
||||
boards = session.exec(select(KanbanBoard)).all()
|
||||
|
||||
print(f"Found {len(boards)} Kanban boards to standardize")
|
||||
|
||||
default_columns = [
|
||||
("ToDo", 0),
|
||||
("In Progress", 1),
|
||||
("Waiting", 2),
|
||||
("Done", 3)
|
||||
]
|
||||
|
||||
for board in boards:
|
||||
print(f"Processing board: {board.name} (ID: {board.id})")
|
||||
|
||||
# Delete all existing columns for this board
|
||||
existing_columns = session.exec(
|
||||
select(KanbanColumn).where(KanbanColumn.board_id == board.id)
|
||||
).all()
|
||||
|
||||
if existing_columns:
|
||||
print(f" Deleting {len(existing_columns)} existing columns")
|
||||
for column in existing_columns:
|
||||
session.delete(column)
|
||||
session.commit()
|
||||
|
||||
# Create the 4 standard columns
|
||||
print(" Creating 4 standard columns")
|
||||
for name, position in default_columns:
|
||||
new_column = KanbanColumn(
|
||||
board_id=board.id,
|
||||
name=name,
|
||||
position=position
|
||||
)
|
||||
session.add(new_column)
|
||||
|
||||
session.commit()
|
||||
print(f" ✓ Board {board.name} standardized")
|
||||
|
||||
print(f"\n✅ Successfully standardized {len(boards)} Kanban boards")
|
||||
print("All boards now have the 4 standard columns: ToDo, In Progress, Waiting, Done")
|
||||
|
||||
if __name__ == "__main__":
|
||||
standardize_kanban_boards()
|
||||
19
frontend-production.sh
Executable file
19
frontend-production.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Team Chat System - Production Frontend Server
|
||||
# Serves the built React app instead of Vite dev server
|
||||
|
||||
PROJECT_DIR="/home/OfficeDesk/frontend"
|
||||
BUILD_DIR="$PROJECT_DIR/dist"
|
||||
|
||||
# Erstelle Build falls nicht vorhanden
|
||||
if [[ ! -d "$BUILD_DIR" ]]; then
|
||||
echo "Building frontend..."
|
||||
cd "$PROJECT_DIR"
|
||||
npm run build
|
||||
fi
|
||||
|
||||
# Serve die statischen Dateien
|
||||
cd "$BUILD_DIR"
|
||||
echo "Serving production build on port 80..."
|
||||
exec serve -s . -l 80
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import React, { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import Login from './components/Auth/Login';
|
||||
import Register from './components/Auth/Register';
|
||||
@ -7,6 +7,7 @@ import ChatView from './components/Chat/ChatView';
|
||||
import SnippetLibrary from './components/Snippets/SnippetLibrary';
|
||||
import AdminPanel from './components/Admin/AdminPanel';
|
||||
import ProfilePage from './components/Profile/ProfilePage';
|
||||
import KanbanBoard from './components/Kanban/KanbanBoard';
|
||||
import Layout from './components/Layout/Layout';
|
||||
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@ -28,29 +29,86 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const AppContent: React.FC = () => {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Speichere den aktuellen Pfad beim Ändern
|
||||
useEffect(() => {
|
||||
// Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register)
|
||||
if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) {
|
||||
// Prüfe Admin-Berechtigung für Admin-Pfade
|
||||
if (location.pathname.startsWith('/admin') && !user?.is_admin) {
|
||||
return; // Speichere keine ungültigen Admin-Pfade
|
||||
}
|
||||
localStorage.setItem('lastVisitedPath', location.pathname + location.search);
|
||||
}
|
||||
}, [location, isAuthenticated, user]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={isAuthenticated ? <Navigate to="/" /> : <Login />} />
|
||||
<Route path="/register" element={isAuthenticated ? <Navigate to="/" /> : <Register />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
<Routes>
|
||||
<Route path="/login" element={isAuthenticated ? <Navigate to="/" /> : <Login />} />
|
||||
<Route path="/register" element={isAuthenticated ? <Navigate to="/" /> : <Register />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<ChatView />} />
|
||||
<Route path="snippets" element={<SnippetLibrary />} />
|
||||
<Route path="kanban" element={<KanbanBoard />} />
|
||||
<Route path="kanban/:channelId" element={<KanbanBoard />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
// Separate Komponente für die Route-Wiederherstellung
|
||||
const RouteRestorer: React.FC = () => {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user && location.pathname === '/') {
|
||||
// Prüfe, ob die Route-Wiederherstellung bereits passiert ist
|
||||
const hasRestoredRoute = sessionStorage.getItem('routeRestored');
|
||||
|
||||
if (!hasRestoredRoute) {
|
||||
const lastVisitedPath = localStorage.getItem('lastVisitedPath');
|
||||
if (lastVisitedPath && lastVisitedPath !== '/') {
|
||||
// Prüfe, ob der Pfad gültig ist
|
||||
const isAdminPath = lastVisitedPath.startsWith('/admin');
|
||||
if (isAdminPath && !user.is_admin) {
|
||||
// Benutzer hat keinen Admin-Zugriff, bleibe auf der Hauptseite
|
||||
localStorage.removeItem('lastVisitedPath');
|
||||
sessionStorage.setItem('routeRestored', 'true');
|
||||
return;
|
||||
}
|
||||
>
|
||||
<Route index element={<ChatView />} />
|
||||
<Route path="snippets" element={<SnippetLibrary />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
// Navigiere zum gespeicherten Pfad
|
||||
navigate(lastVisitedPath, { replace: true });
|
||||
}
|
||||
// Markiere, dass die Route-Wiederherstellung passiert ist
|
||||
sessionStorage.setItem('routeRestored', 'true');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user, navigate, location.pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<RouteRestorer />
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { directMessagesAPI } from '../../services/api';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import type { User } from '../../types';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface DirectMessage {
|
||||
id: number;
|
||||
@ -22,6 +23,7 @@ interface DirectMessageViewProps {
|
||||
}
|
||||
|
||||
const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
|
||||
const { addToast } = useToast();
|
||||
const [messages, setMessages] = useState<DirectMessage[]>([]);
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -91,7 +93,7 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
|
||||
setContent('');
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
alert('Failed to send message');
|
||||
addToast('Failed to send message', 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
@ -93,7 +93,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
|
||||
loadAllUsers();
|
||||
}
|
||||
}}
|
||||
className="p-1 text-indigo-600 dark:text-indigo-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
className="p-1 text-blue-500 dark:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
title="Start new chat"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -114,7 +114,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
|
||||
onClick={() => onSelectUser(user)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
selectedUserId === user.id
|
||||
? 'bg-indigo-100 dark:bg-indigo-900 text-indigo-900 dark:text-indigo-100'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface FileUploadDialogProps {
|
||||
isOpen: boolean;
|
||||
@ -7,6 +8,7 @@ interface FileUploadDialogProps {
|
||||
}
|
||||
|
||||
const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, onUpload }) => {
|
||||
const { addToast } = useToast();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [permission, setPermission] = useState<'read' | 'write'>('read');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@ -30,7 +32,7 @@ const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, on
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
alert('Upload fehlgeschlagen');
|
||||
addToast('Upload fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@ -70,9 +72,9 @@ const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, on
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-indigo-50 file:text-indigo-700
|
||||
hover:file:bg-indigo-100
|
||||
dark:file:bg-indigo-900 dark:file:text-indigo-200"
|
||||
file:bg-blue-50 file:text-blue-700
|
||||
hover:file:bg-blue-100
|
||||
dark:file:bg-blue-900 dark:file:text-blue-200"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
@ -94,7 +96,7 @@ const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, on
|
||||
value="read"
|
||||
checked={permission === 'read'}
|
||||
onChange={(e) => setPermission(e.target.value as 'read' | 'write')}
|
||||
className="w-4 h-4 text-indigo-600 focus:ring-indigo-500"
|
||||
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
@ -113,7 +115,7 @@ const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, on
|
||||
value="write"
|
||||
checked={permission === 'write'}
|
||||
onChange={(e) => setPermission(e.target.value as 'read' | 'write')}
|
||||
className="w-4 h-4 text-indigo-600 focus:ring-indigo-500"
|
||||
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
|
||||
@ -3,6 +3,7 @@ import { messagesAPI, filesAPI } from '../../services/api';
|
||||
import SnippetPicker from '../Snippets/SnippetPicker';
|
||||
import FileUploadDialog from './FileUploadDialog';
|
||||
import type { Snippet } from '../../types';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface MessageInputProps {
|
||||
channelId: number;
|
||||
@ -11,6 +12,7 @@ interface MessageInputProps {
|
||||
}
|
||||
|
||||
const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCancelReply }) => {
|
||||
const { addToast } = useToast();
|
||||
const [content, setContent] = useState('');
|
||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||
const [showSnippetPicker, setShowSnippetPicker] = useState(false);
|
||||
@ -48,7 +50,7 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
|
||||
if (onCancelReply) onCancelReply();
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
alert('Failed to send message');
|
||||
addToast('Failed to send message', 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@ -75,7 +77,7 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
|
||||
if (onCancelReply) onCancelReply();
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
alert('Datei-Upload fehlgeschlagen');
|
||||
addToast('Datei-Upload fehlgeschlagen', 'error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@ import { messagesAPI, filesAPI } from '../../services/api';
|
||||
import type { Message } from '../../types';
|
||||
import CodeBlock from '../common/CodeBlock';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface MessageListProps {
|
||||
channelId: number;
|
||||
@ -11,6 +12,7 @@ interface MessageListProps {
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
const { user } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
@ -156,22 +158,46 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
alert('Download fehlgeschlagen');
|
||||
addToast('Download fehlgeschlagen', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditFile = async (fileId: number, filename: string) => {
|
||||
const handleEditFile = async (fileId: number, _filename: string) => {
|
||||
try {
|
||||
const data = await filesAPI.getOfficeUri(fileId);
|
||||
window.location.href = data.office_uri;
|
||||
} catch (error: any) {
|
||||
console.error('Edit failed:', error);
|
||||
if (error.response?.status === 400) {
|
||||
alert('Dieser Dateityp kann nicht mit Office bearbeitet werden');
|
||||
addToast('Dieser Dateityp kann nicht mit Office bearbeitet werden', 'error');
|
||||
} else if (error.response?.status === 403) {
|
||||
alert('Diese Datei ist schreibgeschützt');
|
||||
addToast('Diese Datei ist schreibgeschützt', 'error');
|
||||
} else {
|
||||
alert('Bearbeiten fehlgeschlagen');
|
||||
addToast('Bearbeiten fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePermission = async (fileId: number, newPermission: 'read' | 'write') => {
|
||||
try {
|
||||
console.log('Changing permission for file:', fileId, 'to:', newPermission);
|
||||
const updatedFile = await filesAPI.updatePermission(fileId, newPermission);
|
||||
console.log('Permission changed successfully:', updatedFile);
|
||||
|
||||
// Reload messages to get updated file permissions from server
|
||||
await loadMessages();
|
||||
|
||||
console.log('Messages reloaded with updated permissions');
|
||||
|
||||
// Give user feedback
|
||||
addToast(`Dateiberechtigung erfolgreich geändert zu "${newPermission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}"`, 'success');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Permission change failed:', error);
|
||||
if (error.response?.status === 403) {
|
||||
addToast('Nur der Uploader oder ein Admin kann die Berechtigung ändern', 'error');
|
||||
} else {
|
||||
addToast('Berechtigung ändern fehlgeschlagen: ' + (error.response?.data?.detail || error.message), 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -182,16 +208,13 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async (messageId: number) => {
|
||||
if (!confirm('Möchten Sie diese Nachricht wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await messagesAPI.delete(messageId);
|
||||
addToast('Nachricht gelöscht', 'success');
|
||||
// Message will be removed via WebSocket broadcast
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
alert('Löschen fehlgeschlagen');
|
||||
addToast('Löschen fehlgeschlagen', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@ -355,7 +378,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement private message
|
||||
alert('Private Nachricht an ' + message.sender_username);
|
||||
addToast('Private Nachricht an ' + message.sender_username, 'info');
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@ -444,14 +467,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
<div className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700 border-t border-gray-300 dark:border-gray-600">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<span className="text-lg">
|
||||
{file.permission === 'write' ? '📝' : '📄'}
|
||||
{file.upload_permission === 'write' ? '📝' : '📄'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-900 dark:text-white truncate">
|
||||
{file.original_filename}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{file.permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
|
||||
{file.upload_permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -472,7 +495,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
onClick={() => setFileMenuId(null)}
|
||||
/>
|
||||
<div className="absolute right-0 bottom-full mb-2 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 w-48">
|
||||
{file.permission === 'write' && isOfficeFile(file.original_filename) && (
|
||||
{file.upload_permission === 'write' && isOfficeFile(file.original_filename) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleEditFile(file.id, file.original_filename);
|
||||
@ -483,6 +506,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
✏️ Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
{file.upload_permission === 'read' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleChangePermission(file.id, 'write');
|
||||
setFileMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Lesen/Schreiben
|
||||
</button>
|
||||
)}
|
||||
{file.upload_permission === 'write' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleChangePermission(file.id, 'read');
|
||||
setFileMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Nur lesen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDownloadFile(file.id, file.original_filename);
|
||||
@ -512,14 +557,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
<div className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700 border-t border-gray-300 dark:border-gray-600">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<span className="text-lg">
|
||||
{file.permission === 'write' ? '📝' : '📄'}
|
||||
{file.upload_permission === 'write' ? '📝' : '📄'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-900 dark:text-white truncate">
|
||||
{file.original_filename}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{file.permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
|
||||
{file.upload_permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -549,7 +594,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
>
|
||||
👁 Öffnen
|
||||
</button>
|
||||
{file.permission === 'write' && isOfficeFile(file.original_filename) && (
|
||||
{file.upload_permission === 'write' && isOfficeFile(file.original_filename) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleEditFile(file.id, file.original_filename);
|
||||
@ -560,6 +605,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
✏️ Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
{file.upload_permission === 'read' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleChangePermission(file.id, 'write');
|
||||
setFileMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Lesen/Schreiben
|
||||
</button>
|
||||
)}
|
||||
{file.upload_permission === 'write' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleChangePermission(file.id, 'read');
|
||||
setFileMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Nur lesen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDownloadFile(file.id, file.original_filename);
|
||||
@ -582,14 +649,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
<div className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<span className="text-lg">
|
||||
{file.permission === 'write' ? '📝' : '📄'}
|
||||
{file.upload_permission === 'write' ? '📝' : '📄'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-900 dark:text-white truncate">
|
||||
{file.original_filename}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{file.permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
|
||||
{file.upload_permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -610,7 +677,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
onClick={() => setFileMenuId(null)}
|
||||
/>
|
||||
<div className="absolute right-0 bottom-full mb-2 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 w-48">
|
||||
{file.permission === 'write' && isOfficeFile(file.original_filename) && (
|
||||
{file.upload_permission === 'write' && isOfficeFile(file.original_filename) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleEditFile(file.id, file.original_filename);
|
||||
@ -621,6 +688,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
||||
✏️ Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
{file.upload_permission === 'read' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleChangePermission(file.id, 'write');
|
||||
setFileMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Lesen/Schreiben
|
||||
</button>
|
||||
)}
|
||||
{file.upload_permission === 'write' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleChangePermission(file.id, 'read');
|
||||
setFileMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Nur lesen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDownloadFile(file.id, file.original_filename);
|
||||
|
||||
334
frontend/src/components/Kanban/KanbanBoard.tsx
Normal file
334
frontend/src/components/Kanban/KanbanBoard.tsx
Normal file
@ -0,0 +1,334 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { kanbanAPI, channelsAPI, departmentsAPI } from '../../services/api';
|
||||
import type { KanbanBoardWithColumns, KanbanColumn, KanbanCard, Channel, Department } from '../../types';
|
||||
import KanbanColumnComponent from './KanbanColumn';
|
||||
import KanbanCardModal from './KanbanCardModal';
|
||||
import KanbanSidebar from './KanbanSidebar';
|
||||
|
||||
const KanbanBoard: React.FC = () => {
|
||||
const { channelId } = useParams<{ channelId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [board, setBoard] = useState<KanbanBoardWithColumns | null>(null);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sidebarLoading, setSidebarLoading] = useState(true);
|
||||
const [selectedCard, setSelectedCard] = useState<KanbanCard | null>(null);
|
||||
const [showCardModal, setShowCardModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSidebarData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (channelId) {
|
||||
loadBoard();
|
||||
} else if (channels.length > 0) {
|
||||
// Wenn keine channelId in der URL, wähle den ersten verfügbaren Channel
|
||||
const firstChannel = channels[0];
|
||||
setSelectedChannel(firstChannel);
|
||||
navigate(`/kanban/${firstChannel.id}`, { replace: true });
|
||||
}
|
||||
}, [channelId, channels]);
|
||||
|
||||
const loadSidebarData = async () => {
|
||||
try {
|
||||
setSidebarLoading(true);
|
||||
const [channelsData, deptsData] = await Promise.all([
|
||||
channelsAPI.getMy(),
|
||||
departmentsAPI.getMy(),
|
||||
]);
|
||||
setChannels(channelsData);
|
||||
setDepartments(deptsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sidebar data:', error);
|
||||
addToast('Fehler beim Laden der Channels', 'error');
|
||||
} finally {
|
||||
setSidebarLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadBoard = async () => {
|
||||
if (!channelId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const boardData = await kanbanAPI.getBoardByChannel(parseInt(channelId));
|
||||
setBoard(boardData);
|
||||
|
||||
// Finde den entsprechenden Channel
|
||||
const channel = channels.find(c => c.id === parseInt(channelId));
|
||||
if (channel) {
|
||||
setSelectedChannel(channel);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
// Board doesn't exist yet, create it with default columns
|
||||
try {
|
||||
await kanbanAPI.createBoard({ channel_id: parseInt(channelId) });
|
||||
|
||||
// Create default columns
|
||||
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
|
||||
for (let i = 0; i < defaultColumns.length; i++) {
|
||||
await kanbanAPI.createColumn({
|
||||
board_id: parseInt(channelId), // This will be the board ID since we just created it
|
||||
name: defaultColumns[i],
|
||||
position: i
|
||||
});
|
||||
}
|
||||
|
||||
const boardData = await kanbanAPI.getBoardByChannel(parseInt(channelId));
|
||||
setBoard(boardData);
|
||||
|
||||
// Finde den entsprechenden Channel
|
||||
const channel = channels.find(c => c.id === parseInt(channelId));
|
||||
if (channel) {
|
||||
setSelectedChannel(channel);
|
||||
}
|
||||
} catch (createError) {
|
||||
addToast('Fehler beim Erstellen des Kanban-Boards', 'error');
|
||||
}
|
||||
} else {
|
||||
addToast('Fehler beim Laden des Kanban-Boards', 'error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChannelSelect = (channel: Channel) => {
|
||||
setSelectedChannel(channel);
|
||||
navigate(`/kanban/${channel.id}`);
|
||||
};
|
||||
|
||||
const handleUpdateColumn = async (columnId: number, updates: Partial<KanbanColumn>) => {
|
||||
try {
|
||||
await kanbanAPI.updateColumn(columnId, updates);
|
||||
setBoard(prev => prev ? {
|
||||
...prev,
|
||||
columns: prev.columns.map(col =>
|
||||
col.id === columnId ? { ...col, ...updates } : col
|
||||
)
|
||||
} : null);
|
||||
} catch (error) {
|
||||
addToast('Fehler beim Aktualisieren der Spalte', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteColumn = async (columnId: number) => {
|
||||
const column = board?.columns.find(col => col.id === columnId);
|
||||
if (!column) return;
|
||||
|
||||
// Prevent deletion of default columns
|
||||
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
|
||||
if (defaultColumns.includes(column.name)) {
|
||||
addToast('Standard-Spalten können nicht gelöscht werden', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Spalte wirklich löschen? Alle Karten darin gehen verloren!')) return;
|
||||
|
||||
try {
|
||||
await kanbanAPI.deleteColumn(columnId);
|
||||
setBoard(prev => prev ? {
|
||||
...prev,
|
||||
columns: prev.columns.filter(col => col.id !== columnId)
|
||||
} : null);
|
||||
addToast('Spalte gelöscht', 'success');
|
||||
} catch (error) {
|
||||
addToast('Fehler beim Löschen der Spalte', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCard = async (columnId: number) => {
|
||||
const cardTitle = prompt('Kartentitel eingeben:');
|
||||
if (!cardTitle?.trim()) return;
|
||||
|
||||
try {
|
||||
const column = board?.columns.find(col => col.id === columnId);
|
||||
if (!column) return;
|
||||
|
||||
const newCard = await kanbanAPI.createCard({
|
||||
column_id: columnId,
|
||||
title: cardTitle,
|
||||
position: column.cards.length
|
||||
});
|
||||
|
||||
setBoard(prev => prev ? {
|
||||
...prev,
|
||||
columns: prev.columns.map(col =>
|
||||
col.id === columnId
|
||||
? { ...col, cards: [...col.cards, newCard] }
|
||||
: col
|
||||
)
|
||||
} : null);
|
||||
|
||||
addToast('Karte erstellt', 'success');
|
||||
} catch (error) {
|
||||
addToast('Fehler beim Erstellen der Karte', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCard = async (cardId: number, updates: Partial<KanbanCard>) => {
|
||||
try {
|
||||
await kanbanAPI.updateCard(cardId, updates);
|
||||
setBoard(prev => prev ? {
|
||||
...prev,
|
||||
columns: prev.columns.map(col => ({
|
||||
...col,
|
||||
cards: col.cards.map(card =>
|
||||
card.id === cardId ? { ...card, ...updates } : card
|
||||
)
|
||||
}))
|
||||
} : null);
|
||||
} catch (error) {
|
||||
addToast('Fehler beim Aktualisieren der Karte', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCard = async (cardId: number) => {
|
||||
if (!confirm('Karte wirklich löschen?')) return;
|
||||
|
||||
try {
|
||||
await kanbanAPI.deleteCard(cardId);
|
||||
setBoard(prev => prev ? {
|
||||
...prev,
|
||||
columns: prev.columns.map(col => ({
|
||||
...col,
|
||||
cards: col.cards.filter(card => card.id !== cardId)
|
||||
}))
|
||||
} : null);
|
||||
addToast('Karte gelöscht', 'success');
|
||||
} catch (error) {
|
||||
addToast('Fehler beim Löschen der Karte', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCard = async (cardId: number, targetColumnId: number, newPosition: number) => {
|
||||
try {
|
||||
await kanbanAPI.moveCard(cardId, targetColumnId, newPosition);
|
||||
|
||||
setBoard(prev => {
|
||||
if (!prev) return null;
|
||||
|
||||
const sourceColumn = prev.columns.find(col =>
|
||||
col.cards.some(card => card.id === cardId)
|
||||
);
|
||||
if (!sourceColumn) return prev;
|
||||
|
||||
const card = sourceColumn.cards.find(c => c.id === cardId);
|
||||
if (!card) return prev;
|
||||
|
||||
// Remove card from source column
|
||||
const updatedColumns = prev.columns.map(col => ({
|
||||
...col,
|
||||
cards: col.cards.filter(c => c.id !== cardId)
|
||||
}));
|
||||
|
||||
// Add card to target column
|
||||
const finalColumns = updatedColumns.map(col =>
|
||||
col.id === targetColumnId
|
||||
? {
|
||||
...col,
|
||||
cards: [
|
||||
...col.cards.slice(0, newPosition),
|
||||
card,
|
||||
...col.cards.slice(newPosition)
|
||||
]
|
||||
}
|
||||
: col
|
||||
);
|
||||
|
||||
return { ...prev, columns: finalColumns };
|
||||
});
|
||||
} catch (error) {
|
||||
addToast('Fehler beim Verschieben der Karte', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = (card: KanbanCard) => {
|
||||
setSelectedCard(card);
|
||||
setShowCardModal(true);
|
||||
};
|
||||
|
||||
if (sidebarLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
Lade Kanban...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
{/* Sidebar */}
|
||||
<KanbanSidebar
|
||||
channels={channels}
|
||||
departments={departments}
|
||||
selectedChannel={selectedChannel}
|
||||
onSelectChannel={handleChannelSelect}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
Lade Board...
|
||||
</div>
|
||||
) : !board ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
Kanban-Board nicht gefunden
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{board.name}
|
||||
</h1>
|
||||
{selectedChannel && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
#{selectedChannel.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{board.columns.map((column) => (
|
||||
<KanbanColumnComponent
|
||||
key={column.id}
|
||||
column={column}
|
||||
onUpdateColumn={handleUpdateColumn}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onCreateCard={handleCreateCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onMoveCard={handleMoveCard}
|
||||
onCardClick={handleCardClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCardModal && selectedCard && (
|
||||
<KanbanCardModal
|
||||
card={selectedCard}
|
||||
onClose={() => {
|
||||
setShowCardModal(false);
|
||||
setSelectedCard(null);
|
||||
}}
|
||||
onUpdate={handleUpdateCard}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanBoard;
|
||||
142
frontend/src/components/Kanban/KanbanCard.tsx
Normal file
142
frontend/src/components/Kanban/KanbanCard.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import type { KanbanCard } from '../../types';
|
||||
|
||||
interface KanbanCardProps {
|
||||
card: KanbanCard;
|
||||
onClick: () => void;
|
||||
onDelete: (cardId: number) => void;
|
||||
sourceColumnId: number;
|
||||
}
|
||||
|
||||
const KanbanCard: React.FC<KanbanCardProps> = ({
|
||||
card,
|
||||
onClick,
|
||||
onDelete,
|
||||
sourceColumnId
|
||||
}) => {
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setData('cardId', card.id.toString());
|
||||
e.dataTransfer.setData('sourceColumnId', sourceColumnId.toString());
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority?: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const isOverdue = (dueDate?: string) => {
|
||||
if (!dueDate) return false;
|
||||
return new Date(dueDate) < new Date();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-3 cursor-pointer hover:shadow-sm transition-shadow group relative"
|
||||
onClick={onClick}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
>
|
||||
{/* Title */}
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-1.5 line-clamp-2">
|
||||
{card.title}
|
||||
</h4>
|
||||
|
||||
{/* Description preview */}
|
||||
{card.description && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-1.5 line-clamp-2">
|
||||
{card.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Priority and Due Date */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{card.priority && (
|
||||
<span className={`px-1.5 py-0.5 text-xs rounded border ${getPriorityColor(card.priority)}`}>
|
||||
{card.priority === 'high' ? 'Hoch' :
|
||||
card.priority === 'medium' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{card.due_date && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
isOverdue(card.due_date)
|
||||
? 'bg-red-100 text-red-700 border border-red-200'
|
||||
: 'bg-blue-100 text-blue-700 border border-blue-200'
|
||||
}`}>
|
||||
{formatDate(card.due_date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
{card.assignee && (
|
||||
<div className="flex items-center">
|
||||
{card.assignee.profile_picture ? (
|
||||
<img
|
||||
src={`http://localhost:8000/${card.assignee.profile_picture}`}
|
||||
alt={card.assignee.username}
|
||||
className="w-5 h-5 rounded-full object-cover"
|
||||
title={card.assignee.username}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold"
|
||||
title={card.assignee.username}
|
||||
>
|
||||
{card.assignee.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{card.labels && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{card.labels.split(',').map((label, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-1.5 py-0.5 text-xs bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded"
|
||||
>
|
||||
{label.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(card.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Karte löschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanCard;
|
||||
487
frontend/src/components/Kanban/KanbanCardModal.tsx
Normal file
487
frontend/src/components/Kanban/KanbanCardModal.tsx
Normal file
@ -0,0 +1,487 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { departmentsAPI, kanbanAPI } from '../../services/api';
|
||||
import type { KanbanCard, User, Department, KanbanChecklistWithItems } from '../../types';
|
||||
|
||||
const AddChecklistItemForm: React.FC<{ checklistId: number; onAdd: (checklistId: number, title: string) => void }> = ({ checklistId, onAdd }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (title.trim()) {
|
||||
onAdd(checklistId, title);
|
||||
setTitle('');
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setTitle('');
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdding) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Aufgabe hinzufügen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Aufgaben-Titel eingeben..."
|
||||
className="flex-1 p-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTitle('');
|
||||
setIsAdding(false);
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-gray-500 text-white rounded hover:bg-gray-600"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface KanbanCardModalProps {
|
||||
card: KanbanCard;
|
||||
onClose: () => void;
|
||||
onUpdate: (cardId: number, updates: Partial<KanbanCard>) => void;
|
||||
}
|
||||
|
||||
const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
|
||||
card,
|
||||
onClose,
|
||||
onUpdate
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [title, setTitle] = useState(card.title);
|
||||
const [description, setDescription] = useState(card.description || '');
|
||||
const [assigneeId, setAssigneeId] = useState<number | undefined>(card.assignee_id);
|
||||
const [dueDate, setDueDate] = useState(card.due_date ? card.due_date.split('T')[0] : '');
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>(card.priority || 'medium');
|
||||
const [labels, setLabels] = useState(card.labels || '');
|
||||
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
||||
const [checklists, setChecklists] = useState<KanbanChecklistWithItems[]>([]);
|
||||
const [showChecklistForm, setShowChecklistForm] = useState(false);
|
||||
const [newChecklistTitle, setNewChecklistTitle] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadAvailableUsers();
|
||||
}, []);
|
||||
|
||||
const loadAvailableUsers = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// Get all departments the user has access to
|
||||
const departments: Department[] = await departmentsAPI.getMy();
|
||||
|
||||
// Collect all users from these departments
|
||||
const userSet = new Map<number, User>();
|
||||
|
||||
for (const _dept of departments) {
|
||||
// This is a simplified approach - in a real app you'd have an endpoint to get department users
|
||||
// For now, we'll just include the current user and maybe add more logic later
|
||||
if (user) {
|
||||
userSet.set(user.id, user);
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableUsers(Array.from(userSet.values()));
|
||||
} catch (error) {
|
||||
console.error('Failed to load available users:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-save functions for individual fields
|
||||
const autoSaveTitle = () => {
|
||||
if (title.trim() !== card.title) {
|
||||
onUpdate(card.id, { title: title.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
const autoSaveDescription = () => {
|
||||
const desc = description.trim() || undefined;
|
||||
if (desc !== (card.description || undefined)) {
|
||||
onUpdate(card.id, { description: desc });
|
||||
}
|
||||
};
|
||||
|
||||
const autoSaveAssignee = () => {
|
||||
if (assigneeId !== card.assignee_id) {
|
||||
onUpdate(card.id, { assignee_id: assigneeId });
|
||||
}
|
||||
};
|
||||
|
||||
const autoSaveDueDate = () => {
|
||||
const date = dueDate ? new Date(dueDate).toISOString() : undefined;
|
||||
if (date !== card.due_date) {
|
||||
onUpdate(card.id, { due_date: date });
|
||||
}
|
||||
};
|
||||
|
||||
const autoSavePriority = () => {
|
||||
if (priority !== (card.priority || 'medium')) {
|
||||
onUpdate(card.id, { priority });
|
||||
}
|
||||
};
|
||||
|
||||
const autoSaveLabels = () => {
|
||||
const lbls = labels.trim() || undefined;
|
||||
if (lbls !== (card.labels || undefined)) {
|
||||
onUpdate(card.id, { labels: lbls });
|
||||
}
|
||||
};
|
||||
|
||||
// Checklist functions
|
||||
const loadChecklists = async () => {
|
||||
try {
|
||||
const cardChecklists = await kanbanAPI.getCardChecklists(card.id);
|
||||
setChecklists(cardChecklists);
|
||||
} catch (error) {
|
||||
console.error('Failed to load checklists:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChecklist = async () => {
|
||||
if (!newChecklistTitle.trim()) return;
|
||||
|
||||
try {
|
||||
const newChecklist = await kanbanAPI.createChecklist({
|
||||
card_id: card.id,
|
||||
title: newChecklistTitle.trim(),
|
||||
position: checklists.length
|
||||
});
|
||||
|
||||
setChecklists(prev => [...prev, { ...newChecklist, items: [] }]);
|
||||
setNewChecklistTitle('');
|
||||
setShowChecklistForm(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create checklist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChecklist = async (checklistId: number) => {
|
||||
if (!confirm('Checkliste wirklich löschen?')) return;
|
||||
|
||||
try {
|
||||
await kanbanAPI.deleteChecklist(checklistId);
|
||||
setChecklists(prev => prev.filter(c => c.id !== checklistId));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete checklist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChecklistItem = async (checklistId: number, title: string) => {
|
||||
try {
|
||||
const checklist = checklists.find(c => c.id === checklistId);
|
||||
if (!checklist) return;
|
||||
|
||||
const newItem = await kanbanAPI.createChecklistItem({
|
||||
checklist_id: checklistId,
|
||||
title: title.trim(),
|
||||
position: checklist.items.length
|
||||
});
|
||||
|
||||
setChecklists(prev => prev.map(c =>
|
||||
c.id === checklistId
|
||||
? { ...c, items: [...c.items, newItem] }
|
||||
: c
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Failed to create checklist item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleChecklistItem = async (itemId: number, completed: boolean) => {
|
||||
try {
|
||||
await kanbanAPI.updateChecklistItem(itemId, { is_completed: completed });
|
||||
|
||||
setChecklists(prev => prev.map(checklist => ({
|
||||
...checklist,
|
||||
items: checklist.items.map(item =>
|
||||
item.id === itemId ? { ...item, is_completed: completed } : item
|
||||
)
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Failed to update checklist item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChecklistItem = async (itemId: number) => {
|
||||
try {
|
||||
await kanbanAPI.deleteChecklistItem(itemId);
|
||||
|
||||
setChecklists(prev => prev.map(checklist => ({
|
||||
...checklist,
|
||||
items: checklist.items.filter(item => item.id !== itemId)
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete checklist item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAvailableUsers();
|
||||
loadChecklists();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={autoSaveTitle}
|
||||
className="text-xl font-bold text-gray-900 dark:text-white bg-transparent border-b border-transparent hover:border-gray-300 dark:hover:border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Kartentitel eingeben..."
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 p-1"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onBlur={autoSaveDescription}
|
||||
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
rows={4}
|
||||
placeholder="Beschreibung hinzufügen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Priorität
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => {
|
||||
setPriority(e.target.value as 'low' | 'medium' | 'high');
|
||||
autoSavePriority();
|
||||
}}
|
||||
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Fälligkeitsdatum
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
onBlur={autoSaveDueDate}
|
||||
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Zugewiesen an
|
||||
</label>
|
||||
<select
|
||||
value={assigneeId || ''}
|
||||
onChange={(e) => {
|
||||
setAssigneeId(e.target.value ? parseInt(e.target.value) : undefined);
|
||||
autoSaveAssignee();
|
||||
}}
|
||||
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="">Nicht zugewiesen</option>
|
||||
{availableUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.username} {user.full_name && `(${user.full_name})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Labels (kommagetrennt)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={labels}
|
||||
onChange={(e) => setLabels(e.target.value)}
|
||||
onBlur={autoSaveLabels}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="z.B. bug, feature, urgent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Checklists */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Checklisten
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowChecklistForm(true)}
|
||||
className="px-3 py-1 text-sm bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
+ Checkliste
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Checklist Form */}
|
||||
{showChecklistForm && (
|
||||
<div className="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newChecklistTitle}
|
||||
onChange={(e) => setNewChecklistTitle(e.target.value)}
|
||||
placeholder="Checklisten-Titel eingeben..."
|
||||
className="flex-1 p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreateChecklist()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateChecklist}
|
||||
className="px-3 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowChecklistForm(false);
|
||||
setNewChecklistTitle('');
|
||||
}}
|
||||
className="px-3 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checklists */}
|
||||
<div className="space-y-4">
|
||||
{checklists.map((checklist) => (
|
||||
<div key={checklist.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{checklist.title}</h4>
|
||||
<button
|
||||
onClick={() => handleDeleteChecklist(checklist.id)}
|
||||
className="text-gray-400 hover:text-red-500 p-1"
|
||||
title="Checkliste löschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Checklist Items */}
|
||||
<div className="space-y-2">
|
||||
{checklist.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.is_completed}
|
||||
onChange={(e) => handleToggleChecklistItem(item.id, e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span className={`flex-1 text-sm ${item.is_completed ? 'line-through text-gray-500' : 'text-gray-900 dark:text-white'}`}>
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteChecklistItem(item.id)}
|
||||
className="text-gray-400 hover:text-red-500 p-1"
|
||||
title="Aufgabe löschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add new item */}
|
||||
<AddChecklistItemForm checklistId={checklist.id} onAdd={handleCreateChecklistItem} />
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mt-3 text-xs text-gray-500">
|
||||
{checklist.items.filter(item => item.is_completed).length} von {checklist.items.length} Aufgaben erledigt
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{checklists.length === 0 && !showChecklistForm && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Keine Checklisten vorhanden. Klicke auf "+ Checkliste" um eine hinzuzufügen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
Erstellt: {new Date(card.created_at).toLocaleString('de-DE')}
|
||||
{card.updated_at !== card.created_at && (
|
||||
<span className="ml-4">
|
||||
Aktualisiert: {new Date(card.updated_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanCardModal;
|
||||
176
frontend/src/components/Kanban/KanbanColumn.tsx
Normal file
176
frontend/src/components/Kanban/KanbanColumn.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { KanbanColumnWithCards, KanbanCard } from '../../types';
|
||||
import KanbanCardComponent from './KanbanCard';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: KanbanColumnWithCards;
|
||||
onUpdateColumn: (columnId: number, updates: Partial<KanbanColumnWithCards>) => void;
|
||||
onDeleteColumn: (columnId: number) => void;
|
||||
onCreateCard: (columnId: number) => void;
|
||||
onDeleteCard: (cardId: number) => void;
|
||||
onMoveCard: (cardId: number, targetColumnId: number, newPosition: number) => void;
|
||||
onCardClick: (card: KanbanCard) => void;
|
||||
}
|
||||
|
||||
const KanbanColumn: React.FC<KanbanColumnProps> = ({
|
||||
column,
|
||||
onUpdateColumn,
|
||||
onDeleteColumn,
|
||||
onCreateCard,
|
||||
onDeleteCard,
|
||||
onMoveCard,
|
||||
onCardClick
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(column.name);
|
||||
const [draggedOver, setDraggedOver] = useState(false);
|
||||
|
||||
const handleSaveName = () => {
|
||||
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
|
||||
if (defaultColumns.includes(column.name)) {
|
||||
// Prevent renaming default columns
|
||||
setEditName(column.name);
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editName.trim() && editName !== column.name) {
|
||||
onUpdateColumn(column.id, { name: editName.trim() });
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveName();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditName(column.name);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggedOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDraggedOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggedOver(false);
|
||||
|
||||
const cardId = parseInt(e.dataTransfer.getData('cardId'));
|
||||
const sourceColumnId = parseInt(e.dataTransfer.getData('sourceColumnId'));
|
||||
|
||||
if (sourceColumnId === column.id) return; // Same column, ignore
|
||||
|
||||
// Find the position to drop at
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const cardHeight = 120; // Approximate card height
|
||||
const position = Math.floor(y / cardHeight);
|
||||
|
||||
onMoveCard(cardId, column.id, Math.min(position, column.cards.length));
|
||||
};
|
||||
|
||||
const getColumnColor = () => {
|
||||
if (column.color) {
|
||||
return column.color;
|
||||
}
|
||||
// Default colors based on position
|
||||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'];
|
||||
return colors[column.position % colors.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-shrink-0 w-72 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-3 ${
|
||||
draggedOver ? 'ring-1 ring-blue-500 bg-blue-50 dark:bg-blue-900/10' : ''
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: getColumnColor() }}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={handleSaveName}
|
||||
onKeyDown={handleKeyPress}
|
||||
className="px-2 py-1 text-sm font-medium bg-white dark:bg-gray-700 border rounded"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className={`text-sm font-medium text-gray-900 dark:text-white ${
|
||||
(() => {
|
||||
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
|
||||
return !defaultColumns.includes(column.name) ? 'cursor-pointer hover:text-blue-600' : '';
|
||||
})()
|
||||
}`}
|
||||
onClick={() => {
|
||||
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
|
||||
if (!defaultColumns.includes(column.name)) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.name}
|
||||
</h3>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded">
|
||||
{column.cards.length}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
|
||||
return !defaultColumns.includes(column.name) && (
|
||||
<button
|
||||
onClick={() => onDeleteColumn(column.id)}
|
||||
className="text-gray-400 hover:text-red-500 p-1"
|
||||
title="Spalte löschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-2 min-h-[150px]">
|
||||
{column.cards.map((card) => (
|
||||
<KanbanCardComponent
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={() => onCardClick(card)}
|
||||
onDelete={onDeleteCard}
|
||||
sourceColumnId={column.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Card Button */}
|
||||
<button
|
||||
onClick={() => onCreateCard(column.id)}
|
||||
className="w-full mt-2 p-2 border border-dashed border-gray-300 dark:border-gray-600 rounded text-gray-500 hover:border-gray-400 dark:hover:border-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition-colors text-sm"
|
||||
>
|
||||
+ Karte hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanColumn;
|
||||
80
frontend/src/components/Kanban/KanbanSidebar.tsx
Normal file
80
frontend/src/components/Kanban/KanbanSidebar.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import type { Channel, Department } from '../../types';
|
||||
|
||||
interface KanbanSidebarProps {
|
||||
channels: Channel[];
|
||||
departments: Department[];
|
||||
selectedChannel: Channel | null;
|
||||
onSelectChannel: (channel: Channel) => void;
|
||||
}
|
||||
|
||||
const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
|
||||
channels,
|
||||
departments,
|
||||
selectedChannel,
|
||||
onSelectChannel,
|
||||
}) => {
|
||||
// Group channels by department
|
||||
const channelsByDept = channels.reduce((acc, channel) => {
|
||||
if (!acc[channel.department_id]) {
|
||||
acc[channel.department_id] = [];
|
||||
}
|
||||
acc[channel.department_id].push(channel);
|
||||
return acc;
|
||||
}, {} as Record<number, Channel[]>);
|
||||
|
||||
return (
|
||||
<div className="w-52 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Kanban Boards</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{departments.map((dept) => (
|
||||
<div key={dept.id} className="mb-3">
|
||||
<div className="px-3 py-1.5 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
{dept.name}
|
||||
</div>
|
||||
|
||||
{channelsByDept[dept.id]?.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => onSelectChannel(channel)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||||
selectedChannel?.id === channel.id
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100 border-r-2 border-blue-500'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-gray-500 dark:text-gray-400 mr-2">#</span>
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</div>
|
||||
{channel.description && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{channel.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{channels.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
Keine Channels verfügbar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Wählen Sie einen Channel für das Kanban-Board
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanSidebar;
|
||||
@ -4,6 +4,7 @@ import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { departmentsAPI } from '../../services/api';
|
||||
import type { Department } from '../../types';
|
||||
import ToastContainer from '../ToastContainer';
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
@ -109,6 +110,16 @@ const Layout: React.FC = () => {
|
||||
Snippets
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/kanban"
|
||||
className={`px-3 py-1.5 text-sm rounded ${
|
||||
location.pathname.startsWith('/kanban')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Kanban
|
||||
</Link>
|
||||
{user?.is_admin && (
|
||||
<Link
|
||||
to="/admin"
|
||||
@ -118,13 +129,15 @@ const Layout: React.FC = () => {
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
🔧 Admin
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2.5 relative user-menu-container">
|
||||
<div className="flex items-center space-x-3">
|
||||
<ToastContainer />
|
||||
<div className="flex items-center space-x-2.5 relative user-menu-container">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex items-center space-x-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@ -178,6 +191,7 @@ const Layout: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { snippetsAPI, departmentsAPI } from '../../services/api';
|
||||
import type { Snippet, Department } from '../../types';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
const LANGUAGE_SUGGESTIONS = [
|
||||
'javascript',
|
||||
@ -24,6 +25,7 @@ interface SnippetEditorProps {
|
||||
}
|
||||
|
||||
const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel }) => {
|
||||
const { addToast } = useToast();
|
||||
const [title, setTitle] = useState('');
|
||||
const [language, setLanguage] = useState('python');
|
||||
const [content, setContent] = useState('');
|
||||
@ -87,7 +89,7 @@ const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel
|
||||
onSave();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save snippet:', error);
|
||||
alert(error.response?.data?.detail || 'Failed to save snippet');
|
||||
addToast(error.response?.data?.detail || 'Failed to save snippet');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { snippetsAPI, departmentsAPI } from '../../services/api';
|
||||
import type { Snippet, Department } from '../../types';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
@ -42,6 +43,7 @@ interface SnippetEditorProps {
|
||||
}
|
||||
|
||||
const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel }) => {
|
||||
const { addToast } = useToast();
|
||||
const [title, setTitle] = useState('');
|
||||
const [language, setLanguage] = useState('python');
|
||||
const [content, setContent] = useState('');
|
||||
@ -105,7 +107,7 @@ const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel
|
||||
onSave();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save snippet:', error);
|
||||
alert(error.response?.data?.detail || 'Failed to save snippet');
|
||||
addToast(error.response?.data?.detail || 'Failed to save snippet', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@ import { snippetsAPI } from '../../services/api';
|
||||
import type { Snippet } from '../../types';
|
||||
import SnippetEditor from './SnippetEditor';
|
||||
import SnippetViewer from './SnippetViewer';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
const SnippetLibrary: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [filteredSnippets, setFilteredSnippets] = useState<Snippet[]>([]);
|
||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||
@ -83,7 +85,7 @@ const SnippetLibrary: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete snippet:', error);
|
||||
alert('Failed to delete snippet');
|
||||
addToast('Failed to delete snippet');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -3,8 +3,10 @@ import { snippetsAPI } from '../../services/api';
|
||||
import type { Snippet } from '../../types';
|
||||
import SnippetEditor from './SnippetEditor';
|
||||
import SnippetViewer from './SnippetViewer';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
const SnippetLibrary: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [filteredSnippets, setFilteredSnippets] = useState<Snippet[]>([]);
|
||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||
@ -83,7 +85,7 @@ const SnippetLibrary: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete snippet:', error);
|
||||
alert('Failed to delete snippet');
|
||||
addToast('Failed to delete snippet', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import type { Snippet } from '../../types';
|
||||
import CodeBlock from '../common/CodeBlock';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface SnippetViewerProps {
|
||||
snippet: Snippet;
|
||||
@ -10,12 +11,13 @@ interface SnippetViewerProps {
|
||||
}
|
||||
|
||||
const SnippetViewer: React.FC<SnippetViewerProps> = ({ snippet, onEdit, onDelete }) => {
|
||||
const { addToast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const isOwner = user?.id === snippet.owner_id;
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(snippet.content);
|
||||
alert('Copied to clipboard!');
|
||||
addToast('Copied to clipboard!');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import type { Snippet } from '../../types';
|
||||
import CodeBlock from '../common/CodeBlock';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface SnippetViewerProps {
|
||||
snippet: Snippet;
|
||||
@ -11,11 +12,12 @@ interface SnippetViewerProps {
|
||||
|
||||
const SnippetViewer: React.FC<SnippetViewerProps> = ({ snippet, onEdit, onDelete }) => {
|
||||
const { user } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const isOwner = user?.id === snippet.owner_id;
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(snippet.content);
|
||||
alert('Copied to clipboard!');
|
||||
addToast('Copied to clipboard!', 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
81
frontend/src/components/ToastContainer.tsx
Normal file
81
frontend/src/components/ToastContainer.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useToast, type Toast } from '../contexts/ToastContext';
|
||||
|
||||
const ToastItem: React.FC<{ toast: Toast }> = ({ toast }) => {
|
||||
const { removeToast } = useToast();
|
||||
const [progress, setProgress] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration && toast.duration > 0) {
|
||||
const interval = 100; // Update every 100ms
|
||||
const decrement = (interval / toast.duration) * 100;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
const newProgress = prev - decrement;
|
||||
if (newProgress <= 0) {
|
||||
removeToast(toast.id);
|
||||
return 0;
|
||||
}
|
||||
return newProgress;
|
||||
});
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [toast.duration, toast.id, removeToast]);
|
||||
|
||||
const getToastStyles = (type: Toast['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-500/60 text-white border-green-600/60';
|
||||
case 'error':
|
||||
return 'bg-red-500/60 text-white border-red-600/60';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500/60 text-white border-yellow-600/60';
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-blue-500/60 text-white border-blue-600/60';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative flex items-center justify-between p-2 rounded-lg border-l-4 shadow-lg mb-2 w-96 ${getToastStyles(toast.type)}`}>
|
||||
<span className="text-xs font-medium flex-1">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="ml-2 text-white hover:text-gray-200 focus:outline-none flex-shrink-0"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Progress Bar */}
|
||||
{toast.duration && toast.duration > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-white/20 rounded-b-lg overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-white/60 transition-all duration-100 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToastContainer: React.FC = () => {
|
||||
const { toasts } = useToast();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
// Show only the most recent toast to keep header compact
|
||||
const recentToast = toasts[toasts.length - 1];
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<ToastItem key={recentToast.id} toast={recentToast} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContainer;
|
||||
@ -55,6 +55,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('lastVisitedPath');
|
||||
sessionStorage.removeItem('routeRestored');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
54
frontend/src/contexts/ToastContext.tsx
Normal file
54
frontend/src/contexts/ToastContext.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: Toast['type'], duration?: number) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = (message: string, type: Toast['type'] = 'info', duration = 2000) => {
|
||||
const id = Date.now().toString();
|
||||
const toast: Toast = { id, message, type, duration };
|
||||
setToasts(prev => [...prev, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -4,12 +4,15 @@ import App from './App';
|
||||
import './index.css';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>
|
||||
|
||||
@ -40,7 +40,7 @@ export const authAPI = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProfile: async (data: { email?: string; full_name?: string; password?: string }): Promise<User> => {
|
||||
updateProfile: async (data: { email?: string; full_name?: string; password?: string; theme?: string }): Promise<User> => {
|
||||
const response = await api.put('/auth/me', data);
|
||||
return response.data;
|
||||
},
|
||||
@ -150,6 +150,17 @@ export const filesAPI = {
|
||||
const response = await api.get(`/files/office-uri/${fileId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePermission: async (fileId: number, permission: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('permission', permission);
|
||||
const response = await api.put(`/files/${fileId}/permission`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const snippetsAPI = {
|
||||
@ -232,4 +243,120 @@ export const directMessagesAPI = {
|
||||
},
|
||||
};
|
||||
|
||||
export const kanbanAPI = {
|
||||
// Board operations
|
||||
createBoard: async (data: { channel_id: number; name?: string }) => {
|
||||
const response = await api.post('/kanban/boards', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getBoardByChannel: async (channelId: number) => {
|
||||
const response = await api.get(`/kanban/boards/${channelId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateBoard: async (boardId: number, data: { name?: string }) => {
|
||||
const response = await api.put(`/kanban/boards/${boardId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Column operations
|
||||
createColumn: async (data: { board_id: number; name: string; position: number; color?: string }) => {
|
||||
const response = await api.post('/kanban/columns', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateColumn: async (columnId: number, data: { name?: string; position?: number; color?: string }) => {
|
||||
const response = await api.put(`/kanban/columns/${columnId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteColumn: async (columnId: number) => {
|
||||
const response = await api.delete(`/kanban/columns/${columnId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Card operations
|
||||
createCard: async (data: {
|
||||
column_id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
assignee_id?: number;
|
||||
position: number;
|
||||
due_date?: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
labels?: string;
|
||||
}) => {
|
||||
const response = await api.post('/kanban/cards', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateCard: async (cardId: number, data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
assignee_id?: number;
|
||||
position?: number;
|
||||
due_date?: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
labels?: string;
|
||||
}) => {
|
||||
const response = await api.put(`/kanban/cards/${cardId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteCard: async (cardId: number) => {
|
||||
const response = await api.delete(`/kanban/cards/${cardId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
moveCard: async (cardId: number, targetColumnId: number, newPosition: number) => {
|
||||
const response = await api.put(`/kanban/cards/${cardId}/move`, null, {
|
||||
params: { target_column_id: targetColumnId, new_position: newPosition }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Checklist API
|
||||
createChecklist: async (data: { card_id: number; title: string; position: number }) => {
|
||||
const response = await api.post('/kanban/checklists', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCardChecklists: async (cardId: number) => {
|
||||
const response = await api.get(`/kanban/cards/${cardId}/checklists`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getChecklist: async (checklistId: number) => {
|
||||
const response = await api.get(`/kanban/checklists/${checklistId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateChecklist: async (checklistId: number, data: { title?: string; position?: number }) => {
|
||||
const response = await api.put(`/kanban/checklists/${checklistId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteChecklist: async (checklistId: number) => {
|
||||
const response = await api.delete(`/kanban/checklists/${checklistId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Checklist Item API
|
||||
createChecklistItem: async (data: { checklist_id: number; title: string; is_completed?: boolean; position: number }) => {
|
||||
const response = await api.post('/kanban/checklist-items', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateChecklistItem: async (itemId: number, data: { title?: string; is_completed?: boolean; position?: number }) => {
|
||||
const response = await api.put(`/kanban/checklist-items/${itemId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteChecklistItem: async (itemId: number) => {
|
||||
const response = await api.delete(`/kanban/checklist-items/${itemId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@ -56,9 +56,10 @@ export interface FileAttachment {
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
uploaded_at: string;
|
||||
permission: 'read' | 'write';
|
||||
message_id: number;
|
||||
upload_permission: 'read' | 'write';
|
||||
uploader_id?: number;
|
||||
can_edit?: boolean;
|
||||
is_editable?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
@ -124,3 +125,73 @@ export interface TranslationGroup {
|
||||
entries: TranslationEntry[];
|
||||
}
|
||||
|
||||
// Kanban Types
|
||||
export interface KanbanBoard {
|
||||
id: number;
|
||||
channel_id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KanbanColumn {
|
||||
id: number;
|
||||
board_id: number;
|
||||
name: string;
|
||||
position: number;
|
||||
color?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KanbanCard {
|
||||
id: number;
|
||||
column_id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
assignee_id?: number;
|
||||
position: number;
|
||||
due_date?: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
labels?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
assignee?: User;
|
||||
}
|
||||
|
||||
export interface KanbanBoardWithColumns extends KanbanBoard {
|
||||
columns: KanbanColumnWithCards[];
|
||||
}
|
||||
|
||||
export interface KanbanColumnWithCards extends KanbanColumn {
|
||||
cards: KanbanCard[];
|
||||
}
|
||||
|
||||
// Checklist Types
|
||||
export interface KanbanChecklist {
|
||||
id: number;
|
||||
card_id: number;
|
||||
title: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KanbanChecklistItem {
|
||||
id: number;
|
||||
checklist_id: number;
|
||||
title: string;
|
||||
is_completed: boolean;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KanbanChecklistWithItems extends KanbanChecklist {
|
||||
items: KanbanChecklistItem[];
|
||||
}
|
||||
|
||||
export interface KanbanCardWithChecklists extends KanbanCard {
|
||||
checklists: KanbanChecklistWithItems[];
|
||||
}
|
||||
|
||||
|
||||
10
frontend/src/vite-env.d.ts
vendored
Normal file
10
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
readonly VITE_WS_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@ -1,9 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
|
||||
@ -103,12 +103,12 @@ start_frontend() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Starte Frontend-Server..."
|
||||
log "Starte Frontend-Server (Production)..."
|
||||
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
# Starte Frontend im Hintergrund
|
||||
nohup npm run dev > "$FRONTEND_LOG" 2>&1 &
|
||||
# Starte Production Server anstatt dev server
|
||||
/home/OfficeDesk/frontend-production.sh > "$FRONTEND_LOG" 2>&1 &
|
||||
local frontend_pid=$!
|
||||
|
||||
# Warte kurz und prüfe ob der Prozess gestartet ist
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user