diff --git a/backend/app/main.py b/backend/app/main.py index 97121d2..0dbdd8e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 5c6771a..7083a79 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routers/channels.py b/backend/app/routers/channels.py index 7b72e22..8b10f98 100644 --- a/backend/app/routers/channels.py +++ b/backend/app/routers/channels.py @@ -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 diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 5781a82..9cbf2d2 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -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 diff --git a/backend/app/routers/kanban.py b/backend/app/routers/kanban.py new file mode 100644 index 0000000..eab5151 --- /dev/null +++ b/backend/app/routers/kanban.py @@ -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 \ No newline at end of file diff --git a/backend/app/routers/messages.py b/backend/app/routers/messages.py index 57101c2..b165a6b 100644 --- a/backend/app/routers/messages.py +++ b/backend/app/routers/messages.py @@ -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 = [] diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 08273aa..54901ee 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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] = [] + diff --git a/backend/scripts/create_kanban_boards_for_channels.py b/backend/scripts/create_kanban_boards_for_channels.py new file mode 100755 index 0000000..548b118 --- /dev/null +++ b/backend/scripts/create_kanban_boards_for_channels.py @@ -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() \ No newline at end of file diff --git a/backend/scripts/create_kanban_checklist_tables.py b/backend/scripts/create_kanban_checklist_tables.py new file mode 100644 index 0000000..f9d52fa --- /dev/null +++ b/backend/scripts/create_kanban_checklist_tables.py @@ -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() \ No newline at end of file diff --git a/backend/scripts/create_kanban_tables.py b/backend/scripts/create_kanban_tables.py new file mode 100644 index 0000000..bca4de2 --- /dev/null +++ b/backend/scripts/create_kanban_tables.py @@ -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() \ No newline at end of file diff --git a/backend/scripts/standardize_kanban_boards.py b/backend/scripts/standardize_kanban_boards.py new file mode 100755 index 0000000..2e5c40d --- /dev/null +++ b/backend/scripts/standardize_kanban_boards.py @@ -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() \ No newline at end of file diff --git a/frontend-production.sh b/frontend-production.sh new file mode 100755 index 0000000..7b40e19 --- /dev/null +++ b/frontend-production.sh @@ -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 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eaa03f8..74bffe8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from './contexts/AuthContext'; import Login from './components/Auth/Login'; import Register from './components/Auth/Register'; @@ -7,6 +7,7 @@ import ChatView from './components/Chat/ChatView'; import SnippetLibrary from './components/Snippets/SnippetLibrary'; import AdminPanel from './components/Admin/AdminPanel'; import ProfilePage from './components/Profile/ProfilePage'; +import KanbanBoard from './components/Kanban/KanbanBoard'; import Layout from './components/Layout/Layout'; const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -28,29 +29,86 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { return <>{children}; }; -const App: React.FC = () => { - const { isAuthenticated } = useAuth(); +const AppContent: React.FC = () => { + const { isAuthenticated, user } = useAuth(); + const location = useLocation(); + + // Speichere den aktuellen Pfad beim Ändern + useEffect(() => { + // Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register) + if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) { + // Prüfe Admin-Berechtigung für Admin-Pfade + if (location.pathname.startsWith('/admin') && !user?.is_admin) { + return; // Speichere keine ungültigen Admin-Pfade + } + localStorage.setItem('lastVisitedPath', location.pathname + location.search); + } + }, [location, isAuthenticated, user]); return ( - - - : } /> - : } /> - - - - + + : } /> + : } /> + + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +}; + +// 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 ( + + + ); }; diff --git a/frontend/src/components/Chat/DirectMessageView.tsx b/frontend/src/components/Chat/DirectMessageView.tsx index c99d036..25cd813 100644 --- a/frontend/src/components/Chat/DirectMessageView.tsx +++ b/frontend/src/components/Chat/DirectMessageView.tsx @@ -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 = ({ user }) => { + const { addToast } = useToast(); const [messages, setMessages] = useState([]); const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); @@ -91,7 +93,7 @@ const DirectMessageView: React.FC = ({ 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); } diff --git a/frontend/src/components/Chat/DirectMessagesSidebar.tsx b/frontend/src/components/Chat/DirectMessagesSidebar.tsx index cd7f842..b9ce0fc 100644 --- a/frontend/src/components/Chat/DirectMessagesSidebar.tsx +++ b/frontend/src/components/Chat/DirectMessagesSidebar.tsx @@ -93,7 +93,7 @@ const DirectMessagesSidebar: React.FC = ({ 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" > @@ -114,7 +114,7 @@ const DirectMessagesSidebar: React.FC = ({ 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' }`} > diff --git a/frontend/src/components/Chat/FileUploadDialog.tsx b/frontend/src/components/Chat/FileUploadDialog.tsx index 2dd93f7..e0aa5d9 100644 --- a/frontend/src/components/Chat/FileUploadDialog.tsx +++ b/frontend/src/components/Chat/FileUploadDialog.tsx @@ -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 = ({ isOpen, onClose, onUpload }) => { + const { addToast } = useToast(); const [selectedFile, setSelectedFile] = useState(null); const [permission, setPermission] = useState<'read' | 'write'>('read'); const [uploading, setUploading] = useState(false); @@ -30,7 +32,7 @@ const FileUploadDialog: React.FC = ({ 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 = ({ 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 && (
@@ -94,7 +96,7 @@ const FileUploadDialog: React.FC = ({ 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" />
@@ -113,7 +115,7 @@ const FileUploadDialog: React.FC = ({ 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" />
diff --git a/frontend/src/components/Chat/MessageInput.tsx b/frontend/src/components/Chat/MessageInput.tsx index 292e1c3..ba1a768 100644 --- a/frontend/src/components/Chat/MessageInput.tsx +++ b/frontend/src/components/Chat/MessageInput.tsx @@ -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 = ({ channelId, replyTo, onCancelReply }) => { + const { addToast } = useToast(); const [content, setContent] = useState(''); const [selectedSnippet, setSelectedSnippet] = useState(null); const [showSnippetPicker, setShowSnippetPicker] = useState(false); @@ -48,7 +50,7 @@ const MessageInput: React.FC = ({ 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 = ({ 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; } }; diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 5f5f5da..d469859 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -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 = ({ channelId, onReply }) => { const { user } = useAuth(); + const { addToast } = useToast(); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(true); @@ -156,22 +158,46 @@ const MessageList: React.FC = ({ 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 = ({ 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 = ({ channelId, onReply }) => { )} + {file.upload_permission === 'read' && ( + + )} + {file.upload_permission === 'write' && ( + + )} - {file.permission === 'write' && isOfficeFile(file.original_filename) && ( + {file.upload_permission === 'write' && isOfficeFile(file.original_filename) && ( )} + {file.upload_permission === 'read' && ( + + )} + {file.upload_permission === 'write' && ( + + )} )} + {file.upload_permission === 'read' && ( + + )} + {file.upload_permission === 'write' && ( + + )} +
+ ); +}; + +export default KanbanCard; \ No newline at end of file diff --git a/frontend/src/components/Kanban/KanbanCardModal.tsx b/frontend/src/components/Kanban/KanbanCardModal.tsx new file mode 100644 index 0000000..ad9a3ab --- /dev/null +++ b/frontend/src/components/Kanban/KanbanCardModal.tsx @@ -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 ( + + ); + } + + return ( +
+ 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 + /> + + +
+ ); +}; + +interface KanbanCardModalProps { + card: KanbanCard; + onClose: () => void; + onUpdate: (cardId: number, updates: Partial) => void; +} + +const KanbanCardModal: React.FC = ({ + card, + onClose, + onUpdate +}) => { + const { user } = useAuth(); + const [title, setTitle] = useState(card.title); + const [description, setDescription] = useState(card.description || ''); + const [assigneeId, setAssigneeId] = useState(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([]); + const [checklists, setChecklists] = useState([]); + 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(); + + 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 ( +
+
+ {/* Header */} +
+ 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..." + /> + + +
+ + {/* Content */} +
+ {/* Description */} +
+ +