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 app.database import create_db_and_tables
from app.config import get_settings
from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages
from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban
settings = get_settings()
@ -53,6 +53,7 @@ app.include_router(messages.router)
app.include_router(direct_messages.router)
app.include_router(files.router)
app.include_router(snippets.router)
app.include_router(kanban.router)
app.include_router(websocket.router)

View File

@ -104,6 +104,7 @@ class Channel(SQLModel, table=True):
# Relationships
department: Department = Relationship(back_populates="channels")
messages: List["Message"] = Relationship(back_populates="channel")
kanban_board: Optional["KanbanBoard"] = Relationship(back_populates="channel")
class Message(SQLModel, table=True):
@ -199,3 +200,85 @@ class Snippet(SQLModel, table=True):
owner: User = Relationship(back_populates="snippets")
department: Optional[Department] = Relationship()
allowed_departments: List["Department"] = Relationship(link_model=SnippetDepartmentLink)
# Kanban Board Models
class KanbanBoard(SQLModel, table=True):
__tablename__ = "kanban_board"
id: Optional[int] = Field(default=None, primary_key=True)
channel_id: int = Field(foreign_key="channel.id")
name: str = Field(default="Kanban Board")
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
channel: Channel = Relationship(back_populates="kanban_board")
columns: List["KanbanColumn"] = Relationship(back_populates="board")
class KanbanColumn(SQLModel, table=True):
__tablename__ = "kanban_column"
id: Optional[int] = Field(default=None, primary_key=True)
board_id: int = Field(foreign_key="kanban_board.id")
name: str
position: int = Field(default=0) # For ordering columns
color: Optional[str] = Field(default=None) # Hex color for the column
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
board: KanbanBoard = Relationship(back_populates="columns")
cards: List["KanbanCard"] = Relationship(back_populates="column")
class KanbanCard(SQLModel, table=True):
__tablename__ = "kanban_card"
id: Optional[int] = Field(default=None, primary_key=True)
column_id: int = Field(foreign_key="kanban_column.id")
title: str
description: Optional[str] = Field(default=None)
assignee_id: Optional[int] = Field(default=None, foreign_key="user.id")
position: int = Field(default=0) # For ordering cards within a column
due_date: Optional[datetime] = Field(default=None)
priority: Optional[str] = Field(default="medium") # low, medium, high
labels: Optional[str] = Field(default=None) # JSON string for labels/tags
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
column: KanbanColumn = Relationship(back_populates="cards")
assignee: Optional[User] = Relationship()
checklists: List["KanbanChecklist"] = Relationship(back_populates="card")
class KanbanChecklist(SQLModel, table=True):
__tablename__ = "kanban_checklist"
id: Optional[int] = Field(default=None, primary_key=True)
card_id: int = Field(foreign_key="kanban_card.id")
title: str
position: int = Field(default=0) # For ordering checklists within a card
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
card: KanbanCard = Relationship(back_populates="checklists")
items: List["KanbanChecklistItem"] = Relationship(back_populates="checklist")
class KanbanChecklistItem(SQLModel, table=True):
__tablename__ = "kanban_checklist_item"
id: Optional[int] = Field(default=None, primary_key=True)
checklist_id: int = Field(foreign_key="kanban_checklist.id")
title: str
is_completed: bool = Field(default=False)
position: int = Field(default=0) # For ordering items within a checklist
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
checklist: KanbanChecklist = Relationship(back_populates="items")

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from typing import List
from app.database import get_session
from app.models import Channel, Department, User
from app.models import Channel, Department, User, KanbanBoard, KanbanColumn
from app.schemas import ChannelCreate, ChannelResponse
from app.auth import get_current_user
@ -34,6 +34,34 @@ def create_channel(
session.commit()
session.refresh(new_channel)
# Automatically create a Kanban board for the new channel
kanban_board = KanbanBoard(
channel_id=new_channel.id,
name=f"Kanban Board"
)
session.add(kanban_board)
session.commit()
session.refresh(kanban_board)
# Create the 4 standard columns
default_columns = [
("ToDo", 0),
("In Progress", 1),
("Waiting", 2),
("Done", 3)
]
for name, position in default_columns:
column = KanbanColumn(
board_id=kanban_board.id,
name=name,
position=position
)
session.add(column)
session.commit()
return new_channel

View File

@ -97,10 +97,18 @@ async def upload_file(
session.refresh(file_attachment)
# Build response with can_edit flag
response = FileAttachmentResponse.model_validate(file_attachment)
response.permission = permission
response.uploader_id = current_user.id
response.can_edit = (permission == "write")
response = FileAttachmentResponse(
id=file_attachment.id,
filename=file_attachment.filename,
original_filename=file_attachment.original_filename,
mime_type=file_attachment.mime_type,
file_size=file_attachment.file_size,
uploaded_at=file_attachment.uploaded_at,
message_id=file_attachment.message_id,
upload_permission=permission,
uploader_id=current_user.id,
is_editable=(permission == "write")
)
return response
@ -268,9 +276,10 @@ async def upload_file_with_message(
"mime_type": file_attachment.mime_type,
"file_size": file_attachment.file_size,
"uploaded_at": file_attachment.uploaded_at.isoformat(),
"permission": file_attachment.upload_permission,
"message_id": file_attachment.message_id,
"upload_permission": file_attachment.upload_permission,
"uploader_id": file_attachment.uploader_id,
"can_edit": file_attachment.is_editable
"is_editable": file_attachment.is_editable
}
response_data = {
@ -374,3 +383,71 @@ def get_office_uri(
"file_url": file_url,
"app": app_protocol
})
@router.put("/{file_id}/permission", response_model=FileAttachmentResponse)
async def update_file_permission(
file_id: int,
permission: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Update the permission of a file (only uploader or admin can change)"""
# Validate permission
if permission not in ["read", "write"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Permission must be 'read' or 'write'"
)
# Get file attachment
file_attachment = session.get(FileAttachment, file_id)
if not file_attachment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Check if user is the uploader or an admin
if file_attachment.uploader_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the uploader or an admin can change file permissions"
)
# Update permission
file_attachment.upload_permission = permission
file_attachment.is_editable = (permission == "write")
session.commit()
session.refresh(file_attachment)
# Get message for channel access check
message = session.get(Message, file_attachment.message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Associated message not found"
)
# Check if user still has access to the channel
if not user_has_channel_access(current_user, message.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to this channel"
)
# Create response
response = FileAttachmentResponse(
id=file_attachment.id,
filename=file_attachment.filename,
original_filename=file_attachment.original_filename,
mime_type=file_attachment.mime_type,
file_size=file_attachment.file_size,
uploaded_at=file_attachment.uploaded_at,
message_id=file_attachment.message_id,
uploader_id=file_attachment.uploader_id,
upload_permission=file_attachment.upload_permission,
is_editable=file_attachment.is_editable
)
return response

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 sqlmodel import Session, select
from sqlalchemy.orm import joinedload
from typing import List
import os
from app.database import get_session
@ -122,11 +123,12 @@ def get_channel_messages(
statement = (
select(Message)
.where(Message.channel_id == channel_id)
.options(joinedload(Message.attachments))
.order_by(Message.created_at.desc())
.offset(offset)
.limit(limit)
)
messages = session.exec(statement).all()
messages = session.exec(statement).unique().all()
# Add sender usernames and reply_to info
responses = []

View File

@ -158,9 +158,10 @@ class FileAttachmentResponse(BaseModel):
mime_type: str
file_size: int
uploaded_at: datetime
permission: Optional[str] = "read"
message_id: int
upload_permission: Optional[str] = "read"
uploader_id: Optional[int] = None
can_edit: bool = False # Computed: whether current user can edit
is_editable: bool = False # Computed: whether current user can edit
class Config:
from_attributes = True
@ -237,3 +238,153 @@ class TranslationUpdateRequest(BaseModel):
translation_id: int
value: str
# Kanban Schemas
class KanbanBoardBase(BaseModel):
name: str = "Kanban Board"
class KanbanBoardCreate(KanbanBoardBase):
channel_id: int
class KanbanBoardUpdate(BaseModel):
name: Optional[str] = None
class KanbanBoardResponse(KanbanBoardBase):
id: int
channel_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class KanbanColumnBase(BaseModel):
name: str
position: int = 0
color: Optional[str] = None
class KanbanColumnCreate(KanbanColumnBase):
board_id: int
class KanbanColumnUpdate(BaseModel):
name: Optional[str] = None
position: Optional[int] = None
color: Optional[str] = None
class KanbanColumnResponse(KanbanColumnBase):
id: int
board_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class KanbanCardBase(BaseModel):
title: str
description: Optional[str] = None
assignee_id: Optional[int] = None
position: int = 0
due_date: Optional[datetime] = None
priority: Optional[str] = "medium"
labels: Optional[str] = None
class KanbanCardCreate(KanbanCardBase):
column_id: int
class KanbanCardUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
assignee_id: Optional[int] = None
position: Optional[int] = None
due_date: Optional[datetime] = None
priority: Optional[str] = None
labels: Optional[str] = None
class KanbanCardResponse(KanbanCardBase):
id: int
column_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class KanbanBoardWithColumns(KanbanBoardResponse):
columns: List["KanbanColumnWithCards"] = []
class KanbanColumnWithCards(KanbanColumnResponse):
cards: List[KanbanCardResponse] = []
# Checklist Schemas
class KanbanChecklistBase(BaseModel):
title: str
position: int = 0
class KanbanChecklistCreate(KanbanChecklistBase):
card_id: int
class KanbanChecklistUpdate(BaseModel):
title: Optional[str] = None
position: Optional[int] = None
class KanbanChecklistResponse(KanbanChecklistBase):
id: int
card_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class KanbanChecklistItemBase(BaseModel):
title: str
is_completed: bool = False
position: int = 0
class KanbanChecklistItemCreate(KanbanChecklistItemBase):
checklist_id: int
class KanbanChecklistItemUpdate(BaseModel):
title: Optional[str] = None
is_completed: Optional[bool] = None
position: Optional[int] = None
class KanbanChecklistItemResponse(KanbanChecklistItemBase):
id: int
checklist_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class KanbanChecklistWithItems(KanbanChecklistResponse):
items: List[KanbanChecklistItemResponse] = []
class KanbanCardWithChecklists(KanbanCardResponse):
checklists: List[KanbanChecklistWithItems] = []

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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import Login from './components/Auth/Login';
import Register from './components/Auth/Register';
@ -7,6 +7,7 @@ import ChatView from './components/Chat/ChatView';
import SnippetLibrary from './components/Snippets/SnippetLibrary';
import AdminPanel from './components/Admin/AdminPanel';
import ProfilePage from './components/Profile/ProfilePage';
import KanbanBoard from './components/Kanban/KanbanBoard';
import Layout from './components/Layout/Layout';
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@ -28,11 +29,23 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <>{children}</>;
};
const App: React.FC = () => {
const { isAuthenticated } = useAuth();
const AppContent: React.FC = () => {
const { isAuthenticated, user } = useAuth();
const location = useLocation();
// Speichere den aktuellen Pfad beim Ändern
useEffect(() => {
// Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register)
if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) {
// Prüfe Admin-Berechtigung für Admin-Pfade
if (location.pathname.startsWith('/admin') && !user?.is_admin) {
return; // Speichere keine ungültigen Admin-Pfade
}
localStorage.setItem('lastVisitedPath', location.pathname + location.search);
}
}, [location, isAuthenticated, user]);
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={isAuthenticated ? <Navigate to="/" /> : <Login />} />
<Route path="/register" element={isAuthenticated ? <Navigate to="/" /> : <Register />} />
@ -47,10 +60,55 @@ const App: React.FC = () => {
>
<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;
}
// Navigiere zum gespeicherten Pfad
navigate(lastVisitedPath, { replace: true });
}
// Markiere, dass die Route-Wiederherstellung passiert ist
sessionStorage.setItem('routeRestored', 'true');
}
}
}, [isAuthenticated, user, navigate, location.pathname]);
return null;
};
const App: React.FC = () => {
return (
<BrowserRouter>
<RouteRestorer />
<AppContent />
</BrowserRouter>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { messagesAPI, filesAPI } from '../../services/api';
import type { Message } from '../../types';
import CodeBlock from '../common/CodeBlock';
import { useAuth } from '../../contexts/AuthContext';
import { useToast } from '../../contexts/ToastContext';
interface MessageListProps {
channelId: number;
@ -11,6 +12,7 @@ interface MessageListProps {
const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
const { user } = useAuth();
const { addToast } = useToast();
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
@ -156,22 +158,46 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
document.body.removeChild(a);
} catch (error) {
console.error('Download failed:', error);
alert('Download fehlgeschlagen');
addToast('Download fehlgeschlagen', 'error');
}
};
const handleEditFile = async (fileId: number, filename: string) => {
const handleEditFile = async (fileId: number, _filename: string) => {
try {
const data = await filesAPI.getOfficeUri(fileId);
window.location.href = data.office_uri;
} catch (error: any) {
console.error('Edit failed:', error);
if (error.response?.status === 400) {
alert('Dieser Dateityp kann nicht mit Office bearbeitet werden');
addToast('Dieser Dateityp kann nicht mit Office bearbeitet werden', 'error');
} else if (error.response?.status === 403) {
alert('Diese Datei ist schreibgeschützt');
addToast('Diese Datei ist schreibgeschützt', 'error');
} else {
alert('Bearbeiten fehlgeschlagen');
addToast('Bearbeiten fehlgeschlagen', 'error');
}
}
};
const handleChangePermission = async (fileId: number, newPermission: 'read' | 'write') => {
try {
console.log('Changing permission for file:', fileId, 'to:', newPermission);
const updatedFile = await filesAPI.updatePermission(fileId, newPermission);
console.log('Permission changed successfully:', updatedFile);
// Reload messages to get updated file permissions from server
await loadMessages();
console.log('Messages reloaded with updated permissions');
// Give user feedback
addToast(`Dateiberechtigung erfolgreich geändert zu "${newPermission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}"`, 'success');
} catch (error: any) {
console.error('Permission change failed:', error);
if (error.response?.status === 403) {
addToast('Nur der Uploader oder ein Admin kann die Berechtigung ändern', 'error');
} else {
addToast('Berechtigung ändern fehlgeschlagen: ' + (error.response?.data?.detail || error.message), 'error');
}
}
};
@ -182,16 +208,13 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
};
const handleDeleteMessage = async (messageId: number) => {
if (!confirm('Möchten Sie diese Nachricht wirklich löschen?')) {
return;
}
try {
await messagesAPI.delete(messageId);
addToast('Nachricht gelöscht', 'success');
// Message will be removed via WebSocket broadcast
} catch (error) {
console.error('Delete failed:', error);
alert('Löschen fehlgeschlagen');
addToast('Löschen fehlgeschlagen', 'error');
}
};
@ -355,7 +378,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<button
onClick={() => {
// TODO: Implement private message
alert('Private Nachricht an ' + message.sender_username);
addToast('Private Nachricht an ' + message.sender_username, 'info');
setOpenMenuId(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
@ -444,14 +467,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<div className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700 border-t border-gray-300 dark:border-gray-600">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-lg">
{file.permission === 'write' ? '📝' : '📄'}
{file.upload_permission === 'write' ? '📝' : '📄'}
</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate">
{file.original_filename}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{file.permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
{file.upload_permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
</div>
</div>
</div>
@ -472,7 +495,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onClick={() => setFileMenuId(null)}
/>
<div className="absolute right-0 bottom-full mb-2 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 w-48">
{file.permission === 'write' && isOfficeFile(file.original_filename) && (
{file.upload_permission === 'write' && isOfficeFile(file.original_filename) && (
<button
onClick={() => {
handleEditFile(file.id, file.original_filename);
@ -483,6 +506,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
Bearbeiten
</button>
)}
{file.upload_permission === 'read' && (
<button
onClick={() => {
handleChangePermission(file.id, 'write');
setFileMenuId(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Lesen/Schreiben
</button>
)}
{file.upload_permission === 'write' && (
<button
onClick={() => {
handleChangePermission(file.id, 'read');
setFileMenuId(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Nur lesen
</button>
)}
<button
onClick={() => {
handleDownloadFile(file.id, file.original_filename);
@ -512,14 +557,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<div className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700 border-t border-gray-300 dark:border-gray-600">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-lg">
{file.permission === 'write' ? '📝' : '📄'}
{file.upload_permission === 'write' ? '📝' : '📄'}
</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate">
{file.original_filename}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{file.permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
{file.upload_permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
</div>
</div>
</div>
@ -549,7 +594,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
>
👁 Öffnen
</button>
{file.permission === 'write' && isOfficeFile(file.original_filename) && (
{file.upload_permission === 'write' && isOfficeFile(file.original_filename) && (
<button
onClick={() => {
handleEditFile(file.id, file.original_filename);
@ -560,6 +605,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
Bearbeiten
</button>
)}
{file.upload_permission === 'read' && (
<button
onClick={() => {
handleChangePermission(file.id, 'write');
setFileMenuId(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Lesen/Schreiben
</button>
)}
{file.upload_permission === 'write' && (
<button
onClick={() => {
handleChangePermission(file.id, 'read');
setFileMenuId(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Nur lesen
</button>
)}
<button
onClick={() => {
handleDownloadFile(file.id, file.original_filename);
@ -582,14 +649,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<div className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-lg">
{file.permission === 'write' ? '📝' : '📄'}
{file.upload_permission === 'write' ? '📝' : '📄'}
</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate">
{file.original_filename}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{file.permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
{file.upload_permission === 'write' ? 'Lesen/Schreiben' : 'Nur Lesen'}
</div>
</div>
</div>
@ -610,7 +677,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onClick={() => setFileMenuId(null)}
/>
<div className="absolute right-0 bottom-full mb-2 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 w-48">
{file.permission === 'write' && isOfficeFile(file.original_filename) && (
{file.upload_permission === 'write' && isOfficeFile(file.original_filename) && (
<button
onClick={() => {
handleEditFile(file.id, file.original_filename);
@ -621,6 +688,28 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
Bearbeiten
</button>
)}
{file.upload_permission === 'read' && (
<button
onClick={() => {
handleChangePermission(file.id, 'write');
setFileMenuId(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Lesen/Schreiben
</button>
)}
{file.upload_permission === 'write' && (
<button
onClick={() => {
handleChangePermission(file.id, 'read');
setFileMenuId(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Nur lesen
</button>
)}
<button
onClick={() => {
handleDownloadFile(file.id, file.original_filename);

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 { departmentsAPI } from '../../services/api';
import type { Department } from '../../types';
import ToastContainer from '../ToastContainer';
const Layout: React.FC = () => {
const { user, logout } = useAuth();
@ -109,6 +110,16 @@ const Layout: React.FC = () => {
Snippets
</Link>
)}
<Link
to="/kanban"
className={`px-3 py-1.5 text-sm rounded ${
location.pathname.startsWith('/kanban')
? 'bg-blue-500 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Kanban
</Link>
{user?.is_admin && (
<Link
to="/admin"
@ -118,12 +129,14 @@ const Layout: React.FC = () => {
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
🔧 Admin
Admin
</Link>
)}
</nav>
</div>
<div className="flex items-center space-x-3">
<ToastContainer />
<div className="flex items-center space-x-2.5 relative user-menu-container">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
@ -180,6 +193,7 @@ const Layout: React.FC = () => {
)}
</div>
</div>
</div>
</header>
{/* Main Content */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 = () => {
localStorage.removeItem('token');
localStorage.removeItem('lastVisitedPath');
sessionStorage.removeItem('routeRestored');
setToken(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 { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<App />
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</StrictMode>

View File

@ -40,7 +40,7 @@ export const authAPI = {
return response.data;
},
updateProfile: async (data: { email?: string; full_name?: string; password?: string }): Promise<User> => {
updateProfile: async (data: { email?: string; full_name?: string; password?: string; theme?: string }): Promise<User> => {
const response = await api.put('/auth/me', data);
return response.data;
},
@ -150,6 +150,17 @@ export const filesAPI = {
const response = await api.get(`/files/office-uri/${fileId}`);
return response.data;
},
updatePermission: async (fileId: number, permission: string) => {
const formData = new FormData();
formData.append('permission', permission);
const response = await api.put(`/files/${fileId}/permission`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
};
export const snippetsAPI = {
@ -232,4 +243,120 @@ export const directMessagesAPI = {
},
};
export const kanbanAPI = {
// Board operations
createBoard: async (data: { channel_id: number; name?: string }) => {
const response = await api.post('/kanban/boards', data);
return response.data;
},
getBoardByChannel: async (channelId: number) => {
const response = await api.get(`/kanban/boards/${channelId}`);
return response.data;
},
updateBoard: async (boardId: number, data: { name?: string }) => {
const response = await api.put(`/kanban/boards/${boardId}`, data);
return response.data;
},
// Column operations
createColumn: async (data: { board_id: number; name: string; position: number; color?: string }) => {
const response = await api.post('/kanban/columns', data);
return response.data;
},
updateColumn: async (columnId: number, data: { name?: string; position?: number; color?: string }) => {
const response = await api.put(`/kanban/columns/${columnId}`, data);
return response.data;
},
deleteColumn: async (columnId: number) => {
const response = await api.delete(`/kanban/columns/${columnId}`);
return response.data;
},
// Card operations
createCard: async (data: {
column_id: number;
title: string;
description?: string;
assignee_id?: number;
position: number;
due_date?: string;
priority?: 'low' | 'medium' | 'high';
labels?: string;
}) => {
const response = await api.post('/kanban/cards', data);
return response.data;
},
updateCard: async (cardId: number, data: {
title?: string;
description?: string;
assignee_id?: number;
position?: number;
due_date?: string;
priority?: 'low' | 'medium' | 'high';
labels?: string;
}) => {
const response = await api.put(`/kanban/cards/${cardId}`, data);
return response.data;
},
deleteCard: async (cardId: number) => {
const response = await api.delete(`/kanban/cards/${cardId}`);
return response.data;
},
moveCard: async (cardId: number, targetColumnId: number, newPosition: number) => {
const response = await api.put(`/kanban/cards/${cardId}/move`, null, {
params: { target_column_id: targetColumnId, new_position: newPosition }
});
return response.data;
},
// Checklist API
createChecklist: async (data: { card_id: number; title: string; position: number }) => {
const response = await api.post('/kanban/checklists', data);
return response.data;
},
getCardChecklists: async (cardId: number) => {
const response = await api.get(`/kanban/cards/${cardId}/checklists`);
return response.data;
},
getChecklist: async (checklistId: number) => {
const response = await api.get(`/kanban/checklists/${checklistId}`);
return response.data;
},
updateChecklist: async (checklistId: number, data: { title?: string; position?: number }) => {
const response = await api.put(`/kanban/checklists/${checklistId}`, data);
return response.data;
},
deleteChecklist: async (checklistId: number) => {
const response = await api.delete(`/kanban/checklists/${checklistId}`);
return response.data;
},
// Checklist Item API
createChecklistItem: async (data: { checklist_id: number; title: string; is_completed?: boolean; position: number }) => {
const response = await api.post('/kanban/checklist-items', data);
return response.data;
},
updateChecklistItem: async (itemId: number, data: { title?: string; is_completed?: boolean; position?: number }) => {
const response = await api.put(`/kanban/checklist-items/${itemId}`, data);
return response.data;
},
deleteChecklistItem: async (itemId: number) => {
const response = await api.delete(`/kanban/checklist-items/${itemId}`);
return response.data;
},
};
export default api;

View File

@ -56,9 +56,10 @@ export interface FileAttachment {
mime_type: string;
file_size: number;
uploaded_at: string;
permission: 'read' | 'write';
message_id: number;
upload_permission: 'read' | 'write';
uploader_id?: number;
can_edit?: boolean;
is_editable?: boolean;
}
export interface LoginRequest {
@ -124,3 +125,73 @@ export interface TranslationGroup {
entries: TranslationEntry[];
}
// Kanban Types
export interface KanbanBoard {
id: number;
channel_id: number;
name: string;
created_at: string;
updated_at: string;
}
export interface KanbanColumn {
id: number;
board_id: number;
name: string;
position: number;
color?: string;
created_at: string;
updated_at: string;
}
export interface KanbanCard {
id: number;
column_id: number;
title: string;
description?: string;
assignee_id?: number;
position: number;
due_date?: string;
priority?: 'low' | 'medium' | 'high';
labels?: string;
created_at: string;
updated_at: string;
assignee?: User;
}
export interface KanbanBoardWithColumns extends KanbanBoard {
columns: KanbanColumnWithCards[];
}
export interface KanbanColumnWithCards extends KanbanColumn {
cards: KanbanCard[];
}
// Checklist Types
export interface KanbanChecklist {
id: number;
card_id: number;
title: string;
position: number;
created_at: string;
updated_at: string;
}
export interface KanbanChecklistItem {
id: number;
checklist_id: number;
title: string;
is_completed: boolean;
position: number;
created_at: string;
updated_at: string;
}
export interface KanbanChecklistWithItems extends KanbanChecklist {
items: KanbanChecklistItem[];
}
export interface KanbanCardWithChecklists extends KanbanCard {
checklists: KanbanChecklistWithItems[];
}

10
frontend/src/vite-env.d.ts vendored Normal file
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 react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {

View File

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