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:
DGSoft 2025-12-10 23:17:07 +01:00
parent 5966b9b3f3
commit a7ff948e7e
39 changed files with 3216 additions and 82 deletions

View File

@ -4,7 +4,7 @@ from fastapi.staticfiles import StaticFiles
from pathlib import Path from pathlib import Path
from app.database import create_db_and_tables from app.database import create_db_and_tables
from app.config import get_settings 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() settings = get_settings()
@ -53,6 +53,7 @@ app.include_router(messages.router)
app.include_router(direct_messages.router) app.include_router(direct_messages.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(snippets.router) app.include_router(snippets.router)
app.include_router(kanban.router)
app.include_router(websocket.router) app.include_router(websocket.router)

View File

@ -104,6 +104,7 @@ class Channel(SQLModel, table=True):
# Relationships # Relationships
department: Department = Relationship(back_populates="channels") department: Department = Relationship(back_populates="channels")
messages: List["Message"] = Relationship(back_populates="channel") messages: List["Message"] = Relationship(back_populates="channel")
kanban_board: Optional["KanbanBoard"] = Relationship(back_populates="channel")
class Message(SQLModel, table=True): class Message(SQLModel, table=True):
@ -199,3 +200,85 @@ class Snippet(SQLModel, table=True):
owner: User = Relationship(back_populates="snippets") owner: User = Relationship(back_populates="snippets")
department: Optional[Department] = Relationship() department: Optional[Department] = Relationship()
allowed_departments: List["Department"] = Relationship(link_model=SnippetDepartmentLink) 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")

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
from app.database import get_session 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.schemas import ChannelCreate, ChannelResponse
from app.auth import get_current_user from app.auth import get_current_user
@ -34,6 +34,34 @@ def create_channel(
session.commit() session.commit()
session.refresh(new_channel) 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 return new_channel

View File

@ -97,10 +97,18 @@ async def upload_file(
session.refresh(file_attachment) session.refresh(file_attachment)
# Build response with can_edit flag # Build response with can_edit flag
response = FileAttachmentResponse.model_validate(file_attachment) response = FileAttachmentResponse(
response.permission = permission id=file_attachment.id,
response.uploader_id = current_user.id filename=file_attachment.filename,
response.can_edit = (permission == "write") 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 return response
@ -268,9 +276,10 @@ async def upload_file_with_message(
"mime_type": file_attachment.mime_type, "mime_type": file_attachment.mime_type,
"file_size": file_attachment.file_size, "file_size": file_attachment.file_size,
"uploaded_at": file_attachment.uploaded_at.isoformat(), "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, "uploader_id": file_attachment.uploader_id,
"can_edit": file_attachment.is_editable "is_editable": file_attachment.is_editable
} }
response_data = { response_data = {
@ -374,3 +383,71 @@ def get_office_uri(
"file_url": file_url, "file_url": file_url,
"app": app_protocol "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

View 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

View File

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlmodel import Session, select from sqlmodel import Session, select
from sqlalchemy.orm import joinedload
from typing import List from typing import List
import os import os
from app.database import get_session from app.database import get_session
@ -122,11 +123,12 @@ def get_channel_messages(
statement = ( statement = (
select(Message) select(Message)
.where(Message.channel_id == channel_id) .where(Message.channel_id == channel_id)
.options(joinedload(Message.attachments))
.order_by(Message.created_at.desc()) .order_by(Message.created_at.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
) )
messages = session.exec(statement).all() messages = session.exec(statement).unique().all()
# Add sender usernames and reply_to info # Add sender usernames and reply_to info
responses = [] responses = []

View File

@ -158,9 +158,10 @@ class FileAttachmentResponse(BaseModel):
mime_type: str mime_type: str
file_size: int file_size: int
uploaded_at: datetime uploaded_at: datetime
permission: Optional[str] = "read" message_id: int
upload_permission: Optional[str] = "read"
uploader_id: Optional[int] = None 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: class Config:
from_attributes = True from_attributes = True
@ -237,3 +238,153 @@ class TranslationUpdateRequest(BaseModel):
translation_id: int translation_id: int
value: str 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] = []

View 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()

View 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()

View 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()

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

View File

@ -1,5 +1,5 @@
import React from 'react'; import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext';
import Login from './components/Auth/Login'; import Login from './components/Auth/Login';
import Register from './components/Auth/Register'; import Register from './components/Auth/Register';
@ -7,6 +7,7 @@ import ChatView from './components/Chat/ChatView';
import SnippetLibrary from './components/Snippets/SnippetLibrary'; import SnippetLibrary from './components/Snippets/SnippetLibrary';
import AdminPanel from './components/Admin/AdminPanel'; import AdminPanel from './components/Admin/AdminPanel';
import ProfilePage from './components/Profile/ProfilePage'; import ProfilePage from './components/Profile/ProfilePage';
import KanbanBoard from './components/Kanban/KanbanBoard';
import Layout from './components/Layout/Layout'; import Layout from './components/Layout/Layout';
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@ -28,29 +29,86 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <>{children}</>; return <>{children}</>;
}; };
const App: React.FC = () => { const AppContent: React.FC = () => {
const { isAuthenticated } = useAuth(); 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 ( return (
<BrowserRouter> <Routes>
<Routes> <Route path="/login" element={isAuthenticated ? <Navigate to="/" /> : <Login />} />
<Route path="/login" element={isAuthenticated ? <Navigate to="/" /> : <Login />} /> <Route path="/register" element={isAuthenticated ? <Navigate to="/" /> : <Register />} />
<Route path="/register" element={isAuthenticated ? <Navigate to="/" /> : <Register />} />
<Route
<Route path="/"
path="/" element={
element={ <ProtectedRoute>
<ProtectedRoute> <Layout />
<Layout /> </ProtectedRoute>
</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 />} /> // Navigiere zum gespeicherten Pfad
<Route path="snippets" element={<SnippetLibrary />} /> navigate(lastVisitedPath, { replace: true });
<Route path="profile" element={<ProfilePage />} /> }
<Route path="admin" element={<AdminRoute><AdminPanel /></AdminRoute>} /> // Markiere, dass die Route-Wiederherstellung passiert ist
</Route> sessionStorage.setItem('routeRestored', 'true');
</Routes> }
}
}, [isAuthenticated, user, navigate, location.pathname]);
return null;
};
const App: React.FC = () => {
return (
<BrowserRouter>
<RouteRestorer />
<AppContent />
</BrowserRouter> </BrowserRouter>
); );
}; };

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { directMessagesAPI } from '../../services/api'; import { directMessagesAPI } from '../../services/api';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import type { User } from '../../types'; import type { User } from '../../types';
import { useToast } from '../../contexts/ToastContext';
interface DirectMessage { interface DirectMessage {
id: number; id: number;
@ -22,6 +23,7 @@ interface DirectMessageViewProps {
} }
const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => { const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
const { addToast } = useToast();
const [messages, setMessages] = useState<DirectMessage[]>([]); const [messages, setMessages] = useState<DirectMessage[]>([]);
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -91,7 +93,7 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
setContent(''); setContent('');
} catch (error) { } catch (error) {
console.error('Failed to send message:', error); console.error('Failed to send message:', error);
alert('Failed to send message'); addToast('Failed to send message', 'error');
} finally { } finally {
setSending(false); setSending(false);
} }

View File

@ -93,7 +93,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
loadAllUsers(); 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" title="Start new chat"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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)} onClick={() => onSelectUser(user)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
selectedUserId === user.id 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' : 'text-gray-700 dark:text-gray-300'
}`} }`}
> >

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useToast } from '../../contexts/ToastContext';
interface FileUploadDialogProps { interface FileUploadDialogProps {
isOpen: boolean; isOpen: boolean;
@ -7,6 +8,7 @@ interface FileUploadDialogProps {
} }
const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, onUpload }) => { const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, onUpload }) => {
const { addToast } = useToast();
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [permission, setPermission] = useState<'read' | 'write'>('read'); const [permission, setPermission] = useState<'read' | 'write'>('read');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -30,7 +32,7 @@ const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, on
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Upload failed:', error); console.error('Upload failed:', error);
alert('Upload fehlgeschlagen'); addToast('Upload fehlgeschlagen', 'error');
} finally { } finally {
setUploading(false); setUploading(false);
} }
@ -70,9 +72,9 @@ const FileUploadDialog: React.FC<FileUploadDialogProps> = ({ isOpen, onClose, on
file:mr-4 file:py-2 file:px-4 file:mr-4 file:py-2 file:px-4
file:rounded file:border-0 file:rounded file:border-0
file:text-sm file:font-semibold file:text-sm file:font-semibold
file:bg-indigo-50 file:text-indigo-700 file:bg-blue-50 file:text-blue-700
hover:file:bg-indigo-100 hover:file:bg-blue-100
dark:file:bg-indigo-900 dark:file:text-indigo-200" dark:file:bg-blue-900 dark:file:text-blue-200"
/> />
{selectedFile && ( {selectedFile && (
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400"> <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" value="read"
checked={permission === 'read'} checked={permission === 'read'}
onChange={(e) => setPermission(e.target.value as 'read' | '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>
<div className="text-sm font-medium text-gray-900 dark:text-white"> <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" value="write"
checked={permission === 'write'} checked={permission === 'write'}
onChange={(e) => setPermission(e.target.value as 'read' | '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>
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-sm font-medium text-gray-900 dark:text-white">

View File

@ -3,6 +3,7 @@ import { messagesAPI, filesAPI } from '../../services/api';
import SnippetPicker from '../Snippets/SnippetPicker'; import SnippetPicker from '../Snippets/SnippetPicker';
import FileUploadDialog from './FileUploadDialog'; import FileUploadDialog from './FileUploadDialog';
import type { Snippet } from '../../types'; import type { Snippet } from '../../types';
import { useToast } from '../../contexts/ToastContext';
interface MessageInputProps { interface MessageInputProps {
channelId: number; channelId: number;
@ -11,6 +12,7 @@ interface MessageInputProps {
} }
const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCancelReply }) => { const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCancelReply }) => {
const { addToast } = useToast();
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null); const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
const [showSnippetPicker, setShowSnippetPicker] = useState(false); const [showSnippetPicker, setShowSnippetPicker] = useState(false);
@ -48,7 +50,7 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
if (onCancelReply) onCancelReply(); if (onCancelReply) onCancelReply();
} catch (error) { } catch (error) {
console.error('Failed to send message:', error); console.error('Failed to send message:', error);
alert('Failed to send message'); addToast('Failed to send message', 'error');
} finally { } finally {
setSending(false); setSending(false);
} }
@ -75,7 +77,7 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
if (onCancelReply) onCancelReply(); if (onCancelReply) onCancelReply();
} catch (error) { } catch (error) {
console.error('Failed to upload file:', error); console.error('Failed to upload file:', error);
alert('Datei-Upload fehlgeschlagen'); addToast('Datei-Upload fehlgeschlagen', 'error');
throw error; throw error;
} }
}; };

View File

@ -3,6 +3,7 @@ import { messagesAPI, filesAPI } from '../../services/api';
import type { Message } from '../../types'; import type { Message } from '../../types';
import CodeBlock from '../common/CodeBlock'; import CodeBlock from '../common/CodeBlock';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useToast } from '../../contexts/ToastContext';
interface MessageListProps { interface MessageListProps {
channelId: number; channelId: number;
@ -11,6 +12,7 @@ interface MessageListProps {
const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => { const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
const { user } = useAuth(); const { user } = useAuth();
const { addToast } = useToast();
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
@ -156,22 +158,46 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
document.body.removeChild(a); document.body.removeChild(a);
} catch (error) { } catch (error) {
console.error('Download failed:', 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 { try {
const data = await filesAPI.getOfficeUri(fileId); const data = await filesAPI.getOfficeUri(fileId);
window.location.href = data.office_uri; window.location.href = data.office_uri;
} catch (error: any) { } catch (error: any) {
console.error('Edit failed:', error); console.error('Edit failed:', error);
if (error.response?.status === 400) { 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) { } else if (error.response?.status === 403) {
alert('Diese Datei ist schreibgeschützt'); addToast('Diese Datei ist schreibgeschützt', 'error');
} else { } 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) => { const handleDeleteMessage = async (messageId: number) => {
if (!confirm('Möchten Sie diese Nachricht wirklich löschen?')) {
return;
}
try { try {
await messagesAPI.delete(messageId); await messagesAPI.delete(messageId);
addToast('Nachricht gelöscht', 'success');
// Message will be removed via WebSocket broadcast // Message will be removed via WebSocket broadcast
} catch (error) { } catch (error) {
console.error('Delete failed:', 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 <button
onClick={() => { onClick={() => {
// TODO: Implement private message // TODO: Implement private message
alert('Private Nachricht an ' + message.sender_username); addToast('Private Nachricht an ' + message.sender_username, 'info');
setOpenMenuId(null); 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" 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 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"> <div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-lg"> <span className="text-lg">
{file.permission === 'write' ? '📝' : '📄'} {file.upload_permission === 'write' ? '📝' : '📄'}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate"> <div className="text-xs font-medium text-gray-900 dark:text-white truncate">
{file.original_filename} {file.original_filename}
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400"> <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> </div>
</div> </div>
@ -472,7 +495,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onClick={() => setFileMenuId(null)} 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"> <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 <button
onClick={() => { onClick={() => {
handleEditFile(file.id, file.original_filename); handleEditFile(file.id, file.original_filename);
@ -483,6 +506,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
Bearbeiten Bearbeiten
</button> </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 <button
onClick={() => { onClick={() => {
handleDownloadFile(file.id, file.original_filename); 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 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"> <div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-lg"> <span className="text-lg">
{file.permission === 'write' ? '📝' : '📄'} {file.upload_permission === 'write' ? '📝' : '📄'}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate"> <div className="text-xs font-medium text-gray-900 dark:text-white truncate">
{file.original_filename} {file.original_filename}
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400"> <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> </div>
</div> </div>
@ -549,7 +594,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
> >
👁 Öffnen 👁 Öffnen
</button> </button>
{file.permission === 'write' && isOfficeFile(file.original_filename) && ( {file.upload_permission === 'write' && isOfficeFile(file.original_filename) && (
<button <button
onClick={() => { onClick={() => {
handleEditFile(file.id, file.original_filename); handleEditFile(file.id, file.original_filename);
@ -560,6 +605,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
Bearbeiten Bearbeiten
</button> </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 <button
onClick={() => { onClick={() => {
handleDownloadFile(file.id, file.original_filename); 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 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"> <div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-lg"> <span className="text-lg">
{file.permission === 'write' ? '📝' : '📄'} {file.upload_permission === 'write' ? '📝' : '📄'}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate"> <div className="text-xs font-medium text-gray-900 dark:text-white truncate">
{file.original_filename} {file.original_filename}
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400"> <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> </div>
</div> </div>
@ -610,7 +677,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onClick={() => setFileMenuId(null)} 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"> <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 <button
onClick={() => { onClick={() => {
handleEditFile(file.id, file.original_filename); handleEditFile(file.id, file.original_filename);
@ -621,6 +688,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
Bearbeiten Bearbeiten
</button> </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 <button
onClick={() => { onClick={() => {
handleDownloadFile(file.id, file.original_filename); handleDownloadFile(file.id, file.original_filename);

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

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

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

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

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

View File

@ -4,6 +4,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { departmentsAPI } from '../../services/api'; import { departmentsAPI } from '../../services/api';
import type { Department } from '../../types'; import type { Department } from '../../types';
import ToastContainer from '../ToastContainer';
const Layout: React.FC = () => { const Layout: React.FC = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@ -109,6 +110,16 @@ const Layout: React.FC = () => {
Snippets Snippets
</Link> </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 && ( {user?.is_admin && (
<Link <Link
to="/admin" 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' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
> >
🔧 Admin Admin
</Link> </Link>
)} )}
</nav> </nav>
</div> </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 <button
onClick={() => setUserMenuOpen(!userMenuOpen)} onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center space-x-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700" 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> </button>
</div> </div>
)} )}
</div>
</div> </div>
</div> </div>
</header> </header>

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { snippetsAPI, departmentsAPI } from '../../services/api'; import { snippetsAPI, departmentsAPI } from '../../services/api';
import type { Snippet, Department } from '../../types'; import type { Snippet, Department } from '../../types';
import { useToast } from '../../contexts/ToastContext';
const LANGUAGE_SUGGESTIONS = [ const LANGUAGE_SUGGESTIONS = [
'javascript', 'javascript',
@ -24,6 +25,7 @@ interface SnippetEditorProps {
} }
const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel }) => { const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel }) => {
const { addToast } = useToast();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [language, setLanguage] = useState('python'); const [language, setLanguage] = useState('python');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
@ -87,7 +89,7 @@ const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel
onSave(); onSave();
} catch (error: any) { } catch (error: any) {
console.error('Failed to save snippet:', error); 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 { } finally {
setSaving(false); setSaving(false);
} }

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { snippetsAPI, departmentsAPI } from '../../services/api'; import { snippetsAPI, departmentsAPI } from '../../services/api';
import type { Snippet, Department } from '../../types'; import type { Snippet, Department } from '../../types';
import { useToast } from '../../contexts/ToastContext';
const LANGUAGES = [ const LANGUAGES = [
{ value: 'bash', label: 'Bash' }, { value: 'bash', label: 'Bash' },
@ -42,6 +43,7 @@ interface SnippetEditorProps {
} }
const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel }) => { const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel }) => {
const { addToast } = useToast();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [language, setLanguage] = useState('python'); const [language, setLanguage] = useState('python');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
@ -105,7 +107,7 @@ const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel
onSave(); onSave();
} catch (error: any) { } catch (error: any) {
console.error('Failed to save snippet:', error); 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 { } finally {
setSaving(false); setSaving(false);
} }

View File

@ -3,8 +3,10 @@ import { snippetsAPI } from '../../services/api';
import type { Snippet } from '../../types'; import type { Snippet } from '../../types';
import SnippetEditor from './SnippetEditor'; import SnippetEditor from './SnippetEditor';
import SnippetViewer from './SnippetViewer'; import SnippetViewer from './SnippetViewer';
import { useToast } from '../../contexts/ToastContext';
const SnippetLibrary: React.FC = () => { const SnippetLibrary: React.FC = () => {
const { addToast } = useToast();
const [snippets, setSnippets] = useState<Snippet[]>([]); const [snippets, setSnippets] = useState<Snippet[]>([]);
const [filteredSnippets, setFilteredSnippets] = useState<Snippet[]>([]); const [filteredSnippets, setFilteredSnippets] = useState<Snippet[]>([]);
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null); const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
@ -83,7 +85,7 @@ const SnippetLibrary: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('Failed to delete snippet:', error); console.error('Failed to delete snippet:', error);
alert('Failed to delete snippet'); addToast('Failed to delete snippet');
} }
} }
}; };

View File

@ -3,8 +3,10 @@ import { snippetsAPI } from '../../services/api';
import type { Snippet } from '../../types'; import type { Snippet } from '../../types';
import SnippetEditor from './SnippetEditor'; import SnippetEditor from './SnippetEditor';
import SnippetViewer from './SnippetViewer'; import SnippetViewer from './SnippetViewer';
import { useToast } from '../../contexts/ToastContext';
const SnippetLibrary: React.FC = () => { const SnippetLibrary: React.FC = () => {
const { addToast } = useToast();
const [snippets, setSnippets] = useState<Snippet[]>([]); const [snippets, setSnippets] = useState<Snippet[]>([]);
const [filteredSnippets, setFilteredSnippets] = useState<Snippet[]>([]); const [filteredSnippets, setFilteredSnippets] = useState<Snippet[]>([]);
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null); const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
@ -83,7 +85,7 @@ const SnippetLibrary: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('Failed to delete snippet:', error); console.error('Failed to delete snippet:', error);
alert('Failed to delete snippet'); addToast('Failed to delete snippet', 'error');
} }
} }
}; };

View File

@ -2,6 +2,7 @@ import React from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import type { Snippet } from '../../types'; import type { Snippet } from '../../types';
import CodeBlock from '../common/CodeBlock'; import CodeBlock from '../common/CodeBlock';
import { useToast } from '../../contexts/ToastContext';
interface SnippetViewerProps { interface SnippetViewerProps {
snippet: Snippet; snippet: Snippet;
@ -10,12 +11,13 @@ interface SnippetViewerProps {
} }
const SnippetViewer: React.FC<SnippetViewerProps> = ({ snippet, onEdit, onDelete }) => { const SnippetViewer: React.FC<SnippetViewerProps> = ({ snippet, onEdit, onDelete }) => {
const { addToast } = useToast();
const { user } = useAuth(); const { user } = useAuth();
const isOwner = user?.id === snippet.owner_id; const isOwner = user?.id === snippet.owner_id;
const copyToClipboard = () => { const copyToClipboard = () => {
navigator.clipboard.writeText(snippet.content); navigator.clipboard.writeText(snippet.content);
alert('Copied to clipboard!'); addToast('Copied to clipboard!');
}; };
return ( return (

View File

@ -2,6 +2,7 @@ import React from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import type { Snippet } from '../../types'; import type { Snippet } from '../../types';
import CodeBlock from '../common/CodeBlock'; import CodeBlock from '../common/CodeBlock';
import { useToast } from '../../contexts/ToastContext';
interface SnippetViewerProps { interface SnippetViewerProps {
snippet: Snippet; snippet: Snippet;
@ -11,11 +12,12 @@ interface SnippetViewerProps {
const SnippetViewer: React.FC<SnippetViewerProps> = ({ snippet, onEdit, onDelete }) => { const SnippetViewer: React.FC<SnippetViewerProps> = ({ snippet, onEdit, onDelete }) => {
const { user } = useAuth(); const { user } = useAuth();
const { addToast } = useToast();
const isOwner = user?.id === snippet.owner_id; const isOwner = user?.id === snippet.owner_id;
const copyToClipboard = () => { const copyToClipboard = () => {
navigator.clipboard.writeText(snippet.content); navigator.clipboard.writeText(snippet.content);
alert('Copied to clipboard!'); addToast('Copied to clipboard!', 'success');
}; };
return ( return (

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

View File

@ -55,6 +55,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const logout = () => { const logout = () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('lastVisitedPath');
sessionStorage.removeItem('routeRestored');
setToken(null); setToken(null);
setUser(null); setUser(null);
}; };

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

View File

@ -4,12 +4,15 @@ import App from './App';
import './index.css'; import './index.css';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<App /> <ToastProvider>
<App />
</ToastProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</StrictMode> </StrictMode>

View File

@ -40,7 +40,7 @@ export const authAPI = {
return response.data; 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); const response = await api.put('/auth/me', data);
return response.data; return response.data;
}, },
@ -150,6 +150,17 @@ export const filesAPI = {
const response = await api.get(`/files/office-uri/${fileId}`); const response = await api.get(`/files/office-uri/${fileId}`);
return response.data; 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 = { 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; export default api;

View File

@ -56,9 +56,10 @@ export interface FileAttachment {
mime_type: string; mime_type: string;
file_size: number; file_size: number;
uploaded_at: string; uploaded_at: string;
permission: 'read' | 'write'; message_id: number;
upload_permission: 'read' | 'write';
uploader_id?: number; uploader_id?: number;
can_edit?: boolean; is_editable?: boolean;
} }
export interface LoginRequest { export interface LoginRequest {
@ -124,3 +125,73 @@ export interface TranslationGroup {
entries: TranslationEntry[]; 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
View 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
}

View File

@ -1,9 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {

View File

@ -103,12 +103,12 @@ start_frontend() {
return 0 return 0
fi fi
log "Starte Frontend-Server..." log "Starte Frontend-Server (Production)..."
cd "$FRONTEND_DIR" cd "$FRONTEND_DIR"
# Starte Frontend im Hintergrund # Starte Production Server anstatt dev server
nohup npm run dev > "$FRONTEND_LOG" 2>&1 & /home/OfficeDesk/frontend-production.sh > "$FRONTEND_LOG" 2>&1 &
local frontend_pid=$! local frontend_pid=$!
# Warte kurz und prüfe ob der Prozess gestartet ist # Warte kurz und prüfe ob der Prozess gestartet ist