mirror of
https://github.com/OHV-IT/collabrix.git
synced 2025-12-15 16:48:36 +01:00
UI: Standardize header heights and sidebar padding - py-3 for all sidebars, py-0.5 for content headers
This commit is contained in:
parent
71bea0ae7d
commit
7d8e839dbe
35
backend/alembic.ini
Normal file
35
backend/alembic.ini
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = backend/alembic
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
propagate = 0
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers = console
|
||||||
|
qualname = alembic
|
||||||
44
backend/alembic/env.py
Normal file
44
backend/alembic/env.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from alembic import context
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add backend path
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.models import SQLModel # noqa: E402
|
||||||
|
from app.database import engine # noqa: E402
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
connectable = engine
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
31
backend/alembic/versions/0001_create_last_seen_table.py
Normal file
31
backend/alembic/versions/0001_create_last_seen_table.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""create last_seen table
|
||||||
|
|
||||||
|
Revision ID: 0001_create_last_seen_table
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-12-12 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0001_create_last_seen_table'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'last_seen',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('channel_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('dm_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('last_seen', sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_last_seen_last_seen'), 'last_seen', ['last_seen'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index(op.f('ix_last_seen_last_seen'), table_name='last_seen')
|
||||||
|
op.drop_table('last_seen')
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
"""Create direct_message_attachment table
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2025-12-12 22:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0002'
|
||||||
|
down_revision = '0001'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create direct_message_attachment table
|
||||||
|
op.create_table(
|
||||||
|
'direct_message_attachment',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('filename', sa.String(), nullable=False),
|
||||||
|
sa.Column('original_filename', sa.String(), nullable=False),
|
||||||
|
sa.Column('mime_type', sa.String(), nullable=False),
|
||||||
|
sa.Column('file_size', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('file_path', sa.String(), nullable=False),
|
||||||
|
sa.Column('direct_message_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('uploader_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('webdav_path', sa.String(), nullable=True),
|
||||||
|
sa.Column('upload_permission', sa.String(), nullable=False, server_default='read'),
|
||||||
|
sa.Column('is_editable', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('uploaded_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(['direct_message_id'], ['direct_message.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['uploader_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('direct_message_attachment')
|
||||||
30
backend/alembic/versions/0003_add_assignee_to_kanban_card.py
Normal file
30
backend/alembic/versions/0003_add_assignee_to_kanban_card.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""Add assignee_id to kanban_card table
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2024-12-13 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0003'
|
||||||
|
down_revision = '0001'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add assignee_id column to kanban_card table
|
||||||
|
op.add_column('kanban_card', sa.Column('assignee_id', sa.Integer(), nullable=True))
|
||||||
|
# Add foreign key constraint
|
||||||
|
op.create_foreign_key('fk_kanban_card_assignee_id', 'kanban_card', 'user', ['assignee_id'], ['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop foreign key constraint
|
||||||
|
op.drop_constraint('fk_kanban_card_assignee_id', 'kanban_card', type_='foreignkey')
|
||||||
|
# Remove assignee_id column
|
||||||
|
op.drop_column('kanban_card', 'assignee_id')
|
||||||
37
backend/alembic/versions/0004_add_kanban_activity_log.py
Normal file
37
backend/alembic/versions/0004_add_kanban_activity_log.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"""Add kanban_card_activity_log table
|
||||||
|
|
||||||
|
Revision ID: 0004
|
||||||
|
Revises: 0003
|
||||||
|
Create Date: 2024-12-13 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0004'
|
||||||
|
down_revision = '0003'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'kanban_card_activity_log',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('card_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('action', sa.String(), nullable=False),
|
||||||
|
sa.Column('field_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('old_value', sa.String(), nullable=True),
|
||||||
|
sa.Column('new_value', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['card_id'], ['kanban_card.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('kanban_card_activity_log')
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
"""Add is_archived to kanban_card table
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2024-12-13 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0005'
|
||||||
|
down_revision = '0004'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('kanban_card', sa.Column('is_archived', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('kanban_card', 'is_archived')
|
||||||
@ -4,7 +4,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from app.database import create_db_and_tables, get_session
|
from app.database import create_db_and_tables, get_session
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban
|
from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban, last_seen
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.models import User, DirectMessage, Department
|
from app.models import User, DirectMessage, Department
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@ -17,6 +17,14 @@ app = FastAPI(
|
|||||||
version="1.0.0"
|
version="1.0.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from starlette.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
|
# Honor proxy headers (X-Forwarded-For, X-Forwarded-Proto) when behind nginx
|
||||||
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||||
|
except ImportError:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("ProxyHeadersMiddleware not available. Start uvicorn with --proxy-headers or install 'starlette' to enable forwarded header handling.")
|
||||||
|
|
||||||
# CORS middleware
|
# CORS middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@ -59,6 +67,12 @@ app.include_router(files.router)
|
|||||||
app.include_router(snippets.router)
|
app.include_router(snippets.router)
|
||||||
app.include_router(kanban.router)
|
app.include_router(kanban.router)
|
||||||
app.include_router(websocket.router)
|
app.include_router(websocket.router)
|
||||||
|
app.include_router(last_seen.router)
|
||||||
|
|
||||||
|
# Mount uploads directory for file serving
|
||||||
|
uploads_dir = Path(__file__).parent.parent / "uploads"
|
||||||
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
app.mount("/files", StaticFiles(directory=uploads_dir), name="files")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -148,6 +148,19 @@ class DirectMessage(SQLModel, table=True):
|
|||||||
sender: User = Relationship(back_populates="sent_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.sender_id"})
|
sender: User = Relationship(back_populates="sent_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.sender_id"})
|
||||||
receiver: User = Relationship(back_populates="received_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.receiver_id"})
|
receiver: User = Relationship(back_populates="received_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.receiver_id"})
|
||||||
snippet: Optional["Snippet"] = Relationship()
|
snippet: Optional["Snippet"] = Relationship()
|
||||||
|
reply_to: Optional["DirectMessage"] = Relationship(back_populates="replies", sa_relationship_kwargs={"foreign_keys": "DirectMessage.reply_to_id", "remote_side": "DirectMessage.id"})
|
||||||
|
replies: List["DirectMessage"] = Relationship(back_populates="reply_to")
|
||||||
|
attachments: List["DirectMessageAttachment"] = Relationship(back_populates="direct_message")
|
||||||
|
|
||||||
|
|
||||||
|
class LastSeen(SQLModel, table=True):
|
||||||
|
__tablename__ = "last_seen"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
channel_id: Optional[int] = Field(default=None)
|
||||||
|
dm_user_id: Optional[int] = Field(default=None)
|
||||||
|
last_seen: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
class FileAttachment(SQLModel, table=True):
|
class FileAttachment(SQLModel, table=True):
|
||||||
@ -186,6 +199,27 @@ class FilePermission(SQLModel, table=True):
|
|||||||
user: User = Relationship(back_populates="file_permissions")
|
user: User = Relationship(back_populates="file_permissions")
|
||||||
|
|
||||||
|
|
||||||
|
class DirectMessageAttachment(SQLModel, table=True):
|
||||||
|
__tablename__ = "direct_message_attachment"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
filename: str
|
||||||
|
original_filename: str
|
||||||
|
mime_type: str
|
||||||
|
file_size: int
|
||||||
|
file_path: str
|
||||||
|
direct_message_id: int = Field(foreign_key="direct_message.id")
|
||||||
|
uploader_id: Optional[int] = Field(default=None, foreign_key="user.id")
|
||||||
|
webdav_path: Optional[str] = None
|
||||||
|
upload_permission: str = Field(default="read") # "read" or "write"
|
||||||
|
is_editable: bool = Field(default=False)
|
||||||
|
uploaded_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
direct_message: DirectMessage = Relationship(back_populates="attachments")
|
||||||
|
uploader: Optional[User] = Relationship()
|
||||||
|
|
||||||
|
|
||||||
class Snippet(SQLModel, table=True):
|
class Snippet(SQLModel, table=True):
|
||||||
__tablename__ = "snippet"
|
__tablename__ = "snippet"
|
||||||
|
|
||||||
@ -221,6 +255,8 @@ class KanbanBoard(SQLModel, table=True):
|
|||||||
# Relationships
|
# Relationships
|
||||||
channel: Channel = Relationship(back_populates="kanban_board")
|
channel: Channel = Relationship(back_populates="kanban_board")
|
||||||
columns: List["KanbanColumn"] = Relationship(back_populates="board")
|
columns: List["KanbanColumn"] = Relationship(back_populates="board")
|
||||||
|
custom_fields: List["KanbanCustomField"] = Relationship(back_populates="board")
|
||||||
|
templates: List["KanbanCardTemplate"] = Relationship(back_populates="board")
|
||||||
|
|
||||||
|
|
||||||
class KanbanColumn(SQLModel, table=True):
|
class KanbanColumn(SQLModel, table=True):
|
||||||
@ -251,6 +287,9 @@ class KanbanCard(SQLModel, table=True):
|
|||||||
due_date: Optional[datetime] = Field(default=None)
|
due_date: Optional[datetime] = Field(default=None)
|
||||||
priority: Optional[str] = Field(default="medium") # low, medium, high
|
priority: Optional[str] = Field(default="medium") # low, medium, high
|
||||||
labels: Optional[str] = Field(default=None) # JSON string for labels/tags
|
labels: Optional[str] = Field(default=None) # JSON string for labels/tags
|
||||||
|
estimated_time: Optional[int] = Field(default=None) # Estimated time in minutes
|
||||||
|
actual_time: Optional[int] = Field(default=None) # Actual time spent in minutes
|
||||||
|
is_archived: bool = Field(default=False) # Soft delete - card is archived but not deleted
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
@ -258,6 +297,10 @@ class KanbanCard(SQLModel, table=True):
|
|||||||
column: KanbanColumn = Relationship(back_populates="cards")
|
column: KanbanColumn = Relationship(back_populates="cards")
|
||||||
assignee: Optional[User] = Relationship()
|
assignee: Optional[User] = Relationship()
|
||||||
checklists: List["KanbanChecklist"] = Relationship(back_populates="card")
|
checklists: List["KanbanChecklist"] = Relationship(back_populates="card")
|
||||||
|
comments: List["KanbanCardComment"] = Relationship(back_populates="card")
|
||||||
|
attachments: List["KanbanCardAttachment"] = Relationship(back_populates="card")
|
||||||
|
time_entries: List["KanbanTimeEntry"] = Relationship(back_populates="card")
|
||||||
|
custom_field_values: List["KanbanCustomFieldValue"] = Relationship(back_populates="card")
|
||||||
|
|
||||||
|
|
||||||
class KanbanChecklist(SQLModel, table=True):
|
class KanbanChecklist(SQLModel, table=True):
|
||||||
@ -288,3 +331,124 @@ class KanbanChecklistItem(SQLModel, table=True):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
checklist: KanbanChecklist = Relationship(back_populates="items")
|
checklist: KanbanChecklist = Relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
|
# Kanban Card Comments
|
||||||
|
class KanbanCardComment(SQLModel, table=True):
|
||||||
|
__tablename__ = "kanban_card_comment"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
card_id: int = Field(foreign_key="kanban_card.id")
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
content: str
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
card: KanbanCard = Relationship(back_populates="comments")
|
||||||
|
user: User = Relationship()
|
||||||
|
|
||||||
|
|
||||||
|
# Kanban Card Attachments
|
||||||
|
class KanbanCardAttachment(SQLModel, table=True):
|
||||||
|
__tablename__ = "kanban_card_attachment"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
card_id: int = Field(foreign_key="kanban_card.id")
|
||||||
|
filename: str
|
||||||
|
original_filename: str
|
||||||
|
mime_type: str
|
||||||
|
file_size: int
|
||||||
|
file_path: str
|
||||||
|
uploader_id: int = Field(foreign_key="user.id")
|
||||||
|
uploaded_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
card: KanbanCard = Relationship(back_populates="attachments")
|
||||||
|
uploader: User = Relationship()
|
||||||
|
|
||||||
|
|
||||||
|
# Kanban Time Tracking
|
||||||
|
class KanbanTimeEntry(SQLModel, table=True):
|
||||||
|
__tablename__ = "kanban_time_entry"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
card_id: int = Field(foreign_key="kanban_card.id")
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
description: Optional[str] = Field(default=None)
|
||||||
|
start_time: datetime
|
||||||
|
end_time: Optional[datetime] = Field(default=None)
|
||||||
|
duration_minutes: Optional[int] = Field(default=None) # Calculated field
|
||||||
|
is_running: bool = Field(default=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
card: KanbanCard = Relationship(back_populates="time_entries")
|
||||||
|
user: User = Relationship()
|
||||||
|
|
||||||
|
|
||||||
|
# Kanban Custom Fields
|
||||||
|
class KanbanCustomField(SQLModel, table=True):
|
||||||
|
__tablename__ = "kanban_custom_field"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
board_id: int = Field(foreign_key="kanban_board.id")
|
||||||
|
name: str
|
||||||
|
field_type: str # 'text', 'number', 'date', 'select', 'multiselect', 'checkbox'
|
||||||
|
options: Optional[str] = Field(default=None) # JSON string for select options
|
||||||
|
is_required: bool = Field(default=False)
|
||||||
|
position: int = Field(default=0)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
board: KanbanBoard = Relationship(back_populates="custom_fields")
|
||||||
|
values: List["KanbanCustomFieldValue"] = Relationship(back_populates="field")
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCustomFieldValue(SQLModel, table=True):
|
||||||
|
__tablename__ = "kanban_custom_field_value"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
field_id: int = Field(foreign_key="kanban_custom_field.id")
|
||||||
|
card_id: int = Field(foreign_key="kanban_card.id")
|
||||||
|
value: str # JSON string for the value
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
field: KanbanCustomField = Relationship(back_populates="values")
|
||||||
|
card: KanbanCard = Relationship(back_populates="custom_field_values")
|
||||||
|
|
||||||
|
|
||||||
|
# Kanban Card Activity Log
|
||||||
|
class KanbanCardActivityLog(SQLModel, table=True):
|
||||||
|
__tablename__ = "kanban_card_activity_log"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
card_id: int = Field(foreign_key="kanban_card.id")
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
action: str # 'created', 'moved', 'updated', 'commented', etc.
|
||||||
|
field_name: Optional[str] = Field(default=None) # Which field was changed
|
||||||
|
old_value: Optional[str] = Field(default=None) # Old value as string
|
||||||
|
new_value: Optional[str] = Field(default=None) # New value as string
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
card: KanbanCard = Relationship()
|
||||||
|
user: User = Relationship()
|
||||||
|
|
||||||
|
|
||||||
|
# Kanban Card Templates
|
||||||
|
class KanbanCardTemplate(SQLModel, table=True):
|
||||||
|
__tablename__ = "kanban_card_template"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
board_id: int = Field(foreign_key="kanban_board.id")
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = Field(default=None)
|
||||||
|
template_data: str # JSON string containing template data
|
||||||
|
is_default: bool = Field(default=False)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
board: KanbanBoard = Relationship(back_populates="templates")
|
||||||
|
|||||||
@ -78,7 +78,7 @@ def get_all_users(
|
|||||||
@router.patch("/users/{user_id}/role")
|
@router.patch("/users/{user_id}/role")
|
||||||
def update_user_role(
|
def update_user_role(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
role: UserRole,
|
body: UserAdminUpdate,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
admin: User = Depends(require_superadmin)
|
admin: User = Depends(require_superadmin)
|
||||||
):
|
):
|
||||||
@ -91,18 +91,18 @@ def update_user_role(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Prevent superadmin from demoting themselves
|
# Prevent superadmin from demoting themselves
|
||||||
if admin.id == user_id and role != UserRole.SUPERADMIN:
|
if admin.id == user_id and body.role != UserRole.SUPERADMIN:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Cannot change your own superadmin role"
|
detail="Cannot change your own superadmin role"
|
||||||
)
|
)
|
||||||
|
|
||||||
user.role = role
|
user.role = body.role
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
|
|
||||||
return {"message": f"User {user.username} role updated to {role.value}", "role": role}
|
return {"message": f"User {user.username} role updated to {body.role.value}", "role": body.role}
|
||||||
|
|
||||||
|
|
||||||
# ========== Department Management ==========
|
# ========== Department Management ==========
|
||||||
@ -344,6 +344,38 @@ def create_channel(
|
|||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/channels/{channel_id}", response_model=ChannelResponse)
|
||||||
|
def update_channel(
|
||||||
|
channel_id: int,
|
||||||
|
channel_data: ChannelCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Update a channel (Admin only)"""
|
||||||
|
channel = session.get(Channel, channel_id)
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Channel not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify department exists
|
||||||
|
department = session.get(Department, channel_data.department_id)
|
||||||
|
if not department:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Department not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.name = channel_data.name
|
||||||
|
channel.description = channel_data.description
|
||||||
|
channel.department_id = channel_data.department_id
|
||||||
|
session.add(channel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(channel)
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/channels/{channel_id}")
|
@router.delete("/channels/{channel_id}")
|
||||||
def delete_channel(
|
def delete_channel(
|
||||||
channel_id: int,
|
channel_id: int,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from sqlmodel import Session, select
|
|||||||
from typing import List
|
from typing import List
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Department, User
|
from app.models import Department, User
|
||||||
from app.schemas import DepartmentCreate, DepartmentResponse
|
from app.schemas import DepartmentCreate, DepartmentResponse, UserResponse
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/departments", tags=["Departments"])
|
router = APIRouter(prefix="/departments", tags=["Departments"])
|
||||||
@ -59,6 +59,32 @@ def get_my_departments(
|
|||||||
return user.departments if user else []
|
return user.departments if user else []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{department_id}/users", response_model=List[UserResponse])
|
||||||
|
def get_department_users(
|
||||||
|
department_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get all users in a department"""
|
||||||
|
# Check if department exists
|
||||||
|
department = session.get(Department, department_id)
|
||||||
|
if not department:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Department not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if current user has access to this department
|
||||||
|
user_departments = [dept.id for dept in current_user.departments]
|
||||||
|
if department_id not in user_departments and current_user.role not in ["admin", "superadmin"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
return department.users
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{department_id}/users/{user_id}")
|
@router.post("/{department_id}/users/{user_id}")
|
||||||
def add_user_to_department(
|
def add_user_to_department(
|
||||||
department_id: int,
|
department_id: int,
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Form
|
||||||
from sqlmodel import Session, select, or_, and_
|
from sqlmodel import Session, select, or_, and_
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import DirectMessage, User, Snippet
|
from app.models import DirectMessage, User, Snippet, DirectMessageAttachment
|
||||||
from app.schemas import DirectMessageCreate, DirectMessageResponse
|
from app.schemas import DirectMessageCreate, DirectMessageResponse, DirectMessageAttachmentResponse
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.websocket import manager
|
from app.websocket import manager
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
router = APIRouter(prefix="/direct-messages", tags=["Direct Messages"])
|
router = APIRouter(prefix="/direct-messages", tags=["Direct Messages"])
|
||||||
|
|
||||||
@ -37,34 +40,83 @@ async def create_direct_message(
|
|||||||
content=message_data.content,
|
content=message_data.content,
|
||||||
sender_id=current_user.id,
|
sender_id=current_user.id,
|
||||||
receiver_id=message_data.receiver_id,
|
receiver_id=message_data.receiver_id,
|
||||||
snippet_id=message_data.snippet_id
|
snippet_id=message_data.snippet_id,
|
||||||
|
reply_to_id=message_data.reply_to_id
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(new_message)
|
session.add(new_message)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(new_message)
|
session.refresh(new_message)
|
||||||
|
|
||||||
# Build response
|
|
||||||
response = DirectMessageResponse.model_validate(new_message)
|
|
||||||
response.sender_username = current_user.username
|
|
||||||
response.receiver_username = receiver.username
|
|
||||||
response.sender_full_name = current_user.full_name
|
|
||||||
response.sender_profile_picture = current_user.profile_picture
|
|
||||||
|
|
||||||
# Load snippet data if present
|
# Load snippet data if present
|
||||||
snippet_data = None
|
snippet_data = None
|
||||||
if new_message.snippet_id:
|
if new_message.snippet_id:
|
||||||
snippet = session.get(Snippet, new_message.snippet_id)
|
snippet = session.get(Snippet, new_message.snippet_id)
|
||||||
if snippet:
|
if snippet:
|
||||||
|
snippet_owner = session.get(User, snippet.owner_id)
|
||||||
snippet_data = {
|
snippet_data = {
|
||||||
"id": snippet.id,
|
"id": snippet.id,
|
||||||
"title": snippet.title,
|
"title": snippet.title,
|
||||||
"content": snippet.content,
|
"content": snippet.content,
|
||||||
"language": snippet.language,
|
"language": snippet.language,
|
||||||
"created_at": snippet.created_at.isoformat(),
|
"tags": snippet.tags,
|
||||||
"updated_at": snippet.updated_at.isoformat()
|
"visibility": snippet.visibility,
|
||||||
|
"department_id": snippet.department_id,
|
||||||
|
"owner_id": snippet.owner_id,
|
||||||
|
"owner_username": snippet_owner.username if snippet_owner else "Unknown",
|
||||||
|
"created_at": snippet.created_at,
|
||||||
|
"updated_at": snippet.updated_at
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Load reply_to data if present
|
||||||
|
reply_to_data = None
|
||||||
|
if new_message.reply_to_id:
|
||||||
|
reply_to_msg = session.get(DirectMessage, new_message.reply_to_id)
|
||||||
|
if reply_to_msg:
|
||||||
|
reply_sender = session.get(User, reply_to_msg.sender_id)
|
||||||
|
reply_to_data = {
|
||||||
|
"id": reply_to_msg.id,
|
||||||
|
"content": reply_to_msg.content,
|
||||||
|
"sender_username": reply_sender.username if reply_sender else "Unknown",
|
||||||
|
"sender_full_name": reply_sender.full_name if reply_sender else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build attachment data
|
||||||
|
attachments_data = [
|
||||||
|
{
|
||||||
|
"id": att.id,
|
||||||
|
"filename": att.filename,
|
||||||
|
"original_filename": att.original_filename,
|
||||||
|
"mime_type": att.mime_type,
|
||||||
|
"file_size": att.file_size,
|
||||||
|
"file_path": att.file_path,
|
||||||
|
"direct_message_id": att.direct_message_id,
|
||||||
|
"uploaded_at": att.uploaded_at,
|
||||||
|
"upload_permission": att.upload_permission,
|
||||||
|
"uploader_id": att.uploader_id,
|
||||||
|
"is_editable": att.is_editable
|
||||||
|
}
|
||||||
|
for att in new_message.attachments
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build response using constructor
|
||||||
|
response = DirectMessageResponse(
|
||||||
|
id=new_message.id,
|
||||||
|
content=new_message.content,
|
||||||
|
sender_id=new_message.sender_id,
|
||||||
|
receiver_id=new_message.receiver_id,
|
||||||
|
snippet_id=new_message.snippet_id,
|
||||||
|
created_at=new_message.created_at,
|
||||||
|
is_read=new_message.is_read,
|
||||||
|
sender_username=current_user.username,
|
||||||
|
receiver_username=receiver.username,
|
||||||
|
sender_full_name=current_user.full_name,
|
||||||
|
sender_profile_picture=current_user.profile_picture,
|
||||||
|
snippet=snippet_data,
|
||||||
|
reply_to=reply_to_data,
|
||||||
|
attachments=attachments_data
|
||||||
|
)
|
||||||
|
|
||||||
# Broadcast via WebSocket to receiver (using negative user ID as "channel")
|
# Broadcast via WebSocket to receiver (using negative user ID as "channel")
|
||||||
response_data = {
|
response_data = {
|
||||||
"id": new_message.id,
|
"id": new_message.id,
|
||||||
@ -78,7 +130,10 @@ async def create_direct_message(
|
|||||||
"created_at": new_message.created_at.isoformat(),
|
"created_at": new_message.created_at.isoformat(),
|
||||||
"is_read": new_message.is_read,
|
"is_read": new_message.is_read,
|
||||||
"snippet_id": new_message.snippet_id,
|
"snippet_id": new_message.snippet_id,
|
||||||
"snippet": snippet_data
|
"snippet": snippet_data,
|
||||||
|
"reply_to_id": new_message.reply_to_id,
|
||||||
|
"reply_to": reply_to_data,
|
||||||
|
"attachments": attachments_data
|
||||||
}
|
}
|
||||||
|
|
||||||
# Broadcast to both sender and receiver using their user IDs as "channel"
|
# Broadcast to both sender and receiver using their user IDs as "channel"
|
||||||
@ -97,6 +152,46 @@ async def create_direct_message(
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{message_id}", status_code=status.HTTP_200_OK)
|
||||||
|
async def delete_direct_message(
|
||||||
|
message_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Delete a direct message (only sender can delete)"""
|
||||||
|
message = session.get(DirectMessage, message_id)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Message not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only sender can delete
|
||||||
|
if message.sender_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You can only delete your own messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete associated attachments and files
|
||||||
|
attachments = session.query(DirectMessageAttachment).filter(
|
||||||
|
DirectMessageAttachment.direct_message_id == message_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for attachment in attachments:
|
||||||
|
# Delete file from disk
|
||||||
|
file_path = Path(attachment.file_path)
|
||||||
|
if file_path.exists():
|
||||||
|
try:
|
||||||
|
file_path.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to delete file: {e}")
|
||||||
|
session.delete(attachment)
|
||||||
|
|
||||||
|
session.delete(message)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Message deleted"}
|
||||||
@router.get("/conversation/{user_id}", response_model=List[DirectMessageResponse])
|
@router.get("/conversation/{user_id}", response_model=List[DirectMessageResponse])
|
||||||
def get_conversation(
|
def get_conversation(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@ -140,13 +235,76 @@ def get_conversation(
|
|||||||
# Build responses
|
# Build responses
|
||||||
responses = []
|
responses = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
msg_response = DirectMessageResponse.model_validate(msg)
|
|
||||||
sender = session.get(User, msg.sender_id)
|
sender = session.get(User, msg.sender_id)
|
||||||
receiver = session.get(User, msg.receiver_id)
|
receiver = session.get(User, msg.receiver_id)
|
||||||
msg_response.sender_username = sender.username if sender else "Unknown"
|
|
||||||
msg_response.receiver_username = receiver.username if receiver else "Unknown"
|
# Build reply_to data if it exists
|
||||||
msg_response.sender_full_name = sender.full_name if sender else None
|
reply_to_data = None
|
||||||
msg_response.sender_profile_picture = sender.profile_picture if sender else None
|
if msg.reply_to_id:
|
||||||
|
reply_to_msg = session.get(DirectMessage, msg.reply_to_id)
|
||||||
|
if reply_to_msg:
|
||||||
|
reply_sender = session.get(User, reply_to_msg.sender_id)
|
||||||
|
reply_to_data = {
|
||||||
|
"id": reply_to_msg.id,
|
||||||
|
"content": reply_to_msg.content,
|
||||||
|
"sender_username": reply_sender.username if reply_sender else "Unknown",
|
||||||
|
"sender_full_name": reply_sender.full_name if reply_sender else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build attachment data
|
||||||
|
attachments_data = [
|
||||||
|
{
|
||||||
|
"id": att.id,
|
||||||
|
"filename": att.filename,
|
||||||
|
"original_filename": att.original_filename,
|
||||||
|
"mime_type": att.mime_type,
|
||||||
|
"file_size": att.file_size,
|
||||||
|
"file_path": att.file_path,
|
||||||
|
"direct_message_id": att.direct_message_id,
|
||||||
|
"uploaded_at": att.uploaded_at,
|
||||||
|
"upload_permission": att.upload_permission,
|
||||||
|
"uploader_id": att.uploader_id,
|
||||||
|
"is_editable": att.is_editable
|
||||||
|
}
|
||||||
|
for att in msg.attachments
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build snippet data
|
||||||
|
snippet_data = None
|
||||||
|
if msg.snippet:
|
||||||
|
snippet_owner = session.get(User, msg.snippet.owner_id)
|
||||||
|
snippet_data = {
|
||||||
|
"id": msg.snippet.id,
|
||||||
|
"title": msg.snippet.title,
|
||||||
|
"content": msg.snippet.content,
|
||||||
|
"language": msg.snippet.language,
|
||||||
|
"tags": msg.snippet.tags,
|
||||||
|
"visibility": msg.snippet.visibility,
|
||||||
|
"department_id": msg.snippet.department_id,
|
||||||
|
"owner_id": msg.snippet.owner_id,
|
||||||
|
"owner_username": snippet_owner.username if snippet_owner else "Unknown",
|
||||||
|
"created_at": msg.snippet.created_at,
|
||||||
|
"updated_at": msg.snippet.updated_at
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create response using dict constructor
|
||||||
|
msg_response = DirectMessageResponse(
|
||||||
|
id=msg.id,
|
||||||
|
content=msg.content,
|
||||||
|
sender_id=msg.sender_id,
|
||||||
|
receiver_id=msg.receiver_id,
|
||||||
|
snippet_id=msg.snippet_id,
|
||||||
|
created_at=msg.created_at,
|
||||||
|
is_read=msg.is_read,
|
||||||
|
sender_username=sender.username if sender else "Unknown",
|
||||||
|
receiver_username=receiver.username if receiver else "Unknown",
|
||||||
|
sender_full_name=sender.full_name if sender else None,
|
||||||
|
sender_profile_picture=sender.profile_picture if sender else None,
|
||||||
|
snippet=snippet_data,
|
||||||
|
reply_to=reply_to_data,
|
||||||
|
attachments=attachments_data
|
||||||
|
)
|
||||||
|
|
||||||
responses.append(msg_response)
|
responses.append(msg_response)
|
||||||
|
|
||||||
# Reverse to show oldest first
|
# Reverse to show oldest first
|
||||||
@ -205,3 +363,149 @@ def get_conversations(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return conversations
|
return conversations
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/upload", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_direct_message_file(
|
||||||
|
user_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
content: str = Form(default=""),
|
||||||
|
permission: str = Form(default="read"),
|
||||||
|
reply_to_id: str = Form(default=""),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Upload a file with a direct message"""
|
||||||
|
# Convert reply_to_id from string to int or None
|
||||||
|
parsed_reply_to_id: Optional[int] = None
|
||||||
|
if reply_to_id and reply_to_id.strip():
|
||||||
|
try:
|
||||||
|
parsed_reply_to_id = int(reply_to_id)
|
||||||
|
except ValueError:
|
||||||
|
parsed_reply_to_id = None
|
||||||
|
|
||||||
|
# Check if receiver exists
|
||||||
|
receiver = session.get(User, user_id)
|
||||||
|
if not receiver:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Receiver not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Can't send message to yourself
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot send message to yourself"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create uploads directory if it doesn't exist
|
||||||
|
upload_dir = Path(__file__).parent.parent.parent / "uploads"
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
file_ext = Path(file.filename).suffix
|
||||||
|
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
||||||
|
file_path = upload_dir / unique_filename
|
||||||
|
|
||||||
|
# Save file to disk
|
||||||
|
file_content = await file.read()
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
f.write(file_content)
|
||||||
|
|
||||||
|
# Create direct message
|
||||||
|
new_message = DirectMessage(
|
||||||
|
content=content or f"Shared file: {file.filename}",
|
||||||
|
sender_id=current_user.id,
|
||||||
|
receiver_id=user_id,
|
||||||
|
reply_to_id=parsed_reply_to_id
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(new_message)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(new_message)
|
||||||
|
|
||||||
|
# Create file attachment
|
||||||
|
attachment = DirectMessageAttachment(
|
||||||
|
filename=unique_filename,
|
||||||
|
original_filename=file.filename,
|
||||||
|
mime_type=file.content_type or "application/octet-stream",
|
||||||
|
file_size=len(file_content),
|
||||||
|
file_path=str(file_path),
|
||||||
|
direct_message_id=new_message.id,
|
||||||
|
uploader_id=current_user.id,
|
||||||
|
upload_permission=permission
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(attachment)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(attachment)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
response = DirectMessageResponse.model_validate(new_message)
|
||||||
|
response.sender_username = current_user.username
|
||||||
|
response.receiver_username = receiver.username
|
||||||
|
response.sender_full_name = current_user.full_name
|
||||||
|
response.sender_profile_picture = current_user.profile_picture
|
||||||
|
|
||||||
|
# Add attachment to response
|
||||||
|
att_response = DirectMessageAttachmentResponse.model_validate(attachment)
|
||||||
|
response.attachments = [att_response]
|
||||||
|
|
||||||
|
# Load reply_to data if present
|
||||||
|
reply_to_data = None
|
||||||
|
if new_message.reply_to_id:
|
||||||
|
reply_to_msg = session.get(DirectMessage, new_message.reply_to_id)
|
||||||
|
if reply_to_msg:
|
||||||
|
reply_sender = session.get(User, reply_to_msg.sender_id)
|
||||||
|
reply_to_data = {
|
||||||
|
"id": reply_to_msg.id,
|
||||||
|
"content": reply_to_msg.content,
|
||||||
|
"sender_username": reply_sender.username if reply_sender else "Unknown",
|
||||||
|
"sender_full_name": reply_sender.full_name if reply_sender else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Broadcast via WebSocket
|
||||||
|
response_data = {
|
||||||
|
"id": new_message.id,
|
||||||
|
"content": new_message.content,
|
||||||
|
"sender_id": new_message.sender_id,
|
||||||
|
"receiver_id": new_message.receiver_id,
|
||||||
|
"sender_username": current_user.username,
|
||||||
|
"receiver_username": receiver.username,
|
||||||
|
"sender_full_name": current_user.full_name,
|
||||||
|
"sender_profile_picture": current_user.profile_picture,
|
||||||
|
"created_at": new_message.created_at.isoformat(),
|
||||||
|
"is_read": new_message.is_read,
|
||||||
|
"snippet_id": new_message.snippet_id,
|
||||||
|
"snippet": None,
|
||||||
|
"reply_to_id": new_message.reply_to_id,
|
||||||
|
"reply_to": reply_to_data,
|
||||||
|
"attachments": [{
|
||||||
|
"id": attachment.id,
|
||||||
|
"filename": attachment.filename,
|
||||||
|
"original_filename": attachment.original_filename,
|
||||||
|
"mime_type": attachment.mime_type,
|
||||||
|
"file_size": attachment.file_size,
|
||||||
|
"uploaded_at": attachment.uploaded_at.isoformat(),
|
||||||
|
"direct_message_id": attachment.direct_message_id,
|
||||||
|
"upload_permission": attachment.upload_permission,
|
||||||
|
"uploader_id": attachment.uploader_id,
|
||||||
|
"is_editable": attachment.is_editable
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Broadcast to both sender and receiver
|
||||||
|
await manager.broadcast_to_channel(
|
||||||
|
{"type": "direct_message", "message": response_data},
|
||||||
|
-user_id
|
||||||
|
)
|
||||||
|
await manager.broadcast_to_channel(
|
||||||
|
{"type": "direct_message", "message": response_data},
|
||||||
|
-current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update user activity
|
||||||
|
manager.update_activity(current_user.id)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
103
backend/app/routers/last_seen.py
Normal file
103
backend/app/routers/last_seen.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.models import User, Channel, LastSeen
|
||||||
|
from app.websocket import manager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/me/last-seen", tags=["LastSeen"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def set_last_seen(
|
||||||
|
channel_id: Optional[int] = None,
|
||||||
|
dm_user_id: Optional[int] = None,
|
||||||
|
last_seen: Optional[str] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not channel_id and not dm_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="channel_id or dm_user_id required")
|
||||||
|
|
||||||
|
# Validate target exists
|
||||||
|
if channel_id:
|
||||||
|
channel = session.get(Channel, channel_id)
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel not found")
|
||||||
|
# Basic access check: user must belong to the channel's department
|
||||||
|
user_dept_ids = [d.id for d in current_user.departments] if current_user.departments else []
|
||||||
|
if channel.department_id not in user_dept_ids:
|
||||||
|
raise HTTPException(status_code=403, detail="No access to channel")
|
||||||
|
|
||||||
|
if dm_user_id:
|
||||||
|
other = session.get(User, dm_user_id)
|
||||||
|
if not other:
|
||||||
|
raise HTTPException(status_code=404, detail="Target user not found")
|
||||||
|
|
||||||
|
ts = datetime.utcnow() if not last_seen else datetime.fromisoformat(last_seen)
|
||||||
|
|
||||||
|
# Upsert last seen
|
||||||
|
if channel_id:
|
||||||
|
statement = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.channel_id == channel_id)
|
||||||
|
else:
|
||||||
|
statement = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.dm_user_id == dm_user_id)
|
||||||
|
|
||||||
|
exists = session.exec(statement).first()
|
||||||
|
if exists:
|
||||||
|
exists.last_seen = ts
|
||||||
|
session.add(exists)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(exists)
|
||||||
|
result = exists
|
||||||
|
else:
|
||||||
|
payload = LastSeen(user_id=current_user.id, channel_id=channel_id, dm_user_id=dm_user_id, last_seen=ts)
|
||||||
|
session.add(payload)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(payload)
|
||||||
|
result = payload
|
||||||
|
|
||||||
|
# Broadcast read_marker to relevant channel or DM partner
|
||||||
|
try:
|
||||||
|
message = {
|
||||||
|
"type": "read_marker",
|
||||||
|
"user_id": current_user.id,
|
||||||
|
"last_seen": result.last_seen.isoformat(),
|
||||||
|
}
|
||||||
|
if channel_id:
|
||||||
|
message["channel_id"] = channel_id
|
||||||
|
await manager.broadcast_to_channel(message, channel_id)
|
||||||
|
elif dm_user_id:
|
||||||
|
message["dm_user_id"] = dm_user_id
|
||||||
|
# partner listens on channel id = -their_user_id
|
||||||
|
await manager.broadcast_to_channel(message, -dm_user_id)
|
||||||
|
# also broadcast to presence channel 0
|
||||||
|
await manager.broadcast_to_channel({**message, "type": "read_marker"}, 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"id": result.id, "user_id": result.user_id, "channel_id": result.channel_id, "dm_user_id": result.dm_user_id, "last_seen": result.last_seen.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def get_last_seen(
|
||||||
|
channel_id: Optional[int] = None,
|
||||||
|
dm_user_id: Optional[int] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not channel_id and not dm_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="channel_id or dm_user_id required")
|
||||||
|
|
||||||
|
if channel_id:
|
||||||
|
stmt = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.channel_id == channel_id)
|
||||||
|
else:
|
||||||
|
stmt = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.dm_user_id == dm_user_id)
|
||||||
|
|
||||||
|
found = session.exec(stmt).first()
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(status_code=404, detail="LastSeen not found")
|
||||||
|
|
||||||
|
return {"id": found.id, "user_id": found.user_id, "channel_id": found.channel_id, "dm_user_id": found.dm_user_id, "last_seen": found.last_seen.isoformat()}
|
||||||
@ -148,6 +148,28 @@ class DirectMessageResponse(DirectMessageBase):
|
|||||||
sender_profile_picture: Optional[str] = None
|
sender_profile_picture: Optional[str] = None
|
||||||
snippet: Optional["SnippetResponse"] = None
|
snippet: Optional["SnippetResponse"] = None
|
||||||
reply_to: Optional[dict] = None # Contains replied message info
|
reply_to: Optional[dict] = None # Contains replied message info
|
||||||
|
attachments: List["DirectMessageAttachmentResponse"] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Direct Message Attachment Schemas
|
||||||
|
class DirectMessageAttachmentCreate(BaseModel):
|
||||||
|
permission: str = "read" # "read" or "write"
|
||||||
|
|
||||||
|
|
||||||
|
class DirectMessageAttachmentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
filename: str
|
||||||
|
original_filename: str
|
||||||
|
mime_type: str
|
||||||
|
file_size: int
|
||||||
|
uploaded_at: datetime
|
||||||
|
direct_message_id: int
|
||||||
|
upload_permission: Optional[str] = "read"
|
||||||
|
uploader_id: Optional[int] = None
|
||||||
|
is_editable: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@ -303,6 +325,8 @@ class KanbanCardBase(BaseModel):
|
|||||||
due_date: Optional[datetime] = None
|
due_date: Optional[datetime] = None
|
||||||
priority: Optional[str] = "medium"
|
priority: Optional[str] = "medium"
|
||||||
labels: Optional[str] = None
|
labels: Optional[str] = None
|
||||||
|
estimated_time: Optional[int] = None
|
||||||
|
actual_time: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class KanbanCardCreate(KanbanCardBase):
|
class KanbanCardCreate(KanbanCardBase):
|
||||||
@ -317,6 +341,9 @@ class KanbanCardUpdate(BaseModel):
|
|||||||
due_date: Optional[datetime] = None
|
due_date: Optional[datetime] = None
|
||||||
priority: Optional[str] = None
|
priority: Optional[str] = None
|
||||||
labels: Optional[str] = None
|
labels: Optional[str] = None
|
||||||
|
estimated_time: Optional[int] = None
|
||||||
|
actual_time: Optional[int] = None
|
||||||
|
is_archived: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class KanbanCardResponse(KanbanCardBase):
|
class KanbanCardResponse(KanbanCardBase):
|
||||||
@ -324,6 +351,11 @@ class KanbanCardResponse(KanbanCardBase):
|
|||||||
column_id: int
|
column_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
attachments_count: int = 0
|
||||||
|
checklists_count: int = 0
|
||||||
|
comments_count: int = 0
|
||||||
|
is_archived: bool = False
|
||||||
|
assignee: Optional["UserResponse"] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@ -395,3 +427,181 @@ class KanbanChecklistWithItems(KanbanChecklistResponse):
|
|||||||
class KanbanCardWithChecklists(KanbanCardResponse):
|
class KanbanCardWithChecklists(KanbanCardResponse):
|
||||||
checklists: List[KanbanChecklistWithItems] = []
|
checklists: List[KanbanChecklistWithItems] = []
|
||||||
|
|
||||||
|
|
||||||
|
# Comment Schemas
|
||||||
|
class KanbanCardCommentBase(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCardCommentCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCardCommentUpdate(BaseModel):
|
||||||
|
content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCardCommentResponse(KanbanCardCommentBase):
|
||||||
|
id: int
|
||||||
|
card_id: int
|
||||||
|
user_id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Attachment Schemas
|
||||||
|
class KanbanCardAttachmentBase(BaseModel):
|
||||||
|
filename: str
|
||||||
|
original_filename: str
|
||||||
|
mime_type: str
|
||||||
|
file_size: int
|
||||||
|
file_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCardAttachmentCreate(BaseModel):
|
||||||
|
card_id: int
|
||||||
|
file: bytes # This will be handled by FastAPI's UploadFile
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCardAttachmentResponse(KanbanCardAttachmentBase):
|
||||||
|
id: int
|
||||||
|
card_id: int
|
||||||
|
uploader_id: int
|
||||||
|
uploaded_at: datetime
|
||||||
|
uploader: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Time Tracking Schemas
|
||||||
|
class KanbanTimeEntryBase(BaseModel):
|
||||||
|
description: Optional[str] = None
|
||||||
|
start_time: datetime
|
||||||
|
end_time: Optional[datetime] = None
|
||||||
|
duration_minutes: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanTimeEntryCreate(BaseModel):
|
||||||
|
card_id: int
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanTimeEntryUpdate(BaseModel):
|
||||||
|
description: Optional[str] = None
|
||||||
|
end_time: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanTimeEntryResponse(KanbanTimeEntryBase):
|
||||||
|
id: int
|
||||||
|
card_id: int
|
||||||
|
user_id: int
|
||||||
|
is_running: bool
|
||||||
|
created_at: datetime
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Custom Field Schemas
|
||||||
|
class KanbanCustomFieldBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
field_type: str
|
||||||
|
options: Optional[str] = None
|
||||||
|
is_required: bool = False
|
||||||
|
position: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCustomFieldCreate(KanbanCustomFieldBase):
|
||||||
|
board_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCustomFieldUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
field_type: Optional[str] = None
|
||||||
|
options: Optional[str] = None
|
||||||
|
is_required: Optional[bool] = None
|
||||||
|
position: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCustomFieldResponse(KanbanCustomFieldBase):
|
||||||
|
id: int
|
||||||
|
board_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCustomFieldValueBase(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCustomFieldValueCreate(KanbanCustomFieldValueBase):
|
||||||
|
field_id: int
|
||||||
|
card_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCustomFieldValueUpdate(BaseModel):
|
||||||
|
value: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCustomFieldValueResponse(KanbanCustomFieldValueBase):
|
||||||
|
id: int
|
||||||
|
field_id: int
|
||||||
|
card_id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Template Schemas
|
||||||
|
class KanbanCardTemplateBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
template_data: str
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCardTemplateCreate(KanbanCardTemplateBase):
|
||||||
|
board_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCardTemplateUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
template_data: Optional[str] = None
|
||||||
|
is_default: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanCardTemplateResponse(KanbanCardTemplateBase):
|
||||||
|
id: int
|
||||||
|
board_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Extended Card Response with all features
|
||||||
|
class KanbanCardExtendedResponse(KanbanCardResponse):
|
||||||
|
estimated_time: Optional[int] = None
|
||||||
|
actual_time: Optional[int] = None
|
||||||
|
comments: List[KanbanCardCommentResponse] = []
|
||||||
|
attachments: List[KanbanCardAttachmentResponse] = []
|
||||||
|
time_entries: List[KanbanTimeEntryResponse] = []
|
||||||
|
custom_field_values: List[KanbanCustomFieldValueResponse] = []
|
||||||
|
checklists: List[KanbanChecklistWithItems] = []
|
||||||
|
|
||||||
|
|
||||||
|
class KanbanBoardExtendedResponse(KanbanBoardResponse):
|
||||||
|
custom_fields: List[KanbanCustomFieldResponse] = []
|
||||||
|
templates: List[KanbanCardTemplateResponse] = []
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"@mui/icons-material": "^7.3.6",
|
"@mui/icons-material": "^7.3.6",
|
||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.6",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
"mdi-react": "^9.4.0",
|
||||||
"prism-react-renderer": "^1.3.5",
|
"prism-react-renderer": "^1.3.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
@ -68,6 +68,11 @@ const AdminPanel: React.FC = () => {
|
|||||||
const [newChannelDesc, setNewChannelDesc] = useState('');
|
const [newChannelDesc, setNewChannelDesc] = useState('');
|
||||||
const [channelDeptId, setChannelDeptId] = useState<number | null>(null);
|
const [channelDeptId, setChannelDeptId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
|
||||||
|
const [editChannelName, setEditChannelName] = useState('');
|
||||||
|
const [editChannelDesc, setEditChannelDesc] = useState('');
|
||||||
|
const [editChannelDeptId, setEditChannelDeptId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [selectedSnippetId, setSelectedSnippetId] = useState<number | null>(null);
|
const [selectedSnippetId, setSelectedSnippetId] = useState<number | null>(null);
|
||||||
const [snippetAccess, setSnippetAccess] = useState<SnippetAccessEntry[]>([]);
|
const [snippetAccess, setSnippetAccess] = useState<SnippetAccessEntry[]>([]);
|
||||||
|
|
||||||
@ -470,6 +475,45 @@ const AdminPanel: React.FC = () => {
|
|||||||
[setGlobalError]
|
[setGlobalError]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const startEditChannel = useCallback((channel: Channel) => {
|
||||||
|
setEditingChannel(channel);
|
||||||
|
setEditChannelName(channel.name);
|
||||||
|
setEditChannelDesc(channel.description || '');
|
||||||
|
setEditChannelDeptId(channel.department_id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelEditChannel = useCallback(() => {
|
||||||
|
setEditingChannel(null);
|
||||||
|
setEditChannelName('');
|
||||||
|
setEditChannelDesc('');
|
||||||
|
setEditChannelDeptId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateChannel = useCallback(
|
||||||
|
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingChannel || !editChannelName.trim() || !editChannelDeptId) {
|
||||||
|
setGlobalError('Bitte geben Sie einen Namen an und wählen Sie eine Abteilung.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await api.put<Channel>(`/admin/channels/${editingChannel.id}`, {
|
||||||
|
name: editChannelName.trim(),
|
||||||
|
description: editChannelDesc.trim() || undefined,
|
||||||
|
department_id: editChannelDeptId,
|
||||||
|
});
|
||||||
|
setChannels((prev) =>
|
||||||
|
prev.map((channel) => (channel.id === editingChannel.id ? response.data : channel))
|
||||||
|
);
|
||||||
|
cancelEditChannel();
|
||||||
|
} catch (err) {
|
||||||
|
setGlobalError('Channel konnte nicht aktualisiert werden.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editingChannel, editChannelName, editChannelDesc, editChannelDeptId, setGlobalError, cancelEditChannel]
|
||||||
|
);
|
||||||
|
|
||||||
const toggleSnippetAccess = useCallback(
|
const toggleSnippetAccess = useCallback(
|
||||||
async (snippetId: number, departmentId: number, enabled: boolean) => {
|
async (snippetId: number, departmentId: number, enabled: boolean) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -1044,7 +1088,72 @@ const AdminPanel: React.FC = () => {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{channels.map((channel) => {
|
{channels.map((channel) => {
|
||||||
const dept = departments.find((item) => item.id === channel.department_id);
|
const dept = departments.find((item) => item.id === channel.department_id);
|
||||||
return (
|
const isEditing = editingChannel?.id === channel.id;
|
||||||
|
return isEditing ? (
|
||||||
|
<div key={channel.id} className="p-4 border border-blue-300 dark:border-blue-600 rounded-md bg-blue-50 dark:bg-blue-900/20">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Channel bearbeiten
|
||||||
|
</h3>
|
||||||
|
<form onSubmit={updateChannel} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Channel-Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editChannelName}
|
||||||
|
onChange={(event) => setEditChannelName(event.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editChannelDesc}
|
||||||
|
onChange={(event) => setEditChannelDesc(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Abteilung
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editChannelDeptId ?? ''}
|
||||||
|
onChange={(event) => setEditChannelDeptId(event.target.value ? Number(event.target.value) : null)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">-- Abteilung wählen --</option>
|
||||||
|
{departments.map((dept) => (
|
||||||
|
<option key={dept.id} value={dept.id}>
|
||||||
|
{dept.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-blue-600 text-white px-3 py-2 rounded-md hover:bg-blue-700 text-sm"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelEditChannel}
|
||||||
|
className="flex-1 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white px-3 py-2 rounded-md hover:bg-gray-400 dark:hover:bg-gray-500 text-sm"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-md"
|
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-md"
|
||||||
@ -1060,12 +1169,20 @@ const AdminPanel: React.FC = () => {
|
|||||||
Abteilung: {dept?.name || `ID ${channel.department_id}`}
|
Abteilung: {dept?.name || `ID ${channel.department_id}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={() => deleteChannel(channel.id, channel.name)}
|
<button
|
||||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 px-3 py-1 text-xs border border-red-600 dark:border-red-400 rounded-md"
|
onClick={() => startEditChannel(channel)}
|
||||||
>
|
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 px-3 py-1 text-xs border border-blue-600 dark:border-blue-400 rounded-md"
|
||||||
Löschen
|
>
|
||||||
</button>
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteChannel(channel.id, channel.name)}
|
||||||
|
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 px-3 py-1 text-xs border border-red-600 dark:border-red-400 rounded-md"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -66,15 +66,13 @@ const ChatView: React.FC = () => {
|
|||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{selectedChannel ? (
|
{selectedChannel ? (
|
||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2">
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-0.5">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
# {selectedChannel.name}
|
# {selectedChannel.name}
|
||||||
</h2>
|
</h2>
|
||||||
{selectedChannel.description && (
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0 h-5">
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
{selectedChannel.description || ''}
|
||||||
{selectedChannel.description}
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MessageList
|
<MessageList
|
||||||
|
|||||||
249
frontend/src/components/Chat/DirectMessageInput.tsx
Normal file
249
frontend/src/components/Chat/DirectMessageInput.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { directMessagesAPI } from '../../services/api';
|
||||||
|
import SnippetPicker from '../Snippets/SnippetPicker';
|
||||||
|
import type { Snippet } from '../../types';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
|
||||||
|
interface DirectMessageInputProps {
|
||||||
|
userId: number;
|
||||||
|
onMessageSent?: () => void;
|
||||||
|
replyTo?: { id: number; content: string; sender_username: string; sender_full_name?: string } | null;
|
||||||
|
onCancelReply?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DirectMessageInput: React.FC<DirectMessageInputProps> = ({ userId, onMessageSent, replyTo, onCancelReply }) => {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||||
|
const [showSnippetPicker, setShowSnippetPicker] = useState(false);
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const emojis = [
|
||||||
|
'😀', '😂', '😊', '😍', '🥰', '😎', '🤔', '🤗', '🤩', '😅', '😇', '🙃',
|
||||||
|
'😉', '😋', '😜', '🤪', '😏', '😌', '😴', '🥳', '🤓', '🧐', '😳', '😱',
|
||||||
|
'😭', '😤', '😡', '🤯', '😶', '🙄', '👍', '👎', '👏', '🙏', '💪', '👋',
|
||||||
|
'🤝', '✌️', '🤞', '👌', '🤘', '🖖', '❤️', '💕', '💖', '💗', '💙', '💚',
|
||||||
|
'💛', '🧡', '💜', '🤎', '🖤', '🤍', '💯', '💢', '💥', '💫', '✨', '🌟',
|
||||||
|
'⭐', '🔥', '💧', '💨', '🌈', '☀️', '🌙', '⚡', '☁️', '🎉', '🎊', '🎈',
|
||||||
|
'🎁', '🏆', '🥇', '🥈', '🥉', '⚽', '🏀', '🎯', '🎮', '🎲', '🚀', '✈️',
|
||||||
|
'🚗', '🏠', '🏢', '🗼', '🌍', '🗺️', '🧭', '⏰', '📱', '💻', '⌨️', '🖱️',
|
||||||
|
'📷', '📚', '📝', '✏️', '📌', '📎', '🔗', '📧', '📨', '📮', '🔔', '🔕',
|
||||||
|
'✅', '❌', '⭕', '✔️', '💬', '💭', '🍕', '🍔', '🍟', '🍿', '☕', '🍺'
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!content.trim() && !selectedSnippet && !selectedFile) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
if (selectedFile) {
|
||||||
|
// Upload file with message using FormData and direct fetch (not API client)
|
||||||
|
// because API client would double the /api/ prefix
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('content', content || `Shared file: ${selectedFile.name}`);
|
||||||
|
formData.append('permission', 'read');
|
||||||
|
if (replyTo?.id) {
|
||||||
|
formData.append('reply_to_id', replyTo.id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiUrl}/direct-messages/${userId}/upload`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
|
throw new Error(errorData.detail || `Upload failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('File uploaded successfully:', result);
|
||||||
|
} else {
|
||||||
|
// Send regular message
|
||||||
|
await directMessagesAPI.create({
|
||||||
|
content: content || `Shared snippet: ${selectedSnippet?.title}`,
|
||||||
|
receiver_id: userId,
|
||||||
|
snippet_id: selectedSnippet?.id,
|
||||||
|
reply_to_id: replyTo?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent('');
|
||||||
|
setSelectedSnippet(null);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (onCancelReply) onCancelReply();
|
||||||
|
if (onMessageSent) onMessageSent();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send message:', error);
|
||||||
|
addToast('Nachricht konnte nicht gesendet werden', 'error');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files?.[0]) {
|
||||||
|
setSelectedFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3">
|
||||||
|
{replyTo && (
|
||||||
|
<div className="mb-2 p-2 bg-blue-50 dark:bg-blue-900 rounded flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs font-medium text-blue-900 dark:text-blue-100">
|
||||||
|
Replying to {replyTo.sender_full_name || replyTo.sender_username}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-700 dark:text-blue-200 mt-0.5 truncate">
|
||||||
|
{replyTo.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancelReply}
|
||||||
|
className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm ml-2"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSnippet && (
|
||||||
|
<div className="mb-2 p-2 bg-blue-100 dark:bg-blue-900 rounded flex items-center justify-between">
|
||||||
|
<span className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
|
{selectedSnippet.title} ({selectedSnippet.language})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="mb-2 p-2 bg-blue-100 dark:bg-blue-900 rounded flex items-center justify-between">
|
||||||
|
<span className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
|
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(2)} KB)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFile(null)}
|
||||||
|
className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-stretch space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSnippetPicker(true)}
|
||||||
|
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-sm self-center"
|
||||||
|
title="Insert snippet"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-sm self-center cursor-pointer"
|
||||||
|
title="Datei anhängen">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative self-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
|
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-sm"
|
||||||
|
title="Add emoji"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setShowEmojiPicker(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-full mb-2 left-0 z-20 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl p-2 w-80 max-h-64 overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-12 gap-1">
|
||||||
|
{emojis.map((emoji, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => {
|
||||||
|
setContent(content + emoji);
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}}
|
||||||
|
className="text-lg hover:bg-gray-100 dark:hover:bg-gray-700 rounded p-1 transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || (!content.trim() && !selectedSnippet && !selectedFile)}
|
||||||
|
className="px-4 text-sm bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSnippetPicker && (
|
||||||
|
<SnippetPicker
|
||||||
|
onSelect={(snippet) => {
|
||||||
|
setSelectedSnippet(snippet);
|
||||||
|
setShowSnippetPicker(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowSnippetPicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DirectMessageInput;
|
||||||
@ -2,9 +2,10 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { directMessagesAPI, getApiUrl } from '../../services/api';
|
import { directMessagesAPI, getApiUrl } from '../../services/api';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import type { User } from '../../types';
|
import type { User } from '../../types';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
|
||||||
import { useUserStatus } from '../../contexts/UserStatusContext';
|
import { useUserStatus } from '../../contexts/UserStatusContext';
|
||||||
|
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
|
||||||
import UserStatusIndicator from '../common/UserStatusIndicator';
|
import UserStatusIndicator from '../common/UserStatusIndicator';
|
||||||
|
import DirectMessageInput from './DirectMessageInput';
|
||||||
|
|
||||||
interface DirectMessage {
|
interface DirectMessage {
|
||||||
id: number;
|
id: number;
|
||||||
@ -18,6 +19,15 @@ interface DirectMessage {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
snippet?: any;
|
snippet?: any;
|
||||||
|
reply_to?: any;
|
||||||
|
attachments?: Array<{
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
original_filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
file_size: number;
|
||||||
|
uploaded_at: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectMessageViewProps {
|
interface DirectMessageViewProps {
|
||||||
@ -25,15 +35,15 @@ interface DirectMessageViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
|
const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
|
||||||
const { addToast } = useToast();
|
|
||||||
const { getUserStatus } = useUserStatus();
|
const { getUserStatus } = useUserStatus();
|
||||||
const [messages, setMessages] = useState<DirectMessage[]>([]);
|
const [messages, setMessages] = useState<DirectMessage[]>([]);
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const [lastReadIndex, setLastReadIndex] = useState<number | null>(null);
|
const [firstUnreadIndex, setFirstUnreadIndex] = useState<number | null>(null);
|
||||||
|
const { hasUnreadDirectMessage } = useUnreadMessages();
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
|
||||||
|
const [replyTo, setReplyTo] = useState<{ id: number; content: string; sender_username: string; sender_full_name?: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMessages();
|
loadMessages();
|
||||||
@ -89,29 +99,49 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// compute last-read index for direct messages using is_read or stored last-seen timestamp
|
// compute first-unread index for direct messages based on is_read or stored last-seen timestamp
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const lastSeenIso = localStorage.getItem(`dm_last_seen_${user.id}`);
|
const lastSeenIso = localStorage.getItem(`dm_last_seen_${user.id}`);
|
||||||
const lastSeen = lastSeenIso ? new Date(lastSeenIso) : null;
|
const lastSeen = lastSeenIso ? new Date(lastSeenIso) : null;
|
||||||
let idx: number | null = null;
|
let idx: number | null = null;
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
|
// First try to find an explicit unread message addressed to current user
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
const created = new Date(msg.created_at);
|
if (currentUser && msg.receiver_id === currentUser.id && !msg.is_read) {
|
||||||
// Consider message read if it's sent by current user, or marked is_read (i.e., receiver has read it),
|
|
||||||
// or its created_at is <= lastSeen timestamp
|
|
||||||
if (
|
|
||||||
(currentUser && msg.sender_id === currentUser.id) ||
|
|
||||||
msg.is_read ||
|
|
||||||
(lastSeen && created <= lastSeen)
|
|
||||||
) {
|
|
||||||
idx = i;
|
idx = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLastReadIndex(idx);
|
|
||||||
|
// If none found, fallback to timestamp-based detection
|
||||||
|
if (idx === null && lastSeen) {
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i];
|
||||||
|
const created = new Date(msg.created_at);
|
||||||
|
if (created > lastSeen && !(currentUser && msg.sender_id === currentUser.id)) {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If still none found but DM is marked unread in context, use first message from other user
|
||||||
|
if (idx === null && hasUnreadDirectMessage(user.id)) {
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (!(currentUser && msg.sender_id === currentUser.id)) {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// debug: expose helpful info to browser console to troubleshoot missing marker
|
||||||
|
// (temporary; can be removed once verified)
|
||||||
|
setFirstUnreadIndex(idx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLastReadIndex(null);
|
setFirstUnreadIndex(null);
|
||||||
}
|
}
|
||||||
}, [messages, user.id, currentUser]);
|
}, [messages, user.id, currentUser]);
|
||||||
|
|
||||||
@ -130,29 +160,13 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleDeleteMessage = async (messageId: number) => {
|
||||||
if (!content.trim()) return;
|
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
try {
|
try {
|
||||||
await directMessagesAPI.create({
|
await directMessagesAPI.deleteMessage(messageId);
|
||||||
content,
|
// Remove the message from the UI
|
||||||
receiver_id: user.id,
|
setMessages(messages.filter(m => m.id !== messageId));
|
||||||
});
|
|
||||||
|
|
||||||
setContent('');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to delete message:', error);
|
||||||
addToast('Failed to send message', 'error');
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -179,58 +193,181 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
|
|||||||
<>
|
<>
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50 dark:bg-gray-900">
|
<div className="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50 dark:bg-gray-900">
|
||||||
{messages.map((message) => {
|
{messages.map((message, index) => {
|
||||||
const isOwnMessage = message.sender_id === currentUser?.id;
|
const isOwnMessage = message.sender_id === currentUser?.id;
|
||||||
const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id;
|
const markerBefore = firstUnreadIndex !== null && firstUnreadIndex === index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={message.id}>
|
<React.Fragment key={message.id}>
|
||||||
<div className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}>
|
{markerBefore && (
|
||||||
|
<div className="flex items-center w-full my-3">
|
||||||
|
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
|
<div className="px-3 text-xs text-gray-500 dark:text-gray-400">Neue Nachrichten</div>
|
||||||
|
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
id={`message-${message.id}`}
|
||||||
|
className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`}
|
||||||
|
>
|
||||||
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
||||||
{/* Profile Picture / Initials */}
|
{message.sender_profile_picture ? (
|
||||||
{message.sender_profile_picture ? (
|
<img
|
||||||
<img
|
src={getApiUrl(message.sender_profile_picture)}
|
||||||
src={getApiUrl(message.sender_profile_picture)}
|
alt={message.sender_username}
|
||||||
alt={message.sender_username}
|
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
|
||||||
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
{getInitials(message.sender_full_name, message.sender_username)}
|
||||||
{getInitials(message.sender_full_name, message.sender_username)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Message Bubble */}
|
|
||||||
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
|
|
||||||
<div className="flex items-baseline space-x-2 mb-1">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<span className="font-semibold text-xs text-gray-900 dark:text-white">
|
|
||||||
{message.sender_full_name || message.sender_username}
|
|
||||||
</span>
|
|
||||||
{message.sender_id !== currentUser?.id && (
|
|
||||||
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
)}
|
||||||
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`px-3 py-1 rounded-lg ${
|
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'} relative`}>
|
||||||
isOwnMessage
|
<div className="flex items-baseline space-x-2 mb-1">
|
||||||
? 'bg-blue-500 bg-opacity-80 text-white rounded-br-none'
|
<div className="flex items-center space-x-1">
|
||||||
: 'bg-white bg-opacity-80 dark:bg-gray-700 dark:bg-opacity-20 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none'
|
<span className="font-semibold text-xs text-gray-900 dark:text-white">
|
||||||
}`}>
|
{message.sender_full_name || message.sender_username || 'Unknown'}
|
||||||
<div className="text-sm whitespace-pre-wrap break-words">
|
</span>
|
||||||
{message.content}
|
{message.sender_id !== currentUser?.id && (
|
||||||
|
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`px-1 py-1 rounded-lg relative ${
|
||||||
|
isOwnMessage
|
||||||
|
? 'bg-blue-500 bg-opacity-80 text-white rounded-br-none'
|
||||||
|
: 'bg-white bg-opacity-80 dark:bg-gray-700 dark:bg-opacity-20 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none'
|
||||||
|
}`}>
|
||||||
|
{/* Hover Action Buttons */}
|
||||||
|
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} top-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 px-2`}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setReplyTo({
|
||||||
|
id: message.id,
|
||||||
|
content: message.content,
|
||||||
|
sender_username: message.sender_username || 'Unknown',
|
||||||
|
sender_full_name: message.sender_full_name
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="p-1.5 bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full shadow-lg border border-gray-200 dark:border-gray-600"
|
||||||
|
title="Antworten"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenMenuId(openMenuId === message.id ? null : message.id)}
|
||||||
|
className="p-1.5 bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full shadow-lg border border-gray-200 dark:border-gray-600"
|
||||||
|
title="Mehr"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-gray-700 dark:text-gray-300" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openMenuId === message.id && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setOpenMenuId(null)}
|
||||||
|
/>
|
||||||
|
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} 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`}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(message.content);
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
{message.sender_id === currentUser?.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
handleDeleteMessage(message.id);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.reply_to && (
|
||||||
|
<div
|
||||||
|
className="mt-1 mb-1 pl-3 border-l-2 border-indigo-500 bg-gray-100 dark:bg-gray-800 p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{message.reply_to.sender_full_name || message.reply_to.sender_username}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||||
|
{message.reply_to.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.snippet && (
|
||||||
|
<div className="mt-2 p-2 bg-gray-900 dark:bg-gray-950 rounded border border-gray-700 dark:border-gray-800">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{message.snippet.language}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{message.snippet.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs text-gray-100 overflow-x-auto p-2 font-mono">
|
||||||
|
{message.snippet.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
{message.attachments.map((file) => (
|
||||||
|
<div key={file.id} className="rounded border border-gray-300 dark:border-gray-600 overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||||
|
<div className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700">
|
||||||
|
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||||
|
<span className="text-lg">📄</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<a
|
||||||
|
href={getApiUrl(`/files/${file.filename}`)}
|
||||||
|
download={file.original_filename}
|
||||||
|
className="text-xs font-medium text-gray-900 dark:text-white hover:underline truncate block"
|
||||||
|
>
|
||||||
|
{file.original_filename}
|
||||||
|
</a>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{(file.file_size / 1024).toFixed(2)} KB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.content && (
|
||||||
|
<div className="text-sm whitespace-pre-wrap break-words px-1">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{markerAfter && (
|
|
||||||
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -238,26 +375,12 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3">
|
<DirectMessageInput
|
||||||
<div className="flex items-end space-x-2">
|
userId={user.id}
|
||||||
<textarea
|
onMessageSent={loadMessages}
|
||||||
value={content}
|
replyTo={replyTo}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onCancelReply={() => setReplyTo(null)}
|
||||||
onKeyPress={handleKeyPress}
|
/>
|
||||||
placeholder="Type a message..."
|
|
||||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={sending || !content.trim()}
|
|
||||||
className="px-3 py-2 text-sm bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -91,7 +91,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-52 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 flex flex-col">
|
<div className="w-52 bg-white dark:bg-gray-800 border-l 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 flex items-center justify-between">
|
<div className="px-3 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Direct Messages</h3>
|
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Direct Messages</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { messagesAPI, filesAPI, getApiUrl } from '../../services/api';
|
|||||||
import type { Message } from '../../types';
|
import type { Message } from '../../types';
|
||||||
import CodeBlock from '../common/CodeBlock';
|
import CodeBlock from '../common/CodeBlock';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useUserStatus } from '../../contexts/UserStatusContext';
|
import { useUserStatus } from '../../contexts/UserStatusContext';
|
||||||
import UserStatusIndicator from '../common/UserStatusIndicator';
|
import UserStatusIndicator from '../common/UserStatusIndicator';
|
||||||
@ -17,7 +18,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
|||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const { getUserStatus } = useUserStatus();
|
const { getUserStatus } = useUserStatus();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [lastReadIndex, setLastReadIndex] = useState<number | null>(null);
|
const [firstUnreadIndex, setFirstUnreadIndex] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
|
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
|
||||||
@ -25,7 +26,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const MESSAGES_LIMIT = 15;
|
const MESSAGES_LIMIT = 10;
|
||||||
|
|
||||||
const getInitials = (fullName?: string, username?: string) => {
|
const getInitials = (fullName?: string, username?: string) => {
|
||||||
if (fullName) {
|
if (fullName) {
|
||||||
@ -120,31 +121,61 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// compute last-read index based on stored last-seen timestamp for this channel
|
const { hasUnreadChannel } = useUnreadMessages();
|
||||||
useEffect(() => {
|
|
||||||
|
// compute first-unread index based on stored last-seen timestamp for this channel
|
||||||
|
const computeFirstUnreadIndex = () => {
|
||||||
try {
|
try {
|
||||||
const lastSeenIso = localStorage.getItem(`channel_last_seen_${channelId}`);
|
const lastSeenIso = localStorage.getItem(`channel_last_seen_${channelId}`);
|
||||||
if (!lastSeenIso) {
|
if (!lastSeenIso) {
|
||||||
setLastReadIndex(null);
|
// if no stored timestamp but channel is marked unread, compute from messages
|
||||||
|
if (hasUnreadChannel(channelId)) {
|
||||||
|
let idx2: number | null = null;
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (!(user && msg.sender_id === user.id)) {
|
||||||
|
idx2 = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFirstUnreadIndex(idx2);
|
||||||
|
} else {
|
||||||
|
setFirstUnreadIndex(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lastSeen = new Date(lastSeenIso);
|
const lastSeen = new Date(lastSeenIso);
|
||||||
// find last message with created_at <= lastSeen or message sent by current user
|
|
||||||
let idx: number | null = null;
|
let idx: number | null = null;
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
const created = new Date(msg.created_at);
|
const created = new Date(msg.created_at);
|
||||||
if ((user && msg.sender_id === user.id) || created <= lastSeen) {
|
// consider unread if created after lastSeen and message is from another user
|
||||||
|
if (created > lastSeen && !(user && msg.sender_id === user.id)) {
|
||||||
idx = i;
|
idx = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLastReadIndex(idx);
|
setFirstUnreadIndex(idx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLastReadIndex(null);
|
setFirstUnreadIndex(null);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
computeFirstUnreadIndex();
|
||||||
}, [messages, channelId, user]);
|
}, [messages, channelId, user]);
|
||||||
|
|
||||||
|
// Listen for lastSeenUpdated event from UnreadMessagesContext to trigger recompute
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLastSeenUpdated = (event: CustomEvent) => {
|
||||||
|
if (event.detail && event.detail.channel_id === channelId) {
|
||||||
|
computeFirstUnreadIndex();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('lastSeenUpdated', handleLastSeenUpdated as EventListener);
|
||||||
|
return () => window.removeEventListener('lastSeenUpdated', handleLastSeenUpdated as EventListener);
|
||||||
|
}, [channelId, messages, user, hasUnreadChannel]);
|
||||||
|
|
||||||
const loadMessages = async (append = false) => {
|
const loadMessages = async (append = false) => {
|
||||||
try {
|
try {
|
||||||
const offset = append ? messages.length : 0;
|
const offset = append ? messages.length : 0;
|
||||||
@ -298,12 +329,12 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
|||||||
<span className="text-xs text-gray-500 dark:text-gray-400">Lade ältere Nachrichten...</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">Lade ältere Nachrichten...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((message) => {
|
{messages.map((message, index) => {
|
||||||
const isOwnMessage = user && message.sender_id === user.id;
|
const isOwnMessage = user && message.sender_id === user.id;
|
||||||
|
|
||||||
// Deleted message - simple text without bubble (check both deleted and is_deleted)
|
// Deleted message - simple text without bubble (check both deleted and is_deleted)
|
||||||
if (message.deleted || message.is_deleted) {
|
if (message.deleted || message.is_deleted) {
|
||||||
const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id;
|
const markerBefore = firstUnreadIndex !== null && firstUnreadIndex === index;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={message.id}>
|
<React.Fragment key={message.id}>
|
||||||
<div
|
<div
|
||||||
@ -342,16 +373,27 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{markerAfter && (
|
{markerBefore && (
|
||||||
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
|
<div className="flex items-center w-full my-3">
|
||||||
|
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
|
<div className="px-3 text-xs text-gray-500 dark:text-gray-400">Neue Nachrichten</div>
|
||||||
|
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id;
|
const markerBefore = firstUnreadIndex !== null && firstUnreadIndex === index;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={message.id}>
|
<React.Fragment key={message.id}>
|
||||||
|
{markerBefore && (
|
||||||
|
<div className="flex items-center w-full my-3">
|
||||||
|
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
|
<div className="px-3 text-xs text-gray-500 dark:text-gray-400">Neue Nachrichten</div>
|
||||||
|
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
id={`message-${message.id}`}
|
id={`message-${message.id}`}
|
||||||
className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`}
|
className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`}
|
||||||
@ -427,16 +469,18 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
|||||||
onClick={() => setOpenMenuId(null)}
|
onClick={() => setOpenMenuId(null)}
|
||||||
/>
|
/>
|
||||||
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} bottom-full mb-2 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 w-48`}>
|
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} 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`}>
|
||||||
<button
|
{!isOwnMessage && (
|
||||||
onClick={() => {
|
<button
|
||||||
// TODO: Implement private message
|
onClick={() => {
|
||||||
addToast('Private Nachricht an ' + message.sender_username, 'info');
|
// TODO: Implement private message
|
||||||
setOpenMenuId(null);
|
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"
|
}}
|
||||||
>
|
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"
|
||||||
Private Nachricht
|
>
|
||||||
</button>
|
Private Nachricht
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(message.content);
|
navigator.clipboard.writeText(message.content);
|
||||||
@ -786,9 +830,6 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{markerAfter && (
|
|
||||||
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -49,14 +49,14 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
<div className="w-64 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">
|
<div className="px-3 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Channels</h3>
|
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Channels</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{departments.map((dept) => (
|
{departments.map((dept) => (
|
||||||
<div key={dept.id} className="mb-3">
|
<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">
|
<div className="px-3 py-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -75,6 +75,11 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<span># {channel.name}</span>
|
<span># {channel.name}</span>
|
||||||
<BlinkingEnvelope hasNewMessages={hasUnreadChannel(channel.id)} />
|
<BlinkingEnvelope hasNewMessages={hasUnreadChannel(channel.id)} />
|
||||||
</div>
|
</div>
|
||||||
|
{channel.description && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||||
|
{channel.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{onDeleteChannel && (
|
{onDeleteChannel && (
|
||||||
|
|||||||
61
frontend/src/components/Common/ConfirmDialog.tsx
Normal file
61
frontend/src/components/Common/ConfirmDialog.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Bestätigen',
|
||||||
|
cancelText = 'Abbrechen',
|
||||||
|
onConfirm,
|
||||||
|
onCancel
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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-md w-full mx-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
96
frontend/src/components/Kanban/CreateCardDialog.tsx
Normal file
96
frontend/src/components/Kanban/CreateCardDialog.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface CreateCardDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateCardDialog: React.FC<CreateCardDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit
|
||||||
|
}) => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTitle('');
|
||||||
|
// Focus the input when dialog opens
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById('card-title-input');
|
||||||
|
if (input) (input as HTMLInputElement).focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (title.trim()) {
|
||||||
|
onSubmit(title.trim());
|
||||||
|
setTitle('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setTitle('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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-md w-full mx-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Neue Karte erstellen
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Kartentitel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="card-title-input"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSubmit(e as any);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="z.B. Feature implementieren"
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!title.trim()}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateCardDialog;
|
||||||
@ -5,9 +5,10 @@ interface KanbanArchiveModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
archivedCards: KanbanCard[];
|
archivedCards: KanbanCard[];
|
||||||
|
onSelectCard: (card: KanbanCard) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KanbanArchiveModal: React.FC<KanbanArchiveModalProps> = ({ isOpen, onClose, archivedCards }) => {
|
const KanbanArchiveModal: React.FC<KanbanArchiveModalProps> = ({ isOpen, onClose, archivedCards, onSelectCard }) => {
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||||
@ -68,7 +69,14 @@ const KanbanArchiveModal: React.FC<KanbanArchiveModalProps> = ({ isOpen, onClose
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{archivedCards.map((card) => (
|
{archivedCards.map((card) => (
|
||||||
<tr key={card.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr
|
||||||
|
key={card.id}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectCard(card);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{card.title}
|
{card.title}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { kanbanAPI, channelsAPI, departmentsAPI } from '../../services/api';
|
import { kanbanAPI, channelsAPI, departmentsAPI } from '../../services/api';
|
||||||
import type { KanbanBoardWithColumns, KanbanColumn, KanbanCard, Channel, Department } from '../../types';
|
import type { KanbanBoardWithColumns, KanbanColumn, KanbanCardExtended, Channel, Department } from '../../types';
|
||||||
import KanbanColumnComponent from './KanbanColumn';
|
import KanbanColumnComponent from './KanbanColumn';
|
||||||
import KanbanCardModal from './KanbanCardModal';
|
import KanbanCardModal from './KanbanCardModal';
|
||||||
import KanbanArchiveModal from './KanbanArchiveModal';
|
import KanbanArchiveModal from './KanbanArchiveModal';
|
||||||
import KanbanSidebar from './KanbanSidebar';
|
import KanbanSidebar from './KanbanSidebar';
|
||||||
|
import CreateCardDialog from './CreateCardDialog';
|
||||||
|
import ConfirmDialog from '../Common/ConfirmDialog';
|
||||||
|
|
||||||
const KanbanBoard: React.FC = () => {
|
const KanbanBoard: React.FC = () => {
|
||||||
const { channelId } = useParams<{ channelId: string }>();
|
const { channelId } = useParams<{ channelId: string }>();
|
||||||
@ -19,9 +21,14 @@ const KanbanBoard: React.FC = () => {
|
|||||||
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
|
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sidebarLoading, setSidebarLoading] = useState(true);
|
const [sidebarLoading, setSidebarLoading] = useState(true);
|
||||||
const [selectedCard, setSelectedCard] = useState<KanbanCard | null>(null);
|
const [selectedCard, setSelectedCard] = useState<KanbanCardExtended | null>(null);
|
||||||
const [showCardModal, setShowCardModal] = useState(false);
|
const [showCardModal, setShowCardModal] = useState(false);
|
||||||
const [showArchiveModal, setShowArchiveModal] = useState(false);
|
const [showArchiveModal, setShowArchiveModal] = useState(false);
|
||||||
|
const [archivedCards, setArchivedCards] = useState<any[]>([]);
|
||||||
|
const [showCreateCardDialog, setShowCreateCardDialog] = useState(false);
|
||||||
|
const [createCardColumnId, setCreateCardColumnId] = useState<number | null>(null);
|
||||||
|
const [showDeleteCardDialog, setShowDeleteCardDialog] = useState(false);
|
||||||
|
const [deleteCardId, setDeleteCardId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSidebarData();
|
loadSidebarData();
|
||||||
@ -72,13 +79,13 @@ const KanbanBoard: React.FC = () => {
|
|||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
// Board doesn't exist yet, create it with default columns
|
// Board doesn't exist yet, create it with default columns
|
||||||
try {
|
try {
|
||||||
await kanbanAPI.createBoard({ channel_id: parseInt(channelId) });
|
const newBoard = await kanbanAPI.createBoard({ channel_id: parseInt(channelId) });
|
||||||
|
|
||||||
// Create default columns
|
// Create default columns using the actual board ID from the response
|
||||||
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
|
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
|
||||||
for (let i = 0; i < defaultColumns.length; i++) {
|
for (let i = 0; i < defaultColumns.length; i++) {
|
||||||
await kanbanAPI.createColumn({
|
await kanbanAPI.createColumn({
|
||||||
board_id: parseInt(channelId), // This will be the board ID since we just created it
|
board_id: newBoard.id, // Use the actual board ID from the response
|
||||||
name: defaultColumns[i],
|
name: defaultColumns[i],
|
||||||
position: i
|
position: i
|
||||||
});
|
});
|
||||||
@ -147,16 +154,20 @@ const KanbanBoard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateCard = async (columnId: number) => {
|
const handleCreateCard = (columnId: number) => {
|
||||||
const cardTitle = prompt('Kartentitel eingeben:');
|
setCreateCardColumnId(columnId);
|
||||||
if (!cardTitle?.trim()) return;
|
setShowCreateCardDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCreateCard = async (cardTitle: string) => {
|
||||||
|
if (!createCardColumnId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const column = board?.columns.find(col => col.id === columnId);
|
const column = board?.columns.find(col => col.id === createCardColumnId);
|
||||||
if (!column) return;
|
if (!column) return;
|
||||||
|
|
||||||
const newCard = await kanbanAPI.createCard({
|
const newCard = await kanbanAPI.createCard({
|
||||||
column_id: columnId,
|
column_id: createCardColumnId,
|
||||||
title: cardTitle,
|
title: cardTitle,
|
||||||
position: column.cards.length
|
position: column.cards.length
|
||||||
});
|
});
|
||||||
@ -164,50 +175,67 @@ const KanbanBoard: React.FC = () => {
|
|||||||
setBoard(prev => prev ? {
|
setBoard(prev => prev ? {
|
||||||
...prev,
|
...prev,
|
||||||
columns: prev.columns.map(col =>
|
columns: prev.columns.map(col =>
|
||||||
col.id === columnId
|
col.id === createCardColumnId
|
||||||
? { ...col, cards: [...col.cards, newCard] }
|
? { ...col, cards: [...col.cards, newCard] }
|
||||||
: col
|
: col
|
||||||
)
|
)
|
||||||
} : null);
|
} : null);
|
||||||
|
|
||||||
addToast('Karte erstellt', 'success');
|
addToast('Karte erstellt', 'success');
|
||||||
|
setShowCreateCardDialog(false);
|
||||||
|
setCreateCardColumnId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast('Fehler beim Erstellen der Karte', 'error');
|
addToast('Fehler beim Erstellen der Karte', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCard = async (cardId: number, updates: Partial<KanbanCard>) => {
|
const handleUpdateCard = async (cardId: number, updates: Partial<KanbanCardExtended>) => {
|
||||||
try {
|
try {
|
||||||
await kanbanAPI.updateCard(cardId, updates);
|
await kanbanAPI.updateCard(cardId, updates);
|
||||||
setBoard(prev => prev ? {
|
|
||||||
...prev,
|
// If card is being restored from archive, reload the board to show it
|
||||||
columns: prev.columns.map(col => ({
|
if (updates.is_archived === false) {
|
||||||
...col,
|
setShowCardModal(false);
|
||||||
cards: col.cards.map(card =>
|
setSelectedCard(null);
|
||||||
card.id === cardId ? { ...card, ...updates } : card
|
loadBoard();
|
||||||
)
|
} else {
|
||||||
}))
|
setBoard(prev => prev ? {
|
||||||
} : null);
|
...prev,
|
||||||
} catch (error) {
|
columns: prev.columns.map(col => ({
|
||||||
addToast('Fehler beim Aktualisieren der Karte', 'error');
|
...col,
|
||||||
|
cards: col.cards.map(card =>
|
||||||
|
card.id === cardId ? { ...card, ...updates } : card
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
} : null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
addToast('Fehler beim Aktualisieren der Karte: ' + (error.response?.data?.detail || error.message), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCard = async (cardId: number) => {
|
const handleDeleteCard = (cardId: number) => {
|
||||||
if (!confirm('Karte wirklich löschen?')) return;
|
setDeleteCardId(cardId);
|
||||||
|
setShowDeleteCardDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDeleteCard = async () => {
|
||||||
|
if (!deleteCardId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await kanbanAPI.deleteCard(cardId);
|
await kanbanAPI.deleteCard(deleteCardId);
|
||||||
setBoard(prev => prev ? {
|
setBoard(prev => prev ? {
|
||||||
...prev,
|
...prev,
|
||||||
columns: prev.columns.map(col => ({
|
columns: prev.columns.map(col => ({
|
||||||
...col,
|
...col,
|
||||||
cards: col.cards.filter(card => card.id !== cardId)
|
cards: col.cards.filter(card => card.id !== deleteCardId)
|
||||||
}))
|
}))
|
||||||
} : null);
|
} : null);
|
||||||
addToast('Karte gelöscht', 'success');
|
addToast('Karte in Archiv verschoben', 'success');
|
||||||
|
setShowDeleteCardDialog(false);
|
||||||
|
setDeleteCardId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast('Fehler beim Löschen der Karte', 'error');
|
addToast('Fehler beim Archivieren der Karte', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,15 +281,32 @@ const KanbanBoard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardClick = (card: KanbanCard) => {
|
const handleCardClick = async (card: KanbanCardExtended) => {
|
||||||
setSelectedCard(card);
|
try {
|
||||||
setShowCardModal(true);
|
// Load the extended card with all features
|
||||||
|
const extendedCard = await kanbanAPI.getCardExtended(card.id);
|
||||||
|
setSelectedCard(extendedCard);
|
||||||
|
setShowCardModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load extended card:', error);
|
||||||
|
// Fallback to basic card if extended loading fails
|
||||||
|
setSelectedCard(card);
|
||||||
|
setShowCardModal(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getArchivedCards = () => {
|
const handleOpenArchiveModal = async () => {
|
||||||
if (!board) return [];
|
setShowArchiveModal(true);
|
||||||
const doneColumn = board.columns.find(col => col.name.toLowerCase() === 'done');
|
if (board) {
|
||||||
return doneColumn ? doneColumn.cards : [];
|
try {
|
||||||
|
const archived = await kanbanAPI.getArchivedCards(board.id);
|
||||||
|
setArchivedCards(archived);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load archived cards:', error);
|
||||||
|
addToast('Fehler beim Laden archivierter Karten', 'error');
|
||||||
|
setArchivedCards([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sidebarLoading) {
|
if (sidebarLoading) {
|
||||||
@ -295,31 +340,26 @@ const KanbanBoard: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2">
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-0.5 flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
{selectedChannel && (
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
<>
|
||||||
Kanban Board
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
</h2>
|
# {selectedChannel.name}
|
||||||
{selectedChannel && (
|
</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0 h-5">
|
||||||
#{selectedChannel.name}
|
{selectedChannel.description || ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
</>
|
||||||
{selectedChannel?.description && (
|
)}
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{selectedChannel.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowArchiveModal(true)}
|
|
||||||
className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
|
||||||
title="Archiv anzeigen"
|
|
||||||
>
|
|
||||||
Archiv
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenArchiveModal}
|
||||||
|
className="px-2 py-1 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors whitespace-nowrap"
|
||||||
|
title="Archiv anzeigen"
|
||||||
|
>
|
||||||
|
Archiv
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Board Content */}
|
{/* Board Content */}
|
||||||
@ -339,7 +379,7 @@ const KanbanBoard: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCardModal && selectedCard && (
|
{showCardModal && selectedCard && board && (
|
||||||
<KanbanCardModal
|
<KanbanCardModal
|
||||||
card={selectedCard}
|
card={selectedCard}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@ -356,9 +396,37 @@ const KanbanBoard: React.FC = () => {
|
|||||||
<KanbanArchiveModal
|
<KanbanArchiveModal
|
||||||
isOpen={showArchiveModal}
|
isOpen={showArchiveModal}
|
||||||
onClose={() => setShowArchiveModal(false)}
|
onClose={() => setShowArchiveModal(false)}
|
||||||
archivedCards={getArchivedCards()}
|
archivedCards={archivedCards}
|
||||||
|
onSelectCard={(card) => {
|
||||||
|
setSelectedCard(card as KanbanCardExtended);
|
||||||
|
setShowCardModal(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Card Dialog */}
|
||||||
|
<CreateCardDialog
|
||||||
|
isOpen={showCreateCardDialog}
|
||||||
|
onClose={() => {
|
||||||
|
setShowCreateCardDialog(false);
|
||||||
|
setCreateCardColumnId(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleConfirmCreateCard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Card Dialog */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showDeleteCardDialog}
|
||||||
|
title="Karte archivieren"
|
||||||
|
message="Möchten Sie diese Karte wirklich archivieren? Sie können sie später über das Archiv wiederherstellen."
|
||||||
|
confirmText="Archivieren"
|
||||||
|
cancelText="Abbrechen"
|
||||||
|
onConfirm={handleConfirmDeleteCard}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowDeleteCardDialog(false);
|
||||||
|
setDeleteCardId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getApiUrl } from '../../services/api';
|
import AccountIcon from 'mdi-react/AccountIcon';
|
||||||
import type { KanbanCard } from '../../types';
|
import type { KanbanCardExtended } from '../../types';
|
||||||
|
|
||||||
interface KanbanCardProps {
|
interface KanbanCardProps {
|
||||||
card: KanbanCard;
|
card: KanbanCardExtended;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onDelete: (cardId: number) => void;
|
onDelete: (cardId: number) => void;
|
||||||
sourceColumnId: number;
|
sourceColumnId: number;
|
||||||
@ -67,8 +67,8 @@ const KanbanCard: React.FC<KanbanCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Priority and Due Date */}
|
{/* Priority and Due Date */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{card.priority && (
|
{card.priority && (
|
||||||
<span className={`px-1.5 py-0.5 text-xs rounded border ${getPriorityColor(card.priority)}`}>
|
<span className={`px-1.5 py-0.5 text-xs rounded border ${getPriorityColor(card.priority)}`}>
|
||||||
{card.priority === 'high' ? 'Hoch' :
|
{card.priority === 'high' ? 'Hoch' :
|
||||||
@ -87,28 +87,52 @@ const KanbanCard: React.FC<KanbanCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assignee */}
|
{/* Assignee Name with Icon */}
|
||||||
{card.assignee && (
|
{card.assignee && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-1">
|
||||||
{card.assignee.profile_picture ? (
|
<AccountIcon size="1rem" className="text-gray-400" />
|
||||||
<img
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
src={getApiUrl(card.assignee.profile_picture)}
|
{card.assignee.full_name || card.assignee.username}
|
||||||
alt={card.assignee.username}
|
</span>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Feature Badges */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Comments */}
|
||||||
|
{card.comments_count > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
{card.comments_count}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
{card.attachments_count > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||||
|
</svg>
|
||||||
|
{card.attachments_count}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checklists */}
|
||||||
|
{card.checklists_count > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{card.checklists_count}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{card.labels && (
|
{card.labels && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { departmentsAPI, kanbanAPI } from '../../services/api';
|
import { departmentsAPI, kanbanAPI } from '../../services/api';
|
||||||
|
import ConfirmDialog from '../Common/ConfirmDialog';
|
||||||
import type { KanbanCard, User, Department, KanbanChecklistWithItems } from '../../types';
|
import type { KanbanCard, User, Department, KanbanChecklistWithItems } from '../../types';
|
||||||
|
|
||||||
const AddChecklistItemForm: React.FC<{ checklistId: number; onAdd: (checklistId: number, title: string) => void }> = ({ checklistId, onAdd }) => {
|
const AddChecklistItemForm: React.FC<{ checklistId: number; onAdd: (checklistId: number, title: string) => void }> = ({ checklistId, onAdd }) => {
|
||||||
@ -90,6 +91,19 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
|
|||||||
const [checklists, setChecklists] = useState<KanbanChecklistWithItems[]>([]);
|
const [checklists, setChecklists] = useState<KanbanChecklistWithItems[]>([]);
|
||||||
const [showChecklistForm, setShowChecklistForm] = useState(false);
|
const [showChecklistForm, setShowChecklistForm] = useState(false);
|
||||||
const [newChecklistTitle, setNewChecklistTitle] = useState('');
|
const [newChecklistTitle, setNewChecklistTitle] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'attachments' | 'comments' | 'activity'>('overview');
|
||||||
|
const [comments, setComments] = useState<any[]>([]);
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [attachments, setAttachments] = useState<any[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [postingComment, setPostingComment] = useState(false);
|
||||||
|
const [activity, setActivity] = useState<any[]>([]);
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Confirm Dialog States
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
const [deleteItemType, setDeleteItemType] = useState<'comment' | 'attachment' | null>(null);
|
||||||
|
const [deleteItemId, setDeleteItemId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAvailableUsers();
|
loadAvailableUsers();
|
||||||
@ -105,11 +119,14 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
|
|||||||
// Collect all users from these departments
|
// Collect all users from these departments
|
||||||
const userSet = new Map<number, User>();
|
const userSet = new Map<number, User>();
|
||||||
|
|
||||||
for (const _dept of departments) {
|
for (const dept of departments) {
|
||||||
// This is a simplified approach - in a real app you'd have an endpoint to get department users
|
try {
|
||||||
// For now, we'll just include the current user and maybe add more logic later
|
const deptUsers: User[] = await departmentsAPI.getUsers(dept.id);
|
||||||
if (user) {
|
for (const deptUser of deptUsers) {
|
||||||
userSet.set(user.id, user);
|
userSet.set(deptUser.id, deptUser);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load users for department ${dept.id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,6 +136,21 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'activity') {
|
||||||
|
loadActivity();
|
||||||
|
}
|
||||||
|
}, [activeTab, card.id]);
|
||||||
|
|
||||||
|
const loadActivity = async () => {
|
||||||
|
try {
|
||||||
|
const activityData = await kanbanAPI.getCardActivity(card.id);
|
||||||
|
setActivity(activityData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load activity:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-save functions for individual fields
|
// Auto-save functions for individual fields
|
||||||
const autoSaveTitle = () => {
|
const autoSaveTitle = () => {
|
||||||
if (title.trim() !== card.title) {
|
if (title.trim() !== card.title) {
|
||||||
@ -133,9 +165,9 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoSaveAssignee = () => {
|
const autoSavePriority = () => {
|
||||||
if (assigneeId !== card.assignee_id) {
|
if (priority !== (card.priority || 'medium')) {
|
||||||
onUpdate(card.id, { assignee_id: assigneeId });
|
onUpdate(card.id, { priority });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -146,12 +178,6 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoSavePriority = () => {
|
|
||||||
if (priority !== (card.priority || 'medium')) {
|
|
||||||
onUpdate(card.id, { priority });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoSaveLabels = () => {
|
const autoSaveLabels = () => {
|
||||||
const lbls = labels.trim() || undefined;
|
const lbls = labels.trim() || undefined;
|
||||||
if (lbls !== (card.labels || undefined)) {
|
if (lbls !== (card.labels || undefined)) {
|
||||||
@ -250,227 +276,619 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAvailableUsers();
|
loadAvailableUsers();
|
||||||
loadChecklists();
|
loadChecklists();
|
||||||
|
loadAttachments();
|
||||||
|
loadComments();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
try {
|
||||||
|
const data = await kanbanAPI.getCardComments(card.id);
|
||||||
|
setComments(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load comments:', error);
|
||||||
|
setComments([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePostComment = async () => {
|
||||||
|
if (!newComment.trim()) return;
|
||||||
|
|
||||||
|
setPostingComment(true);
|
||||||
|
try {
|
||||||
|
const comment = await kanbanAPI.createComment({
|
||||||
|
card_id: card.id,
|
||||||
|
content: newComment.trim()
|
||||||
|
});
|
||||||
|
setComments(prev => [...prev, comment]);
|
||||||
|
setNewComment('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to post comment:', error);
|
||||||
|
alert('Fehler beim Posten des Kommentars');
|
||||||
|
} finally {
|
||||||
|
setPostingComment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = (commentId: number) => {
|
||||||
|
setDeleteItemType('comment');
|
||||||
|
setDeleteItemId(commentId);
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAttachments = async () => {
|
||||||
|
try {
|
||||||
|
const data = await kanbanAPI.getCardAttachments(card.id);
|
||||||
|
setAttachments(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load attachments:', error);
|
||||||
|
setAttachments([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadAttachment = async (file: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const newAttachment = await kanbanAPI.uploadAttachment(card.id, file);
|
||||||
|
setAttachments(prev => [...prev, newAttachment]);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload attachment:', error);
|
||||||
|
alert('Fehler beim Hochladen der Datei');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAttachment = (attachmentId: number) => {
|
||||||
|
setDeleteItemType('attachment');
|
||||||
|
setDeleteItemId(attachmentId);
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!deleteItemId || !deleteItemType) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (deleteItemType === 'attachment') {
|
||||||
|
await kanbanAPI.deleteAttachment(deleteItemId);
|
||||||
|
setAttachments(prev => prev.filter(a => a.id !== deleteItemId));
|
||||||
|
} else if (deleteItemType === 'comment') {
|
||||||
|
await kanbanAPI.deleteComment(deleteItemId);
|
||||||
|
setComments(prev => prev.filter(c => c.id !== deleteItemId));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete item:', error);
|
||||||
|
} finally {
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setDeleteItemType(null);
|
||||||
|
setDeleteItemId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAttachment = async (attachment: any) => {
|
||||||
|
try {
|
||||||
|
const blob = await kanbanAPI.downloadAttachment(attachment.id);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = attachment.original_filename || attachment.filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download attachment:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div
|
||||||
<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">
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-xl w-[900px] mx-4 h-[85vh] overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between p-3 border-b border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
onBlur={autoSaveTitle}
|
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"
|
className="flex-1 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 resize-none overflow-y-auto max-h-20"
|
||||||
placeholder="Kartentitel eingeben..."
|
placeholder="Kartentitel eingeben..."
|
||||||
|
style={{ minHeight: '2rem' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{card.is_archived && (
|
||||||
|
<button
|
||||||
|
onClick={() => onUpdate(card.id, { is_archived: false })}
|
||||||
|
className="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900 rounded-lg transition-colors"
|
||||||
|
title="Karte wiederherstellen"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Navigation */}
|
||||||
|
<div className="flex border-b border-gray-300 dark:border-gray-700 overflow-x-auto bg-white dark:bg-gray-800">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={() => setActiveTab('overview')}
|
||||||
className="text-gray-400 hover:text-gray-600 p-1"
|
className={`px-4 py-3 font-medium whitespace-nowrap transition-colors ${
|
||||||
|
activeTab === 'overview'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Details & Einstellungen
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
</button>
|
||||||
</svg>
|
<button
|
||||||
|
onClick={() => setActiveTab('attachments')}
|
||||||
|
className={`px-4 py-3 font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
|
||||||
|
activeTab === 'attachments'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Anhänge
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<span className="text-xs bg-gray-300 dark:bg-gray-600 px-1.5 py-0.5 rounded-full">
|
||||||
|
{attachments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('comments')}
|
||||||
|
className={`px-4 py-3 font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
|
||||||
|
activeTab === 'comments'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Kommentare
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<span className="text-xs bg-gray-300 dark:bg-gray-600 px-1.5 py-0.5 rounded-full">
|
||||||
|
{comments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('activity')}
|
||||||
|
className={`px-4 py-3 font-medium whitespace-nowrap transition-colors ${
|
||||||
|
activeTab === 'activity'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Aktivität
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-4 bg-gray-50 dark:bg-gray-800">
|
||||||
{/* Description */}
|
{/* Overview Tab - Details & Einstellungen */}
|
||||||
<div>
|
{activeTab === 'overview' && (
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<div className="space-y-3">
|
||||||
Beschreibung
|
{/* Description */}
|
||||||
</label>
|
<div className="bg-white dark:bg-gray-700 p-3 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
<textarea
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
value={description}
|
Beschreibung
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
</label>
|
||||||
onBlur={autoSaveDescription}
|
<textarea
|
||||||
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"
|
value={description}
|
||||||
rows={4}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Beschreibung hinzufügen..."
|
onBlur={autoSaveDescription}
|
||||||
/>
|
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 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
</div>
|
rows={4}
|
||||||
|
placeholder="Beschreibung hinzufügen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Priority */}
|
{/* Priority & Due Date */}
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-4 bg-white dark:bg-gray-700 p-3 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<div>
|
||||||
Priorität
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
</label>
|
Priorität
|
||||||
<select
|
</label>
|
||||||
value={priority}
|
<select
|
||||||
onChange={(e) => {
|
value={priority}
|
||||||
setPriority(e.target.value as 'low' | 'medium' | 'high');
|
onChange={(e) => {
|
||||||
autoSavePriority();
|
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"
|
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 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
Abbrechen
|
<option value="low">Niedrig</option>
|
||||||
</button>
|
<option value="medium">Mittel</option>
|
||||||
|
<option value="high">Hoch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="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 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Checklists */}
|
{/* Assignee & Labels */}
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-2 gap-4 bg-white dark:bg-gray-700 p-3 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
{checklists.map((checklist) => (
|
<div>
|
||||||
<div key={checklist.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<div className="flex items-center justify-between mb-3">
|
Zugewiesen an
|
||||||
<h4 className="font-medium text-gray-900 dark:text-white">{checklist.title}</h4>
|
</label>
|
||||||
<button
|
<select
|
||||||
onClick={() => handleDeleteChecklist(checklist.id)}
|
value={assigneeId || ''}
|
||||||
className="text-gray-400 hover:text-red-500 p-1"
|
onChange={(e) => {
|
||||||
title="Checkliste löschen"
|
const newAssigneeId = e.target.value ? parseInt(e.target.value) : undefined;
|
||||||
>
|
setAssigneeId(newAssigneeId);
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
// Check and save immediately with new value
|
||||||
<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" />
|
if (newAssigneeId !== card.assignee_id) {
|
||||||
</svg>
|
onUpdate(card.id, { assignee_id: newAssigneeId });
|
||||||
</button>
|
}
|
||||||
|
}}
|
||||||
|
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 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="z.B. bug, feature, urgent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Checklist Items */}
|
{/* Checklists */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
{checklist.items.map((item) => (
|
{checklists.map((checklist) => (
|
||||||
<div key={item.id} className="flex items-center gap-2">
|
<div key={checklist.id} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-gray-700">
|
||||||
<input
|
<div className="flex items-center justify-between mb-3">
|
||||||
type="checkbox"
|
<h4 className="font-medium text-gray-900 dark:text-white">{checklist.title}</h4>
|
||||||
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
|
<button
|
||||||
onClick={() => handleDeleteChecklistItem(item.id)}
|
onClick={() => handleDeleteChecklist(checklist.id)}
|
||||||
className="text-gray-400 hover:text-red-500 p-1"
|
className="text-gray-400 hover:text-red-500 p-1"
|
||||||
title="Aufgabe löschen"
|
title="Checkliste löschen"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Add new item */}
|
{/* Checklist Items */}
|
||||||
<AddChecklistItemForm checklistId={checklist.id} onAdd={handleCreateChecklistItem} />
|
<div className="space-y-2">
|
||||||
</div>
|
{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>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Add new item */}
|
||||||
<div className="mt-3 text-xs text-gray-500">
|
<AddChecklistItemForm checklistId={checklist.id} onAdd={handleCreateChecklistItem} />
|
||||||
{checklist.items.filter(item => item.is_completed).length} von {checklist.items.length} Aufgaben erledigt
|
</div>
|
||||||
</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>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{checklists.length === 0 && !showChecklistForm && (
|
{/* Attachments Tab */}
|
||||||
|
{activeTab === 'attachments' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Upload Area */}
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-gray-400 dark:border-gray-600 rounded-lg p-4 text-center hover:border-blue-500 dark:hover:border-blue-400 transition-colors cursor-pointer bg-white dark:bg-gray-700"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleUploadAttachment(files[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
handleUploadAttachment(e.target.files[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium mb-1">
|
||||||
|
{uploading ? 'Wird hochgeladen...' : 'Datei hochladen'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{uploading ? 'Bitte warten...' : 'Klicken oder ziehen'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Hochgeladene Dateien ({attachments.length})
|
||||||
|
</h3>
|
||||||
|
{attachments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
Noch keine Anhänge vorhanden
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments.map((attachment: any) => (
|
||||||
|
<div key={attachment.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<svg className="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{attachment.filename || attachment.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{attachment.created_at ? new Date(attachment.created_at).toLocaleDateString('de-DE') : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadAttachment(attachment)}
|
||||||
|
className="text-gray-400 hover:text-blue-500 p-2"
|
||||||
|
title="Anhang herunterladen"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteAttachment(attachment.id)}
|
||||||
|
className="text-gray-400 hover:text-red-500 p-2"
|
||||||
|
title="Anhang 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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kommentare Tab */}
|
||||||
|
{activeTab === 'comments' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Add Comment Form */}
|
||||||
|
<div className="border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-gray-700">
|
||||||
|
<textarea
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
placeholder="Schreiben Sie einen Kommentar..."
|
||||||
|
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 text-sm resize-none"
|
||||||
|
rows={3}
|
||||||
|
disabled={postingComment}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePostComment}
|
||||||
|
disabled={postingComment || !newComment.trim()}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{postingComment ? 'Wird gepostet...' : 'Kommentar hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
Noch keine Kommentare vorhanden. Schreiben Sie den ersten Kommentar!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comments.map((comment: any) => (
|
||||||
|
<div key={comment.id} className="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-700">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm text-gray-900 dark:text-white">
|
||||||
|
{comment.user?.username || comment.user?.full_name || 'Unbekannter Nutzer'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{comment.created_at ? new Date(comment.created_at).toLocaleString('de-DE') : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{user?.username === comment.user?.username && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteComment(comment.id)}
|
||||||
|
className="text-gray-400 hover:text-red-500 p-1"
|
||||||
|
title="Kommentar 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>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 break-words">
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aktivität Tab */}
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activity.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
Keine Checklisten vorhanden. Klicke auf "+ Checkliste" um eine hinzuzufügen.
|
Keine Aktivität vorhanden
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activity.map((log, index) => (
|
||||||
|
<div key={index} className="flex gap-3 pb-3 border-b border-gray-300 dark:border-gray-700 last:border-b-0 bg-white dark:bg-gray-700 p-3 rounded-lg">
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white capitalize">
|
||||||
|
{log.action === 'updated' && `${log.field_name} aktualisiert`}
|
||||||
|
{log.action === 'moved' && `Zu ${log.new_value} verschoben`}
|
||||||
|
{log.action === 'created' && 'Karte erstellt'}
|
||||||
|
{log.action === 'archived' && 'Karte archiviert'}
|
||||||
|
{!['updated', 'moved', 'created', 'archived'].includes(log.action) && log.action}
|
||||||
|
</p>
|
||||||
|
{log.action === 'updated' && log.old_value && log.new_value && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Von <span className="font-semibold">{log.old_value}</span> zu <span className="font-semibold">{log.new_value}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{new Date(log.created_at).toLocaleString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div className="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-4 mt-6">
|
||||||
Erstellt: {new Date(card.created_at).toLocaleString('de-DE')}
|
Erstellt: {new Date(card.created_at).toLocaleString('de-DE')}
|
||||||
{card.updated_at !== card.created_at && (
|
{card.updated_at !== card.created_at && (
|
||||||
<span className="ml-4">
|
<span className="ml-4">
|
||||||
@ -480,6 +898,21 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Dialog */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showConfirmDialog}
|
||||||
|
title={deleteItemType === 'attachment' ? 'Anhang löschen' : 'Kommentar löschen'}
|
||||||
|
message={deleteItemType === 'attachment' ? 'Möchten Sie diese Datei wirklich löschen?' : 'Möchten Sie diesen Kommentar wirklich löschen?'}
|
||||||
|
confirmText="Löschen"
|
||||||
|
cancelText="Abbrechen"
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setDeleteItemType(null);
|
||||||
|
setDeleteItemId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { KanbanColumnWithCards, KanbanCard } from '../../types';
|
import type { KanbanColumnWithCards, KanbanCardExtended } from '../../types';
|
||||||
import KanbanCardComponent from './KanbanCard';
|
import KanbanCardComponent from './KanbanCard';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
@ -9,7 +9,7 @@ interface KanbanColumnProps {
|
|||||||
onCreateCard: (columnId: number) => void;
|
onCreateCard: (columnId: number) => void;
|
||||||
onDeleteCard: (cardId: number) => void;
|
onDeleteCard: (cardId: number) => void;
|
||||||
onMoveCard: (cardId: number, targetColumnId: number, newPosition: number) => void;
|
onMoveCard: (cardId: number, targetColumnId: number, newPosition: number) => void;
|
||||||
onCardClick: (card: KanbanCard) => void;
|
onCardClick: (card: KanbanCardExtended) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KanbanColumn: React.FC<KanbanColumnProps> = ({
|
const KanbanColumn: React.FC<KanbanColumnProps> = ({
|
||||||
@ -87,8 +87,8 @@ const KanbanColumn: React.FC<KanbanColumnProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 min-w-72 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-3 ${
|
className={`flex-1 min-w-72 bg-white/70 dark:bg-gray-800/50 border border-gray-200/50 dark:border-gray-700/50 rounded-md p-3 ${
|
||||||
draggedOver ? 'ring-1 ring-blue-500 bg-blue-50 dark:bg-blue-900/10' : ''
|
draggedOver ? 'ring-1 ring-blue-500 bg-blue-50/50 dark:bg-blue-900/20' : ''
|
||||||
}`}
|
}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
|||||||
@ -37,14 +37,14 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
<div className="w-64 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">
|
<div className="px-3 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Kanban Boards</h3>
|
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Kanban Boards</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{departments.map((dept) => (
|
{departments.map((dept) => (
|
||||||
<div key={dept.id} className="mb-3">
|
<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">
|
<div className="px-3 py-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-gray-500 dark:text-gray-400 mr-2">#</span>
|
<span className="text-gray-500 dark:text-gray-400 mr-1">#</span>
|
||||||
<span className="truncate">{channel.name}</span>
|
<span className="truncate">{channel.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{channel.description && (
|
{channel.description && (
|
||||||
|
|||||||
@ -104,6 +104,16 @@ const Layout: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Chat
|
Chat
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/kanban"
|
||||||
|
className={`px-3 py-1.5 text-sm rounded ${
|
||||||
|
location.pathname.startsWith('/kanban')
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Kanban
|
||||||
|
</Link>
|
||||||
{(isAdmin(user) || hasSnippetAccess) && (
|
{(isAdmin(user) || hasSnippetAccess) && (
|
||||||
<Link
|
<Link
|
||||||
to="/snippets"
|
to="/snippets"
|
||||||
@ -116,16 +126,6 @@ const Layout: React.FC = () => {
|
|||||||
Snippets
|
Snippets
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
|
||||||
to="/kanban"
|
|
||||||
className={`px-3 py-1.5 text-sm rounded ${
|
|
||||||
location.pathname.startsWith('/kanban')
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Kanban
|
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -9,11 +9,24 @@ interface BlinkingEnvelopeProps {
|
|||||||
const BlinkingEnvelope: React.FC<BlinkingEnvelopeProps> = ({ hasNewMessages, className = '' }) => {
|
const BlinkingEnvelope: React.FC<BlinkingEnvelopeProps> = ({ hasNewMessages, className = '' }) => {
|
||||||
if (!hasNewMessages) return null;
|
if (!hasNewMessages) return null;
|
||||||
|
|
||||||
|
const blinkingStyle = `
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 49% { opacity: 1; }
|
||||||
|
50%, 100% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
.blinking-envelope {
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarkChatUnread
|
<>
|
||||||
className={`text-gray-900 dark:text-white animate-pulse ${className}`}
|
<style>{blinkingStyle}</style>
|
||||||
style={{ fontSize: '18px' }}
|
<MarkChatUnread
|
||||||
/>
|
className={`text-gray-900 dark:text-white blinking-envelope ${className}`}
|
||||||
|
style={{ fontSize: '18px' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -100,6 +100,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
if (data.type === 'message' || data.type === 'direct_message') {
|
if (data.type === 'message' || data.type === 'direct_message') {
|
||||||
// Dispatch custom event for unread messages
|
// Dispatch custom event for unread messages
|
||||||
window.dispatchEvent(new CustomEvent('unreadMessage', { detail: data }));
|
window.dispatchEvent(new CustomEvent('unreadMessage', { detail: data }));
|
||||||
|
} else if (data.type === 'read_marker') {
|
||||||
|
// Dispatch read marker events for syncing last-seen across sessions
|
||||||
|
window.dispatchEvent(new CustomEvent('readMarker', { detail: data }));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing presence WebSocket message:', error);
|
console.error('Error parsing presence WebSocket message:', error);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
|
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
|
import { lastSeenAPI } from '../services/api';
|
||||||
|
|
||||||
interface UnreadMessagesContextType {
|
interface UnreadMessagesContextType {
|
||||||
unreadChannels: Set<number>;
|
unreadChannels: Set<number>;
|
||||||
@ -61,6 +62,53 @@ export const UnreadMessagesProvider: React.FC<UnreadMessagesProviderProps> = ({
|
|||||||
};
|
};
|
||||||
}, [user, activeChannelId, activeDirectMessageUserId]);
|
}, [user, activeChannelId, activeDirectMessageUserId]);
|
||||||
|
|
||||||
|
// Listen for read_marker events to sync last-seen across sessions (and own sessions)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleReadMarker = (event: CustomEvent) => {
|
||||||
|
const data = event.detail;
|
||||||
|
try {
|
||||||
|
if (!data) return;
|
||||||
|
// If it's a channel read marker (from any user, including own)
|
||||||
|
if (data.channel_id && data.user_id) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`channel_last_seen_${data.channel_id}`, data.last_seen);
|
||||||
|
// Dispatch a custom event so MessageList knows to recompute firstUnreadIndex
|
||||||
|
window.dispatchEvent(new CustomEvent('lastSeenUpdated', {
|
||||||
|
detail: { channel_id: data.channel_id }
|
||||||
|
}));
|
||||||
|
} catch (e) {}
|
||||||
|
// Remove unread flag for that channel if present
|
||||||
|
setUnreadChannels(prev => {
|
||||||
|
const copy = new Set(prev);
|
||||||
|
if (copy.has(data.channel_id)) copy.delete(data.channel_id);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a DM read marker (from any user, including own)
|
||||||
|
if (data.dm_user_id && data.user_id) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`dm_last_seen_${data.user_id}`, data.last_seen);
|
||||||
|
// Dispatch a custom event so DirectMessageView knows to recompute
|
||||||
|
window.dispatchEvent(new CustomEvent('lastSeenUpdated', {
|
||||||
|
detail: { dm_user_id: data.user_id }
|
||||||
|
}));
|
||||||
|
} catch (e) {}
|
||||||
|
setUnreadDirectMessages(prev => {
|
||||||
|
const copy = new Set(prev);
|
||||||
|
if (copy.has(data.user_id)) copy.delete(data.user_id);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('readMarker', handleReadMarker as EventListener);
|
||||||
|
return () => window.removeEventListener('readMarker', handleReadMarker as EventListener);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const markChannelAsRead = (channelId: number) => {
|
const markChannelAsRead = (channelId: number) => {
|
||||||
setUnreadChannels(prev => {
|
setUnreadChannels(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@ -68,8 +116,13 @@ export const UnreadMessagesProvider: React.FC<UnreadMessagesProviderProps> = ({
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// store last seen timestamp locally so MessageList can render a read marker
|
const iso = new Date().toISOString();
|
||||||
localStorage.setItem(`channel_last_seen_${channelId}`, new Date().toISOString());
|
// best-effort: persist to server, fallback to localStorage
|
||||||
|
lastSeenAPI.setLastSeen({ channel_id: channelId, last_seen: iso }).catch(() => {
|
||||||
|
localStorage.setItem(`channel_last_seen_${channelId}`, iso);
|
||||||
|
});
|
||||||
|
// also keep local copy immediately
|
||||||
|
localStorage.setItem(`channel_last_seen_${channelId}`, iso);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore storage errors
|
// ignore storage errors
|
||||||
}
|
}
|
||||||
@ -86,8 +139,11 @@ export const UnreadMessagesProvider: React.FC<UnreadMessagesProviderProps> = ({
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// store last seen timestamp for direct messages
|
const iso = new Date().toISOString();
|
||||||
localStorage.setItem(`dm_last_seen_${userId}`, new Date().toISOString());
|
lastSeenAPI.setLastSeen({ dm_user_id: userId, last_seen: iso }).catch(() => {
|
||||||
|
localStorage.setItem(`dm_last_seen_${userId}`, iso);
|
||||||
|
});
|
||||||
|
localStorage.setItem(`dm_last_seen_${userId}`, iso);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,6 +84,11 @@ export const departmentsAPI = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getUsers: async (departmentId: number) => {
|
||||||
|
const response = await api.get(`/departments/${departmentId}/users`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
create: async (data: { name: string; description?: string }) => {
|
create: async (data: { name: string; description?: string }) => {
|
||||||
const response = await api.post('/departments/', data);
|
const response = await api.post('/departments/', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -258,10 +263,26 @@ export const directMessagesAPI = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (data: { content: string; receiver_id: number; snippet_id?: number }) => {
|
create: async (data: { content: string; receiver_id: number; snippet_id?: number; reply_to_id?: number }) => {
|
||||||
const response = await api.post('/direct-messages/', data);
|
const response = await api.post('/direct-messages/', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteMessage: async (messageId: number) => {
|
||||||
|
const response = await api.delete(`/direct-messages/${messageId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lastSeenAPI = {
|
||||||
|
setLastSeen: async (data: { channel_id?: number; dm_user_id?: number; last_seen?: string }) => {
|
||||||
|
const response = await api.post('/me/last-seen/', null, { params: data });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
getLastSeen: async (params: { channel_id?: number; dm_user_id?: number }) => {
|
||||||
|
const response = await api.get('/me/last-seen/', { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const kanbanAPI = {
|
export const kanbanAPI = {
|
||||||
@ -276,6 +297,11 @@ export const kanbanAPI = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getBoardById: async (boardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/boards/by-id/${boardId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
updateBoard: async (boardId: number, data: { name?: string }) => {
|
updateBoard: async (boardId: number, data: { name?: string }) => {
|
||||||
const response = await api.put(`/kanban/boards/${boardId}`, data);
|
const response = await api.put(`/kanban/boards/${boardId}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -330,6 +356,11 @@ export const kanbanAPI = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getArchivedCards: async (boardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/boards/${boardId}/archived-cards`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
moveCard: async (cardId: number, targetColumnId: number, newPosition: number) => {
|
moveCard: async (cardId: number, targetColumnId: number, newPosition: number) => {
|
||||||
const response = await api.put(`/kanban/cards/${cardId}/move`, null, {
|
const response = await api.put(`/kanban/cards/${cardId}/move`, null, {
|
||||||
params: { target_column_id: targetColumnId, new_position: newPosition }
|
params: { target_column_id: targetColumnId, new_position: newPosition }
|
||||||
@ -378,6 +409,173 @@ export const kanbanAPI = {
|
|||||||
const response = await api.delete(`/kanban/checklist-items/${itemId}`);
|
const response = await api.delete(`/kanban/checklist-items/${itemId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Comment API
|
||||||
|
createComment: async (data: { card_id: number; content: string }) => {
|
||||||
|
const response = await api.post(`/kanban/cards/${data.card_id}/comments`, { content: data.content });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCardComments: async (cardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/cards/${cardId}/comments`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCardActivity: async (cardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/cards/${cardId}/activity`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateComment: async (commentId: number, data: { content?: string }) => {
|
||||||
|
const response = await api.put(`/kanban/comments/${commentId}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteComment: async (commentId: number) => {
|
||||||
|
const response = await api.delete(`/kanban/comments/${commentId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Attachment API
|
||||||
|
uploadAttachment: async (cardId: number, file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const response = await api.post(`/kanban/cards/${cardId}/attachments`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCardAttachments: async (cardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/cards/${cardId}/attachments`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAttachment: async (attachmentId: number) => {
|
||||||
|
const response = await api.delete(`/kanban/attachments/${attachmentId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadAttachment: async (attachmentId: number) => {
|
||||||
|
const response = await api.get(`/kanban/attachments/${attachmentId}/download`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Time Tracking API
|
||||||
|
startTimeTracking: async (data: { card_id: number; description?: string }) => {
|
||||||
|
const response = await api.post('/kanban/cards/time/start', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
stopTimeTracking: async (entryId: number) => {
|
||||||
|
const response = await api.put(`/kanban/time/${entryId}/stop`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCardTimeEntries: async (cardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/cards/${cardId}/time`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Custom Fields API
|
||||||
|
createCustomField: async (data: {
|
||||||
|
board_id: number;
|
||||||
|
name: string;
|
||||||
|
field_type: string;
|
||||||
|
options?: string;
|
||||||
|
is_required?: boolean;
|
||||||
|
position: number;
|
||||||
|
}) => {
|
||||||
|
const response = await api.post(`/kanban/boards/${data.board_id}/custom-fields`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getBoardCustomFields: async (boardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/boards/${boardId}/custom-fields`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCustomField: async (fieldId: number, data: {
|
||||||
|
name?: string;
|
||||||
|
field_type?: string;
|
||||||
|
options?: string;
|
||||||
|
is_required?: boolean;
|
||||||
|
position?: number;
|
||||||
|
}) => {
|
||||||
|
const response = await api.put(`/kanban/custom-fields/${fieldId}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCustomField: async (fieldId: number) => {
|
||||||
|
const response = await api.delete(`/kanban/custom-fields/${fieldId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCustomFieldValue: async (cardId: number, fieldId: number, value: string) => {
|
||||||
|
const response = await api.put(`/kanban/cards/${cardId}/custom-fields/${fieldId}`, { value });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCardCustomFieldValues: async (cardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/cards/${cardId}/custom-fields`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Templates API
|
||||||
|
createTemplate: async (data: {
|
||||||
|
board_id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
template_data: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
}) => {
|
||||||
|
const response = await api.post(`/kanban/boards/${data.board_id}/templates`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getBoardTemplates: async (boardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/boards/${boardId}/templates`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createCardFromTemplate: async (templateId: number, columnId: number) => {
|
||||||
|
const response = await api.post(`/kanban/cards/from-template/${templateId}`, null, {
|
||||||
|
params: { column_id: columnId }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bulk Operations API
|
||||||
|
bulkMoveCards: async (data: { card_ids: number[]; column_id: number; position?: number }) => {
|
||||||
|
const response = await api.post('/kanban/cards/bulk/move', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkDeleteCards: async (data: { card_ids: number[] }) => {
|
||||||
|
const response = await api.delete('/kanban/cards/bulk', { data });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search and Filter API
|
||||||
|
searchCards: async (boardId: number, params: {
|
||||||
|
q?: string;
|
||||||
|
assignee_id?: number;
|
||||||
|
priority?: string;
|
||||||
|
labels?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await api.get(`/kanban/boards/${boardId}/search`, { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Extended Card API
|
||||||
|
getCardExtended: async (cardId: number) => {
|
||||||
|
const response = await api.get(`/kanban/cards/${cardId}/extended`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userStatusAPI = {
|
export const userStatusAPI = {
|
||||||
|
|||||||
@ -169,9 +169,15 @@ export interface KanbanCard {
|
|||||||
due_date?: string;
|
due_date?: string;
|
||||||
priority?: 'low' | 'medium' | 'high';
|
priority?: 'low' | 'medium' | 'high';
|
||||||
labels?: string;
|
labels?: string;
|
||||||
|
estimated_time?: number;
|
||||||
|
actual_time?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
assignee?: User;
|
assignee?: User;
|
||||||
|
attachments_count: number;
|
||||||
|
checklists_count: number;
|
||||||
|
comments_count: number;
|
||||||
|
is_archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KanbanBoardWithColumns extends KanbanBoard {
|
export interface KanbanBoardWithColumns extends KanbanBoard {
|
||||||
@ -179,7 +185,7 @@ export interface KanbanBoardWithColumns extends KanbanBoard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface KanbanColumnWithCards extends KanbanColumn {
|
export interface KanbanColumnWithCards extends KanbanColumn {
|
||||||
cards: KanbanCard[];
|
cards: KanbanCardExtended[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checklist Types
|
// Checklist Types
|
||||||
@ -210,3 +216,89 @@ export interface KanbanCardWithChecklists extends KanbanCard {
|
|||||||
checklists: KanbanChecklistWithItems[];
|
checklists: KanbanChecklistWithItems[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comment Types
|
||||||
|
export interface KanbanCardComment {
|
||||||
|
id: number;
|
||||||
|
card_id: number;
|
||||||
|
user_id: number;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment Types
|
||||||
|
export interface KanbanCardAttachment {
|
||||||
|
id: number;
|
||||||
|
card_id: number;
|
||||||
|
filename: string;
|
||||||
|
original_filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
file_size: number;
|
||||||
|
file_path: string;
|
||||||
|
uploader_id: number;
|
||||||
|
uploaded_at: string;
|
||||||
|
uploader?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time Tracking Types
|
||||||
|
export interface KanbanTimeEntry {
|
||||||
|
id: number;
|
||||||
|
card_id: number;
|
||||||
|
user_id: number;
|
||||||
|
description?: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string;
|
||||||
|
duration_minutes?: number;
|
||||||
|
is_running: boolean;
|
||||||
|
created_at: string;
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Field Types
|
||||||
|
export interface KanbanCustomField {
|
||||||
|
id: number;
|
||||||
|
board_id: number;
|
||||||
|
name: string;
|
||||||
|
field_type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox';
|
||||||
|
options?: string; // JSON string for select options
|
||||||
|
is_required: boolean;
|
||||||
|
position: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanCustomFieldValue {
|
||||||
|
id: number;
|
||||||
|
field_id: number;
|
||||||
|
card_id: number;
|
||||||
|
value: string; // JSON string for the value
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template Types
|
||||||
|
export interface KanbanCardTemplate {
|
||||||
|
id: number;
|
||||||
|
board_id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
template_data: string; // JSON string containing template data
|
||||||
|
is_default: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended Card Types
|
||||||
|
export interface KanbanCardExtended extends KanbanCard {
|
||||||
|
estimated_time?: number;
|
||||||
|
actual_time?: number;
|
||||||
|
comments: KanbanCardComment[];
|
||||||
|
attachments: KanbanCardAttachment[];
|
||||||
|
time_entries: KanbanTimeEntry[];
|
||||||
|
custom_field_values: KanbanCustomFieldValue[];
|
||||||
|
checklists: KanbanChecklistWithItems[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanBoardExtended extends KanbanBoard {
|
||||||
|
custom_fields: KanbanCustomField[];
|
||||||
|
templates: KanbanCardTemplate[];
|
||||||
|
}
|
||||||
|
|||||||
@ -44,6 +44,8 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
rewrite ^/api/(.*) /$1 break;
|
rewrite ^/api/(.*) /$1 break;
|
||||||
proxy_pass http://192.168.0.12:8000;
|
proxy_pass http://192.168.0.12:8000;
|
||||||
|
# Rewrite backend Location headers to https so browsers aren't redirected to http
|
||||||
|
proxy_redirect http:// https://;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@ -101,6 +103,8 @@ server {
|
|||||||
# Auth endpoints
|
# Auth endpoints
|
||||||
location /auth/ {
|
location /auth/ {
|
||||||
proxy_pass http://192.168.0.12:8000/auth/;
|
proxy_pass http://192.168.0.12:8000/auth/;
|
||||||
|
# Rewrite backend Location headers to https so browsers aren't redirected to http
|
||||||
|
proxy_redirect http:// https://;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
17
scripts/simulate_read_marker.py
Normal file
17
scripts/simulate_read_marker.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import asyncio
|
||||||
|
from app.websocket import manager
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
msg = {
|
||||||
|
"type": "read_marker",
|
||||||
|
"user_id": 42,
|
||||||
|
"channel_id": 4,
|
||||||
|
"last_seen": "2025-12-12T13:30:00Z",
|
||||||
|
}
|
||||||
|
# Broadcast to channel and presence (0)
|
||||||
|
await manager.broadcast_to_channel(msg, 4)
|
||||||
|
await manager.broadcast_to_channel({**msg, "type": "read_marker"}, 0)
|
||||||
|
print('Simulated broadcast sent:', msg)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
12
start-backend-clean.sh
Executable file
12
start-backend-clean.sh
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Kill any existing backend processes
|
||||||
|
echo "Killing existing backend processes..."
|
||||||
|
pkill -9 -f "uvicorn" 2>/dev/null || true
|
||||||
|
pkill -9 -f "python3.*app.main" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Start fresh backend
|
||||||
|
echo "Starting backend..."
|
||||||
|
cd /home/OfficeDesk/backend
|
||||||
|
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
@ -7,4 +7,4 @@ echo "📍 API: http://localhost:8000"
|
|||||||
echo "📚 Docs: http://localhost:8000/docs"
|
echo "📚 Docs: http://localhost:8000/docs"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
|
/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|||||||
@ -6,4 +6,4 @@ echo "🚀 Starte Frontend-Server..."
|
|||||||
echo "📍 App: http://192.168.0.12"
|
echo "📍 App: http://192.168.0.12"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
npm run dev
|
npm run build
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user