feat(messages): group messaging and search
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- Search bar in inbox/sent: filters by subject, content, sender/recipient
- Group chats: create named or ad-hoc groups with Norda members
- Group roles: owner, moderator, member with permission hierarchy
- Group management: add/remove members, change roles
- Photo avatars in message list (fallback to initials)
- Unread count API extended to include group messages
- Migration 088: message_group, message_group_member, group_message tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-20 11:11:57 +01:00
parent 2b0907c2ad
commit 3a18ebcb28
12 changed files with 4195 additions and 14 deletions

View File

@ -265,6 +265,15 @@ def register_blueprints(app):
'api_notification_mark_read': 'messages.api_notification_mark_read',
'api_notifications_mark_all_read': 'messages.api_notifications_mark_all_read',
'api_notifications_unread_count': 'messages.api_notifications_unread_count',
'group_compose': 'messages.group_compose',
'group_create': 'messages.group_create',
'group_view': 'messages.group_view',
'group_send': 'messages.group_send',
'group_manage': 'messages.group_manage',
'group_add_member': 'messages.group_add_member',
'group_remove_member': 'messages.group_remove_member',
'group_change_role': 'messages.group_change_role',
'group_edit': 'messages.group_edit',
})
logger.info("Created messages endpoint aliases")
except ImportError as e:

View File

@ -2,7 +2,7 @@
Messages Blueprint
==================
Private messages and notifications routes.
Private messages, group messages, and notifications routes.
"""
from flask import Blueprint
@ -10,3 +10,4 @@ from flask import Blueprint
bp = Blueprint('messages', __name__)
from . import routes # noqa: E402, F401
from . import group_routes # noqa: E402, F401

View File

@ -0,0 +1,594 @@
"""
Group Messages Routes
=====================
Group chat creation, viewing, and messaging.
"""
import os
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from sqlalchemy.orm import joinedload
from sqlalchemy import func
from database import (SessionLocal, User, Company, UserCompanyPermissions,
MessageGroup, MessageGroupMember, GroupMessage,
MessageAttachment, UserNotification, UserBlock)
from extensions import limiter
from utils.helpers import sanitize_html
from utils.decorators import member_required
from email_service import send_email, build_message_notification_email
from message_upload_service import MessageUploadService
def _get_active_norda_members(db, exclude_user_id):
"""Pobierz aktywnych członków Nordy do wyboru."""
users_with_companies = db.query(
User,
Company.name.label('company_name')
).outerjoin(
UserCompanyPermissions,
UserCompanyPermissions.user_id == User.id
).outerjoin(
Company,
(Company.id == UserCompanyPermissions.company_id) & (Company.status == 'active')
).filter(
User.is_active == True,
User.is_verified == True,
User.id != exclude_user_id
).order_by(User.name).all()
seen_ids = set()
users = []
for user, company_name in users_with_companies:
if user.id not in seen_ids:
seen_ids.add(user.id)
user._company_name = company_name
users.append(user)
return users
def _check_group_access(db, group_id, user_id):
"""Sprawdź czy użytkownik jest członkiem grupy. Zwraca (group, membership) lub (None, None)."""
group = db.query(MessageGroup).filter(MessageGroup.id == group_id).first()
if not group:
return None, None
membership = db.query(MessageGroupMember).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id == user_id
).first()
if not membership:
return None, None
return group, membership
def _check_block_for_group(db, group_id, target_user_id):
"""Sprawdź czy dodanie użytkownika do grupy jest zablokowane przez UserBlock."""
member_ids = [m.user_id for m in db.query(MessageGroupMember.user_id).filter(
MessageGroupMember.group_id == group_id
).all()]
block = db.query(UserBlock).filter(
((UserBlock.user_id == target_user_id) & (UserBlock.blocked_user_id.in_(member_ids))) |
((UserBlock.user_id.in_(member_ids)) & (UserBlock.blocked_user_id == target_user_id))
).first()
return block is not None
@bp.route('/wiadomosci/nowa-grupa')
@login_required
@member_required
def group_compose():
"""Formularz tworzenia grupy"""
db = SessionLocal()
try:
users = _get_active_norda_members(db, current_user.id)
return render_template('messages/group_compose.html', users=users)
finally:
db.close()
@bp.route('/wiadomosci/grupa/utworz', methods=['POST'])
@login_required
@member_required
def group_create():
"""Utwórz grupę i wyślij pierwszą wiadomość"""
name = request.form.get('name', '').strip()
content = sanitize_html(request.form.get('content', '').strip())
member_ids = request.form.getlist('members', type=int)
if not content:
flash('Treść pierwszej wiadomości jest wymagana.', 'error')
return redirect(url_for('.group_compose'))
if not member_ids:
flash('Wybierz co najmniej jedną osobę.', 'error')
return redirect(url_for('.group_compose'))
db = SessionLocal()
try:
# Verify all members exist and are active Norda members
valid_members = db.query(User).filter(
User.id.in_(member_ids),
User.is_active == True,
User.is_verified == True
).all()
if not valid_members:
flash('Nie znaleziono wybranych użytkowników.', 'error')
return redirect(url_for('.group_compose'))
# Check blocks
for member in valid_members:
block = db.query(UserBlock).filter(
((UserBlock.user_id == current_user.id) & (UserBlock.blocked_user_id == member.id)) |
((UserBlock.user_id == member.id) & (UserBlock.blocked_user_id == current_user.id))
).first()
if block:
flash(f'Nie można dodać użytkownika {member.name or member.email} do grupy.', 'error')
return redirect(url_for('.group_compose'))
# Create group
group = MessageGroup(
name=name if name else None,
is_named=bool(name),
owner_id=current_user.id
)
db.add(group)
db.flush()
# Add owner as member
owner_member = MessageGroupMember(
group_id=group.id,
user_id=current_user.id,
role='owner',
last_read_at=datetime.now()
)
db.add(owner_member)
# Add selected members
for member in valid_members:
if member.id != current_user.id:
gm = MessageGroupMember(
group_id=group.id,
user_id=member.id,
role='member',
added_by_id=current_user.id
)
db.add(gm)
# Create first message
msg = GroupMessage(
group_id=group.id,
sender_id=current_user.id,
content=content
)
db.add(msg)
db.flush()
# Process attachments
if request.files.getlist('attachments'):
upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
files = [f for f in request.files.getlist('attachments') if f and f.filename]
if files:
valid_files, errors = upload_service.validate_files(files)
if errors:
db.rollback()
for err in errors:
flash(err, 'error')
return redirect(url_for('.group_compose'))
for f, filename, ext, size, file_content in valid_files:
stored_filename, _ = upload_service.save_file(file_content, ext)
att = MessageAttachment(
group_message_id=msg.id,
filename=filename,
stored_filename=stored_filename,
file_size=size,
mime_type=upload_service.get_mime_type(ext)
)
db.add(att)
group.updated_at = datetime.now()
# Notifications for all members
sender_name = current_user.name or current_user.email.split('@')[0]
group_display = name if name else 'Nowa grupa'
for member in valid_members:
if member.id != current_user.id:
notif = UserNotification(
user_id=member.id,
title=f'Dodano do grupy: {group_display}',
message=f'{sender_name} utworzył(a) grupę i wysłał(a) pierwszą wiadomość',
notification_type='message',
related_type='group',
related_id=group.id,
action_url=url_for('.group_view', group_id=group.id)
)
db.add(notif)
# Email notification
if member.notify_email_messages != False and member.email:
try:
message_url = url_for('.group_view', group_id=group.id, _external=True)
settings_url = url_for('auth.konto_prywatnosc', _external=True)
preview = (content[:200] + '...') if len(content) > 200 else content
email_html, email_text = build_message_notification_email(
sender_name=sender_name,
subject=f'Grupa: {group_display}',
content_preview=preview,
message_url=message_url,
settings_url=settings_url
)
send_email(
to=[member.email],
subject=f'Nowa grupa: {group_display} — Norda Biznes',
body_text=email_text,
body_html=email_html,
email_type='message_notification',
user_id=member.id,
recipient_name=member.name
)
except Exception:
import logging
logging.getLogger(__name__).warning(f"Failed to send group email to {member.email}")
db.commit()
flash('Grupa utworzona!', 'success')
return redirect(url_for('.group_view', group_id=group.id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>')
@login_required
@member_required
def group_view(group_id):
"""Widok czatu grupowego"""
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group:
flash('Grupa nie istnieje lub nie masz dostępu.', 'error')
return redirect(url_for('.messages_inbox'))
# Load messages with senders and attachments
messages = db.query(GroupMessage).options(
joinedload(GroupMessage.sender),
joinedload(GroupMessage.attachments)
).filter(
GroupMessage.group_id == group_id
).order_by(GroupMessage.created_at.asc()).all()
# Load members with user details
members = db.query(MessageGroupMember).options(
joinedload(MessageGroupMember.user)
).filter(
MessageGroupMember.group_id == group_id
).order_by(MessageGroupMember.role, MessageGroupMember.joined_at).all()
# Mark as read
membership.last_read_at = datetime.now()
db.commit()
return render_template('messages/group_view.html',
group=group,
messages=messages,
members=members,
membership=membership
)
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/wyslij', methods=['POST'])
@login_required
@member_required
def group_send(group_id):
"""Wyślij wiadomość do grupy"""
content = sanitize_html(request.form.get('content', '').strip())
if not content:
flash('Treść jest wymagana.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group:
flash('Grupa nie istnieje lub nie masz dostępu.', 'error')
return redirect(url_for('.messages_inbox'))
msg = GroupMessage(
group_id=group_id,
sender_id=current_user.id,
content=content
)
db.add(msg)
db.flush()
# Process attachments
if request.files.getlist('attachments'):
upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
files = [f for f in request.files.getlist('attachments') if f and f.filename]
if files:
valid_files, errors = upload_service.validate_files(files)
if errors:
db.rollback()
for err in errors:
flash(err, 'error')
return redirect(url_for('.group_view', group_id=group_id))
for f, filename, ext, size, file_content in valid_files:
stored_filename, _ = upload_service.save_file(file_content, ext)
att = MessageAttachment(
group_message_id=msg.id,
filename=filename,
stored_filename=stored_filename,
file_size=size,
mime_type=upload_service.get_mime_type(ext)
)
db.add(att)
group.updated_at = datetime.now()
membership.last_read_at = datetime.now()
# Notify all other members
sender_name = current_user.name or current_user.email.split('@')[0]
group_display = group.name or group.display_name
other_members = db.query(MessageGroupMember).options(
joinedload(MessageGroupMember.user)
).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id != current_user.id
).all()
for m in other_members:
notif = UserNotification(
user_id=m.user_id,
title=f'{group_display} — nowa wiadomość',
message=f'{sender_name}: {content[:80]}...' if len(content) > 80 else f'{sender_name}: {content}',
notification_type='message',
related_type='group',
related_id=group.id,
action_url=url_for('.group_view', group_id=group_id)
)
db.add(notif)
if m.user and m.user.notify_email_messages != False and m.user.email:
try:
message_url = url_for('.group_view', group_id=group_id, _external=True)
settings_url = url_for('auth.konto_prywatnosc', _external=True)
preview = (content[:200] + '...') if len(content) > 200 else content
email_html, email_text = build_message_notification_email(
sender_name=sender_name,
subject=f'Grupa: {group_display}',
content_preview=preview,
message_url=message_url,
settings_url=settings_url
)
send_email(
to=[m.user.email],
subject=f'{group_display} — nowa wiadomość od {sender_name}',
body_text=email_text,
body_html=email_html,
email_type='message_notification',
user_id=m.user_id,
recipient_name=m.user.name
)
except Exception:
import logging
logging.getLogger(__name__).warning(f"Failed to send group email to {m.user.email}")
db.commit()
return redirect(url_for('.group_view', group_id=group_id))
finally:
db.close()
# ============================================================
# GROUP MANAGEMENT ROUTES
# ============================================================
@bp.route('/wiadomosci/grupa/<int:group_id>/zarzadzaj')
@login_required
@member_required
def group_manage(group_id):
"""Panel zarządzania członkami grupy"""
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.can_manage_members:
flash('Brak uprawnień do zarządzania grupą.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
members = db.query(MessageGroupMember).options(
joinedload(MessageGroupMember.user)
).filter(
MessageGroupMember.group_id == group_id
).order_by(MessageGroupMember.role, MessageGroupMember.joined_at).all()
available_users = _get_active_norda_members(db, current_user.id)
member_ids = {m.user_id for m in members}
available_users = [u for u in available_users if u.id not in member_ids]
return render_template('messages/group_manage.html',
group=group,
members=members,
membership=membership,
available_users=available_users
)
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/dodaj-czlonka', methods=['POST'])
@login_required
@member_required
def group_add_member(group_id):
"""Dodaj osobę do grupy"""
user_id = request.form.get('user_id', type=int)
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.can_manage_members:
flash('Brak uprawnień.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
if not user_id:
flash('Nie wybrano użytkownika.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
# Check user exists and is active
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
flash('Użytkownik nie istnieje.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
# Check not already member
existing = db.query(MessageGroupMember).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id == user_id
).first()
if existing:
flash('Użytkownik jest już członkiem grupy.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
# Check blocks
if _check_block_for_group(db, group_id, user_id):
flash('Nie można dodać tego użytkownika do grupy.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
new_member = MessageGroupMember(
group_id=group_id,
user_id=user_id,
role='member',
added_by_id=current_user.id
)
db.add(new_member)
# Notification
adder_name = current_user.name or current_user.email.split('@')[0]
group_display = group.name or group.display_name
notif = UserNotification(
user_id=user_id,
title=f'Dodano do grupy: {group_display}',
message=f'{adder_name} dodał(a) Cię do grupy',
notification_type='message',
related_type='group',
related_id=group.id,
action_url=url_for('.group_view', group_id=group_id)
)
db.add(notif)
db.commit()
flash(f'Dodano {user.name or user.email} do grupy.', 'success')
return redirect(url_for('.group_manage', group_id=group_id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/usun-czlonka', methods=['POST'])
@login_required
@member_required
def group_remove_member(group_id):
"""Usuń osobę z grupy"""
user_id = request.form.get('user_id', type=int)
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.can_manage_members:
flash('Brak uprawnień.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
if user_id == group.owner_id:
flash('Nie można usunąć właściciela grupy.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
target = db.query(MessageGroupMember).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id == user_id
).first()
if not target:
flash('Użytkownik nie jest członkiem grupy.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
# Moderator cannot remove another moderator (only owner can)
if target.role == 'moderator' and not membership.is_owner:
flash('Tylko właściciel może usunąć moderatora.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
user = db.query(User).filter(User.id == user_id).first()
db.delete(target)
db.commit()
flash(f'Usunięto {user.name or user.email if user else "użytkownika"} z grupy.', 'success')
return redirect(url_for('.group_manage', group_id=group_id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/zmien-role', methods=['POST'])
@login_required
@member_required
def group_change_role(group_id):
"""Zmień rolę członka (tylko owner)"""
user_id = request.form.get('user_id', type=int)
new_role = request.form.get('role')
if new_role not in ('moderator', 'member'):
flash('Nieprawidłowa rola.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.is_owner:
flash('Tylko właściciel może zmieniać role.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
target = db.query(MessageGroupMember).filter(
MessageGroupMember.group_id == group_id,
MessageGroupMember.user_id == user_id
).first()
if not target or target.is_owner:
flash('Nie można zmienić roli tego użytkownika.', 'error')
return redirect(url_for('.group_manage', group_id=group_id))
target.role = new_role
db.commit()
role_label = 'moderatora' if new_role == 'moderator' else 'uczestnika'
user = db.query(User).filter(User.id == user_id).first()
flash(f'{user.name or user.email if user else "Użytkownik"} — rola zmieniona na {role_label}.', 'success')
return redirect(url_for('.group_manage', group_id=group_id))
finally:
db.close()
@bp.route('/wiadomosci/grupa/<int:group_id>/edytuj', methods=['POST'])
@login_required
@member_required
def group_edit(group_id):
"""Edytuj nazwę i opis grupy (tylko owner)"""
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
db = SessionLocal()
try:
group, membership = _check_group_access(db, group_id, current_user.id)
if not group or not membership.is_owner:
flash('Tylko właściciel może edytować grupę.', 'error')
return redirect(url_for('.group_view', group_id=group_id))
group.name = name if name else None
group.is_named = bool(name)
group.description = description if description else None
db.commit()
flash('Grupa zaktualizowana.', 'success')
return redirect(url_for('.group_manage', group_id=group_id))
finally:
db.close()

View File

@ -31,6 +31,7 @@ def messages_inbox():
"""Skrzynka odbiorcza"""
page = request.args.get('page', 1, type=int)
per_page = 20
search_query = request.args.get('q', '').strip()
db = SessionLocal()
try:
@ -38,9 +39,24 @@ def messages_inbox():
joinedload(PrivateMessage.attachments)
).filter(
PrivateMessage.recipient_id == current_user.id
).order_by(PrivateMessage.created_at.desc())
)
total = db.query(PrivateMessage).filter(PrivateMessage.recipient_id == current_user.id).count()
# Search filtering
if search_query:
from sqlalchemy import or_, func
search_pattern = f'%{search_query}%'
query = query.join(User, PrivateMessage.sender_id == User.id).filter(
or_(
PrivateMessage.subject.ilike(search_pattern),
func.regexp_replace(PrivateMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern),
User.name.ilike(search_pattern),
User.email.ilike(search_pattern)
)
)
query = query.order_by(PrivateMessage.created_at.desc())
total = query.count()
messages = query.limit(per_page).offset((page - 1) * per_page).all()
unread_count = db.query(PrivateMessage).filter(
@ -48,11 +64,65 @@ def messages_inbox():
PrivateMessage.is_read == False
).count()
# Fetch group chats for current user
from database import MessageGroupMember, MessageGroup, GroupMessage
group_items = []
group_memberships = db.query(MessageGroupMember).filter(
MessageGroupMember.user_id == current_user.id
).all()
for ms in group_memberships:
group = db.query(MessageGroup).filter(MessageGroup.id == ms.group_id).first()
if not group:
continue
# Apply search filter to groups
if search_query:
from sqlalchemy import or_, func
search_pattern = f'%{search_query}%'
# Check group name or any message content
name_match = group.name and search_query.lower() in group.name.lower()
content_match = db.query(GroupMessage).filter(
GroupMessage.group_id == group.id,
func.regexp_replace(GroupMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern)
).first()
if not name_match and not content_match:
continue
last_msg = db.query(GroupMessage).filter(
GroupMessage.group_id == group.id
).order_by(GroupMessage.created_at.desc()).first()
grp_unread = 0
if ms.last_read_at:
grp_unread = db.query(GroupMessage).filter(
GroupMessage.group_id == group.id,
GroupMessage.sender_id != current_user.id,
GroupMessage.created_at > ms.last_read_at
).count()
else:
grp_unread = db.query(GroupMessage).filter(
GroupMessage.group_id == group.id,
GroupMessage.sender_id != current_user.id
).count()
group_items.append({
'type': 'group',
'group': group,
'last_message': last_msg,
'unread_count': grp_unread,
'membership': ms,
'sort_date': last_msg.created_at if last_msg else group.created_at
})
return render_template('messages/inbox.html',
messages=messages,
page=page,
total_pages=(total + per_page - 1) // per_page,
unread_count=unread_count
unread_count=unread_count,
search_query=search_query,
group_items=group_items
)
finally:
db.close()
@ -65,6 +135,7 @@ def messages_sent():
"""Wysłane wiadomości"""
page = request.args.get('page', 1, type=int)
per_page = 20
search_query = request.args.get('q', '').strip()
db = SessionLocal()
try:
@ -72,15 +143,30 @@ def messages_sent():
joinedload(PrivateMessage.attachments)
).filter(
PrivateMessage.sender_id == current_user.id
).order_by(PrivateMessage.created_at.desc())
)
total = db.query(PrivateMessage).filter(PrivateMessage.sender_id == current_user.id).count()
if search_query:
from sqlalchemy import or_, func
search_pattern = f'%{search_query}%'
query = query.join(User, PrivateMessage.recipient_id == User.id).filter(
or_(
PrivateMessage.subject.ilike(search_pattern),
func.regexp_replace(PrivateMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern),
User.name.ilike(search_pattern),
User.email.ilike(search_pattern)
)
)
query = query.order_by(PrivateMessage.created_at.desc())
total = query.count()
messages = query.limit(per_page).offset((page - 1) * per_page).all()
return render_template('messages/sent.html',
messages=messages,
page=page,
total_pages=(total + per_page - 1) // per_page
total_pages=(total + per_page - 1) // per_page,
search_query=search_query
)
finally:
db.close()
@ -505,14 +591,33 @@ def messages_reply(message_id):
@login_required
@member_required
def api_unread_count():
"""API: Liczba nieprzeczytanych wiadomości"""
"""API: Liczba nieprzeczytanych wiadomości (1:1 + grupowe)"""
db = SessionLocal()
try:
count = db.query(PrivateMessage).filter(
from sqlalchemy import func
from database import MessageGroupMember, GroupMessage
# 1:1 unread
pm_count = db.query(PrivateMessage).filter(
PrivateMessage.recipient_id == current_user.id,
PrivateMessage.is_read == False
).count()
return jsonify({'count': count})
# Group unread
group_count = 0
memberships = db.query(MessageGroupMember).filter(
MessageGroupMember.user_id == current_user.id
).all()
for m in memberships:
q = db.query(func.count(GroupMessage.id)).filter(
GroupMessage.group_id == m.group_id,
GroupMessage.sender_id != current_user.id
)
if m.last_read_at:
q = q.filter(GroupMessage.created_at > m.last_read_at)
group_count += q.scalar() or 0
return jsonify({'count': pm_count + group_count})
finally:
db.close()

View File

@ -2319,11 +2319,12 @@ class PrivateMessage(Base):
class MessageAttachment(Base):
"""Załączniki do wiadomości prywatnych"""
"""Załączniki do wiadomości prywatnych i grupowych"""
__tablename__ = 'message_attachments'
id = Column(Integer, primary_key=True)
message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE'), nullable=False)
message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE')) # nullable now
group_message_id = Column(Integer, ForeignKey('group_message.id', ondelete='CASCADE')) # NEW
filename = Column(String(255), nullable=False) # original filename
stored_filename = Column(String(255), nullable=False) # UUID-based on disk
file_size = Column(Integer, nullable=False) # bytes
@ -2333,6 +2334,91 @@ class MessageAttachment(Base):
message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan'))
# ============================================================
# GROUP MESSAGES
# ============================================================
class MessageGroup(Base):
"""Grupa czatowa — nazwana lub ad-hoc"""
__tablename__ = 'message_group'
id = Column(Integer, primary_key=True)
name = Column(String(255))
description = Column(Text)
owner_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
is_named = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
owner = relationship('User', foreign_keys=[owner_id])
members = relationship('MessageGroupMember', backref='group', cascade='all, delete-orphan')
messages = relationship('GroupMessage', backref='group', cascade='all, delete-orphan',
order_by='GroupMessage.created_at')
@property
def display_name(self):
"""Nazwa wyświetlana — nazwa grupy lub lista imion uczestników"""
if self.name:
return self.name
names = [m.user.name or m.user.email.split('@')[0] for m in self.members if m.user]
return ', '.join(sorted(names)[:4]) + (f' +{len(names)-4}' if len(names) > 4 else '')
@property
def member_count(self):
return len(self.members)
@property
def last_message(self):
"""Ostatnia wiadomość w grupie"""
if self.messages:
return self.messages[-1]
return None
class MessageGroupMember(Base):
"""Członkostwo w grupie czatowej"""
__tablename__ = 'message_group_member'
group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
role = Column(String(20), nullable=False, default='member') # owner, moderator, member
last_read_at = Column(DateTime)
joined_at = Column(DateTime, default=datetime.now)
added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
user = relationship('User', foreign_keys=[user_id])
added_by = relationship('User', foreign_keys=[added_by_id])
@property
def is_owner(self):
return self.role == 'owner'
@property
def is_moderator(self):
return self.role == 'moderator'
@property
def can_manage_members(self):
return self.role in ('owner', 'moderator')
class GroupMessage(Base):
"""Wiadomość w grupie czatowej"""
__tablename__ = 'group_message'
id = Column(Integer, primary_key=True)
group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), nullable=False)
sender_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.now)
sender = relationship('User', foreign_keys=[sender_id])
attachments = relationship('MessageAttachment',
foreign_keys='MessageAttachment.group_message_id',
backref='group_message',
cascade='all, delete-orphan')
# ============================================================
# B2B CLASSIFIEDS
# ============================================================
@ -4104,7 +4190,7 @@ class CompanyPKD(Base):
class CompanyFinancialReport(Base):
"""
Financial reports (sprawozdania finansowe) filed with KRS.
Tracks report periods and filing dates.
Tracks report periods, filing dates, and key financial figures.
"""
__tablename__ = 'company_financial_reports'
@ -4116,8 +4202,21 @@ class CompanyFinancialReport(Base):
report_type = Column(String(50), default='annual') # annual, quarterly
source = Column(String(50), default='ekrs')
# Financial data (in PLN)
revenue = Column(Numeric(15, 2)) # Przychody netto ze sprzedaży
operating_profit = Column(Numeric(15, 2)) # Zysk/strata z działalności operacyjnej
net_profit = Column(Numeric(15, 2)) # Zysk/strata netto
total_assets = Column(Numeric(15, 2)) # Suma aktywów
equity = Column(Numeric(15, 2)) # Kapitał własny
liabilities = Column(Numeric(15, 2)) # Zobowiązania
employees_count = Column(Integer) # Średnia liczba zatrudnionych
# Company size classification: micro, small, medium, large
size_class = Column(String(20))
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = relationship('Company', backref='financial_reports')
@ -4126,6 +4225,28 @@ class CompanyFinancialReport(Base):
UniqueConstraint('company_id', 'period_start', 'period_end', 'report_type', name='uq_company_financial_report'),
)
def classify_size(self):
"""Classify company size based on EU criteria (revenue + employees + assets).
micro: <2M EUR revenue, <10 employees, <2M EUR assets
small: <10M EUR revenue, <50 employees, <10M EUR assets
medium: <50M EUR revenue, <250 employees, <43M EUR assets
large: above medium
EUR/PLN ~4.3
"""
rev = float(self.revenue or 0)
emp = self.employees_count or 0
assets = float(self.total_assets or 0)
# PLN thresholds (approx EUR * 4.3)
if rev < 8_600_000 and emp < 10 and assets < 8_600_000:
return 'micro'
elif rev < 43_000_000 and emp < 50 and assets < 43_000_000:
return 'small'
elif rev < 215_000_000 and emp < 250 and assets < 185_000_000:
return 'medium'
else:
return 'large'
def __repr__(self):
return f'<CompanyFinancialReport {self.period_start} - {self.period_end}>'

View File

@ -0,0 +1,59 @@
-- 088_message_groups.sql
-- Group messaging tables + alter message_attachments
BEGIN;
-- Group/channel for multi-person chats
CREATE TABLE message_group (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
description TEXT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_named BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_message_group_owner ON message_group(owner_id);
-- Group membership with roles
CREATE TABLE message_group_member (
group_id INTEGER NOT NULL REFERENCES message_group(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'moderator', 'member')),
last_read_at TIMESTAMP,
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
added_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX idx_message_group_member_user ON message_group_member(user_id);
-- Messages within a group
CREATE TABLE group_message (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES message_group(id) ON DELETE CASCADE,
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_group_message_group ON group_message(group_id);
CREATE INDEX idx_group_message_created ON group_message(group_id, created_at);
-- Extend message_attachments to support group messages
ALTER TABLE message_attachments ALTER COLUMN message_id DROP NOT NULL;
ALTER TABLE message_attachments ADD COLUMN group_message_id INTEGER REFERENCES group_message(id) ON DELETE CASCADE;
ALTER TABLE message_attachments ADD CONSTRAINT chk_attachment_one_parent
CHECK ((message_id IS NOT NULL) != (group_message_id IS NOT NULL));
CREATE INDEX idx_message_attachments_group ON message_attachments(group_message_id) WHERE group_message_id IS NOT NULL;
-- Grants
GRANT ALL ON TABLE message_group TO nordabiz_app;
GRANT ALL ON TABLE message_group_member TO nordabiz_app;
GRANT ALL ON TABLE group_message TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE message_group_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE group_message_id_seq TO nordabiz_app;
COMMIT;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,609 @@
{% extends "base.html" %}
{% block title %}Nowa grupa - Norda Biznes Partner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
{% endblock %}
{% block extra_css %}
.quill-container {
border: 1px solid var(--border);
border-radius: var(--radius);
}
.quill-container .ql-toolbar {
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
.quill-container .ql-container {
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
font-size: var(--font-size-base);
}
.quill-container .ql-editor {
min-height: 200px;
}
.quill-container .ql-editor img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: var(--spacing-sm) 0;
}
.compose-container {
max-width: 700px;
margin: 0 auto;
}
.compose-header {
margin-bottom: var(--spacing-xl);
}
.compose-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.compose-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-xs);
color: var(--text-primary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
transition: var(--transition);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-lg);
}
.back-link:hover {
color: var(--primary);
}
/* Member picker */
.member-picker {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.selected-members {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
min-height: 44px;
align-items: center;
border-bottom: 1px solid var(--border);
background: var(--background);
}
.selected-members:empty {
border-bottom: none;
}
.member-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--primary);
color: white;
border-radius: 16px;
font-size: var(--font-size-sm);
font-weight: 500;
}
.member-pill-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
}
.member-pill-initial {
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255,255,255,0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
}
.member-pill-remove {
background: none;
border: none;
color: rgba(255,255,255,0.7);
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
}
.member-pill-remove:hover {
color: white;
}
.member-search-box {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.member-search-box input {
width: 100%;
border: none;
outline: none;
font-size: var(--font-size-sm);
padding: 4px 0;
background: transparent;
}
.member-list {
max-height: 240px;
overflow-y: auto;
}
.member-list-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
transition: background 0.15s;
}
.member-list-item:hover {
background: var(--background);
}
.member-list-item.selected {
background: rgba(37, 99, 235, 0.06);
}
.member-list-item input[type="checkbox"] {
width: auto;
flex-shrink: 0;
}
.member-list-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.member-list-initial {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.member-list-info {
flex: 1;
min-width: 0;
}
.member-list-name {
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.member-list-company {
font-size: var(--font-size-xs);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-list-empty {
padding: var(--spacing-md);
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.selected-count {
font-size: var(--font-size-sm);
color: var(--text-secondary);
padding: var(--spacing-xs) var(--spacing-md);
border-top: 1px solid var(--border);
background: var(--background);
}
@media (max-width: 640px) {
.compose-card {
padding: var(--spacing-md);
}
}
{% endblock %}
{% block content %}
<div class="compose-container">
<a href="{{ url_for('messages_inbox') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Powrot do wiadomosci
</a>
<div class="compose-header">
<h1>Nowa grupa</h1>
</div>
<div class="compose-card">
<form method="POST" action="{{ url_for('messages.group_create') }}" enctype="multipart/form-data" id="group-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="members-hidden-inputs"></div>
<div class="form-group">
<label for="group-name">Nazwa grupy (opcjonalnie)</label>
<input type="text" id="group-name" name="name" maxlength="100" placeholder="np. Komisja ds. PEJ">
</div>
<div class="form-group">
<label>Uczestnicy *</label>
<div class="member-picker">
<div class="selected-members" id="selected-members">
<span style="color: var(--text-secondary); font-size: var(--font-size-sm);" id="placeholder-text">Wybierz uczestnikow z listy ponizej...</span>
</div>
<div class="member-search-box">
<input type="text" id="member-search" placeholder="Szukaj po imieniu, nazwisku lub firmie..." autocomplete="off">
</div>
<div class="member-list" id="member-list"></div>
<div class="selected-count" id="selected-count">Wybrano: 0 osob</div>
</div>
</div>
<div class="form-group">
<label>Pierwsza wiadomosc *</label>
<div id="quill-content" class="quill-container" style="min-height: 200px; background: var(--surface);"></div>
<textarea id="content" name="content" style="display:none;" required></textarea>
</div>
<div class="form-group">
<label>Zalaczniki (maks. 3 pliki, 15MB lacznie)</label>
<div class="file-upload-zone" id="file-drop-zone" style="border: 2px dashed var(--border-color); border-radius: var(--radius); padding: 20px; text-align: center; cursor: pointer; transition: border-color 0.2s;">
<input type="file" name="attachments" id="file-input" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="display: none;">
<p style="margin: 0; color: var(--text-secondary); font-size: var(--font-size-sm);">
Przeciagnij pliki tutaj lub <a href="#" onclick="document.getElementById('file-input').click(); return false;" style="color: var(--primary);">wybierz z dysku</a>
</p>
<p style="margin: 4px 0 0; color: var(--text-secondary); font-size: var(--font-size-xs);">
JPG, PNG, GIF (5MB) · PDF, DOCX, XLSX (10MB)
</p>
</div>
<div id="file-list" style="margin-top: 8px;"></div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Utworz grupe</button>
<a href="{{ url_for('messages_inbox') }}" class="btn btn-secondary">Anuluj</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
/* Member picker */
(function() {
var users = [
{% for user in users %}
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, companyName: {{ (user._company_name or '') | tojson }}, avatarPath: {{ (user.avatar_path or '') | tojson }}}{{ ',' if not loop.last }}
{% endfor %}
];
var selectedIds = new Set();
var searchInput = document.getElementById('member-search');
var listDiv = document.getElementById('member-list');
var selectedDiv = document.getElementById('selected-members');
var countDiv = document.getElementById('selected-count');
var hiddenInputsDiv = document.getElementById('members-hidden-inputs');
var placeholderText = document.getElementById('placeholder-text');
function normalize(str) {
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
function filterUsers(query) {
if (!query) return users;
var q = normalize(query);
return users.filter(function(u) {
return normalize(u.name).indexOf(q) !== -1 ||
normalize(u.companyName).indexOf(q) !== -1 ||
normalize(u.email).indexOf(q) !== -1;
});
}
function renderList() {
var query = searchInput.value.trim();
var filtered = filterUsers(query);
if (filtered.length === 0) {
listDiv.innerHTML = '<div class="member-list-empty">Nie znaleziono</div>';
return;
}
listDiv.innerHTML = filtered.map(function(u) {
var checked = selectedIds.has(u.id) ? 'checked' : '';
var selectedClass = selectedIds.has(u.id) ? ' selected' : '';
var initial = (u.name || u.email)[0].toUpperCase();
var avatarHtml = u.avatarPath
? '<img src="' + u.avatarPath + '" class="member-list-avatar" alt="">'
: '<div class="member-list-initial">' + initial + '</div>';
var companyHtml = u.companyName ? '<div class="member-list-company">' + u.companyName + '</div>' : '';
return '<label class="member-list-item' + selectedClass + '" data-user-id="' + u.id + '">' +
'<input type="checkbox" ' + checked + ' data-id="' + u.id + '" style="width:auto;">' +
avatarHtml +
'<div class="member-list-info">' +
'<div class="member-list-name">' + u.name + '</div>' +
companyHtml +
'</div>' +
'</label>';
}).join('');
}
function renderPills() {
if (selectedIds.size === 0) {
selectedDiv.innerHTML = '<span style="color: var(--text-secondary); font-size: var(--font-size-sm);" id="placeholder-text">Wybierz uczestnikow z listy ponizej...</span>';
} else {
var html = '';
selectedIds.forEach(function(id) {
var user = users.find(function(u) { return u.id === id; });
if (!user) return;
var initial = (user.name || user.email)[0].toUpperCase();
var avatarHtml = user.avatarPath
? '<img src="' + user.avatarPath + '" class="member-pill-avatar" alt="">'
: '<div class="member-pill-initial">' + initial + '</div>';
html += '<span class="member-pill">' +
avatarHtml +
'<span>' + user.name + '</span>' +
'<button type="button" class="member-pill-remove" data-id="' + id + '" title="Usun"></button>' +
'</span>';
});
selectedDiv.innerHTML = html;
}
countDiv.textContent = 'Wybrano: ' + selectedIds.size + ' osob';
}
function updateHiddenInputs() {
hiddenInputsDiv.innerHTML = '';
selectedIds.forEach(function(id) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'members';
input.value = id;
hiddenInputsDiv.appendChild(input);
});
}
function toggleUser(id) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
renderPills();
updateHiddenInputs();
renderList();
}
listDiv.addEventListener('change', function(e) {
if (e.target.type === 'checkbox') {
toggleUser(parseInt(e.target.dataset.id));
}
});
selectedDiv.addEventListener('click', function(e) {
var btn = e.target.closest('.member-pill-remove');
if (btn) {
toggleUser(parseInt(btn.dataset.id));
}
});
searchInput.addEventListener('input', renderList);
renderList();
/* Form validation */
document.getElementById('group-form').addEventListener('submit', function(e) {
if (selectedIds.size < 1) {
e.preventDefault();
alert('Wybierz co najmniej jednego uczestnika.');
return;
}
});
})();
/* File attachment handling */
(function() {
var dropZone = document.getElementById('file-drop-zone');
var fileInput = document.getElementById('file-input');
var fileList = document.getElementById('file-list');
if (!dropZone) return;
dropZone.addEventListener('click', function(e) {
if (e.target.tagName !== 'A') fileInput.click();
});
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
dropZone.style.borderColor = 'var(--primary)';
});
dropZone.addEventListener('dragleave', function() {
dropZone.style.borderColor = 'var(--border-color)';
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
dropZone.style.borderColor = 'var(--border-color)';
var dt = new DataTransfer();
Array.from(e.dataTransfer.files).forEach(function(f) { dt.items.add(f); });
Array.from(fileInput.files).forEach(function(f) { dt.items.add(f); });
fileInput.files = dt.files;
updateFileList();
});
fileInput.addEventListener('change', updateFileList);
function updateFileList() {
var files = Array.from(fileInput.files);
if (files.length === 0) { fileList.innerHTML = ''; return; }
fileList.innerHTML = files.map(function(f, i) {
var sizeMB = (f.size / 1024 / 1024).toFixed(1);
return '<div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: var(--font-size-sm);">' +
'<span style="color: var(--text-secondary);">📎</span> ' +
'<span>' + f.name + '</span> ' +
'<span style="color: var(--text-secondary);">(' + sizeMB + ' MB)</span> ' +
'<a href="#" onclick="removeFile(' + i + '); return false;" style="color: var(--danger); margin-left: auto;"></a>' +
'</div>';
}).join('');
}
window.removeFile = function(index) {
var dt = new DataTransfer();
Array.from(fileInput.files).forEach(function(f, i) {
if (i !== index) dt.items.add(f);
});
fileInput.files = dt.files;
updateFileList();
};
})();
/* Quill editor for message content */
(function() {
var csrfToken = '{{ csrf_token() }}';
var quill = new Quill('#quill-content', {
theme: 'snow',
placeholder: 'Napisz wiadomosc...',
modules: {
toolbar: {
container: [
['bold', 'italic'],
[{'list': 'ordered'}, {'list': 'bullet'}],
['link', 'image'],
['clean']
],
handlers: {
image: function() {
var input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = function() {
if (input.files && input.files[0]) {
uploadImage(input.files[0]);
}
};
}
}
}
}
});
function uploadImage(file) {
var fd = new FormData();
fd.append('image', file);
fetch('/api/messages/upload-image', {
method: 'POST',
headers: {'X-CSRFToken': csrfToken},
body: fd
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.url) {
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', data.url);
quill.setSelection(range.index + 1);
} else {
alert(data.error || 'Blad uploadu');
}
})
.catch(function() { alert('Blad polaczenia'); });
}
quill.root.addEventListener('paste', function(e) {
var items = (e.clipboardData || {}).items || [];
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.stopImmediatePropagation();
e.preventDefault();
var file = items[i].getAsFile();
if (file) uploadImage(file);
return;
}
}
}, true);
var textarea = document.getElementById('content');
quill.on('text-change', function() {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
});
document.getElementById('group-form').addEventListener('submit', function(e) {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
if (!textarea.value.trim()) {
e.preventDefault();
alert('Tresc wiadomosci jest wymagana.');
}
});
})();
{% endblock %}

View File

@ -0,0 +1,562 @@
{% extends "base.html" %}
{% block title %}Zarzadzanie grupa - {{ group.display_name }} - Norda Biznes Partner{% endblock %}
{% block extra_css %}
.manage-container {
max-width: 700px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-lg);
font-size: var(--font-size-sm);
}
.back-link:hover {
color: var(--primary);
}
.manage-header {
margin-bottom: var(--spacing-xl);
}
.manage-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.manage-card {
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color, #e5e7eb);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
}
.manage-card h2 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-xs);
color: var(--text-primary);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
transition: var(--transition);
font-family: inherit;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Members table */
.members-table {
width: 100%;
border-collapse: collapse;
}
.members-table th {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid var(--border-color, #e5e7eb);
}
.members-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border-color, #f3f4f6);
vertical-align: middle;
}
.members-table tr:last-child td {
border-bottom: none;
}
.member-cell {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.member-initial {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.member-info-name {
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.member-info-email {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.role-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.role-badge.owner {
background: #fef3c7;
color: #92400e;
}
.role-badge.moderator {
background: #dbeafe;
color: #1d4ed8;
}
.role-badge.member {
background: #f3f4f6;
color: #6b7280;
}
.action-buttons {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
.btn-action {
padding: 4px 12px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 500;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.btn-action:hover {
background: var(--background);
color: var(--text-primary);
}
.btn-action.danger {
color: #dc2626;
border-color: #fecaca;
}
.btn-action.danger:hover {
background: #fef2f2;
border-color: #dc2626;
}
.btn-action.moderator-toggle {
color: var(--primary);
border-color: #bfdbfe;
}
.btn-action.moderator-toggle:hover {
background: #eff6ff;
}
/* Add member section */
.add-member-section {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color, #e5e7eb);
}
.add-member-section h3 {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.add-member-autocomplete {
position: relative;
}
.add-member-autocomplete input[type="text"] {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
transition: var(--transition);
}
.add-member-autocomplete input[type="text"]:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.add-autocomplete-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
max-height: 240px;
overflow-y: auto;
z-index: 100;
box-shadow: var(--shadow-lg);
display: none;
}
.add-autocomplete-results.visible {
display: block;
}
.add-autocomplete-item {
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-sm);
transition: background 0.15s;
}
.add-autocomplete-item:hover {
background: var(--background);
}
.add-autocomplete-item-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.add-autocomplete-item-initial {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.add-autocomplete-item-name {
font-weight: 500;
font-size: var(--font-size-sm);
}
.add-autocomplete-item-company {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.add-autocomplete-no-results {
padding: var(--spacing-sm) var(--spacing-md);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
@media (max-width: 640px) {
.manage-card {
padding: var(--spacing-md);
}
.members-table th:last-child,
.members-table td:last-child {
text-align: right;
}
.action-buttons {
justify-content: flex-end;
}
}
{% endblock %}
{% block content %}
<div class="manage-container">
<a href="{{ url_for('messages.group_view', group_id=group.id) }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Powrot do grupy
</a>
<div class="manage-header">
<h1>Zarzadzanie grupa</h1>
</div>
{# ===== GROUP INFO EDIT (owner only) ===== #}
{% if membership.is_owner %}
<div class="manage-card">
<h2>Informacje o grupie</h2>
<form method="POST" action="{{ url_for('messages.group_update', group_id=group.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="group-name">Nazwa grupy</label>
<input type="text" id="group-name" name="name" maxlength="100" value="{{ group.name or '' }}" placeholder="np. Komisja ds. PEJ">
</div>
<div class="form-group">
<label for="group-description">Opis (opcjonalnie)</label>
<textarea id="group-description" name="description" maxlength="500" placeholder="Krotki opis grupy...">{{ group.description or '' }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Zapisz zmiany</button>
</form>
</div>
{% endif %}
{# ===== MEMBERS TABLE ===== #}
<div class="manage-card">
<h2>Uczestnicy ({{ members|length }})</h2>
<table class="members-table">
<thead>
<tr>
<th>Osoba</th>
<th>Rola</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for m in members %}
<tr>
<td>
<div class="member-cell">
{% if m.user.avatar_path %}
<img src="{{ m.user.avatar_path }}" class="member-avatar" alt="">
{% else %}
<div class="member-initial">{{ (m.user.name or m.user.email)[0].upper() }}</div>
{% endif %}
<div>
<div class="member-info-name">{{ m.user.name or m.user.email.split('@')[0] }}</div>
{% if m.user.privacy_show_email != False %}
<div class="member-info-email">{{ m.user.email }}</div>
{% endif %}
</div>
</div>
</td>
<td>
{% if m.is_owner %}
<span class="role-badge owner">Wlasciciel</span>
{% elif m.is_moderator %}
<span class="role-badge moderator">Moderator</span>
{% else %}
<span class="role-badge member">Uczestnik</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
{% if m.user_id != current_user.id and not m.is_owner %}
{% if membership.is_owner %}
{# Owner can toggle moderator and remove #}
<form method="POST" action="{{ url_for('messages.group_toggle_moderator', group_id=group.id, user_id=m.user_id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-action moderator-toggle" title="{% if m.is_moderator %}Odbierz moderatora{% else %}Nadaj moderatora{% endif %}">
{% if m.is_moderator %}
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
Odbierz mod.
{% else %}
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
Nadaj mod.
{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('messages.group_remove_member', group_id=group.id, user_id=m.user_id) }}" style="display:inline;" onsubmit="return confirm('Czy na pewno chcesz usunac tego uczestnika z grupy?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-action danger">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path 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>
Usun
</button>
</form>
{% elif membership.is_moderator and not m.is_moderator %}
{# Moderator can remove members only #}
<form method="POST" action="{{ url_for('messages.group_remove_member', group_id=group.id, user_id=m.user_id) }}" style="display:inline;" onsubmit="return confirm('Czy na pewno chcesz usunac tego uczestnika z grupy?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-action danger">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path 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>
Usun
</button>
</form>
{% endif %}
{% elif m.user_id == current_user.id %}
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Ty</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# ===== ADD MEMBER ===== #}
<div class="add-member-section">
<h3>Dodaj uczestnika</h3>
<div class="add-member-autocomplete" id="add-member-autocomplete">
<input type="text" id="add-member-search" placeholder="Wpisz imie, nazwisko lub email..." autocomplete="off">
<div id="add-autocomplete-results" class="add-autocomplete-results"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
/* Add member autocomplete */
(function() {
var availableUsers = [
{% for user in available_users %}
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, companyName: {{ (user._company_name or '') | tojson }}, avatarPath: {{ (user.avatar_path or '') | tojson }}}{{ ',' if not loop.last }}
{% endfor %}
];
var searchInput = document.getElementById('add-member-search');
var resultsDiv = document.getElementById('add-autocomplete-results');
var autocompleteDiv = document.getElementById('add-member-autocomplete');
var groupId = {{ group.id }};
var csrfToken = '{{ csrf_token() }}';
function normalize(str) {
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
function filterUsers(query) {
if (!query) return [];
var q = normalize(query);
return availableUsers.filter(function(u) {
return normalize(u.name).indexOf(q) !== -1 ||
normalize(u.email).indexOf(q) !== -1 ||
normalize(u.companyName).indexOf(q) !== -1;
});
}
function renderResults(matches) {
if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="add-autocomplete-no-results">Nie znaleziono</div>';
resultsDiv.classList.add('visible');
return;
}
resultsDiv.innerHTML = matches.map(function(u) {
var initial = (u.name || u.email)[0].toUpperCase();
var avatarHtml = u.avatarPath
? '<img src="' + u.avatarPath + '" class="add-autocomplete-item-avatar" alt="">'
: '<div class="add-autocomplete-item-initial">' + initial + '</div>';
var companyHtml = u.companyName ? '<div class="add-autocomplete-item-company">' + u.companyName + '</div>' : '';
return '<div class="add-autocomplete-item" data-id="' + u.id + '">' +
avatarHtml +
'<div>' +
'<div class="add-autocomplete-item-name">' + u.name + '</div>' +
companyHtml +
'</div>' +
'</div>';
}).join('');
resultsDiv.classList.add('visible');
}
searchInput.addEventListener('input', function() {
var q = this.value.trim();
if (q.length === 0) {
resultsDiv.classList.remove('visible');
return;
}
renderResults(filterUsers(q));
});
searchInput.addEventListener('focus', function() {
if (this.value.trim().length > 0) {
renderResults(filterUsers(this.value.trim()));
}
});
resultsDiv.addEventListener('click', function(e) {
var item = e.target.closest('.add-autocomplete-item');
if (item) {
var userId = item.dataset.id;
/* Submit add member via hidden form */
var form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("messages.group_add_member", group_id=group.id) }}';
var csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
var userInput = document.createElement('input');
userInput.type = 'hidden';
userInput.name = 'user_id';
userInput.value = userId;
form.appendChild(userInput);
document.body.appendChild(form);
form.submit();
}
});
document.addEventListener('click', function(e) {
if (!autocompleteDiv.contains(e.target)) {
resultsDiv.classList.remove('visible');
}
});
})();
{% endblock %}

View File

@ -0,0 +1,549 @@
{% extends "base.html" %}
{% block title %}{{ group.display_name }} - Norda Biznes Partner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
{% endblock %}
{% block extra_css %}
.group-container {
max-width: 800px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-lg);
font-size: var(--font-size-sm);
}
.back-link:hover {
color: var(--primary);
}
/* Group header */
.group-header {
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color, #e5e7eb);
padding: var(--spacing-lg) var(--spacing-xl);
margin-bottom: var(--spacing-lg);
}
.group-header-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
}
.group-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.group-member-count {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
background: #eff6ff;
border-radius: 12px;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--primary);
}
.group-manage-link {
font-size: var(--font-size-sm);
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.group-manage-link:hover {
text-decoration: underline;
}
/* Members collapsible */
.members-toggle {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-top: var(--spacing-md);
padding: 0;
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-secondary);
}
.members-toggle:hover {
color: var(--text-primary);
}
.members-toggle svg {
transition: transform 0.2s;
}
.members-toggle.open svg {
transform: rotate(90deg);
}
.members-list {
display: none;
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--border-color, #f3f4f6);
}
.members-list.visible {
display: block;
}
.member-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) 0;
}
.member-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.member-initial {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 11px;
flex-shrink: 0;
}
.member-name {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.role-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.role-badge.owner {
background: #fef3c7;
color: #92400e;
}
.role-badge.moderator {
background: #dbeafe;
color: #1d4ed8;
}
/* Messages */
.messages-section {
margin-bottom: var(--spacing-lg);
}
.group-message {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.msg-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.msg-initial {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.msg-initial.is-me {
background: var(--secondary);
}
.msg-bubble {
flex: 1;
min-width: 0;
}
.msg-header {
display: flex;
align-items: baseline;
gap: var(--spacing-sm);
margin-bottom: 2px;
}
.msg-sender {
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.msg-time {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.msg-content {
background: var(--surface);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-sm);
line-height: 1.6;
color: var(--text-primary);
}
.msg-content img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: var(--spacing-xs) 0;
}
.msg-content a {
color: var(--primary);
}
.msg-content p {
margin-bottom: var(--spacing-xs);
}
.msg-content p:last-child {
margin-bottom: 0;
}
.msg-attachments {
margin-top: var(--spacing-xs);
}
.msg-attachment-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: var(--font-size-sm);
}
/* Reply form */
.reply-section {
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color, #e5e7eb);
padding: var(--spacing-lg) var(--spacing-xl);
}
.reply-section h3 {
font-size: var(--font-size-base);
font-weight: 600;
margin-bottom: var(--spacing-md);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.quill-container {
border: 1px solid var(--border);
border-radius: var(--radius);
}
.quill-container .ql-toolbar {
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
.quill-container .ql-container {
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
font-size: var(--font-size-base);
}
.quill-container .ql-editor {
min-height: 120px;
}
.quill-container .ql-editor img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: var(--spacing-sm) 0;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl) var(--spacing-lg);
color: var(--text-secondary);
}
@media (max-width: 640px) {
.group-header {
padding: var(--spacing-md);
}
.group-header-top {
flex-direction: column;
align-items: flex-start;
}
.reply-section {
padding: var(--spacing-md);
}
}
{% endblock %}
{% block content %}
<div class="group-container">
<a href="{{ url_for('messages_inbox') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Powrot do wiadomosci
</a>
{# ===== GROUP HEADER ===== #}
<div class="group-header">
<div class="group-header-top">
<div class="group-title">
{{ group.display_name }}
<span class="group-member-count">{{ members|length }}</span>
</div>
{% if membership.is_owner or membership.is_moderator %}
<a href="{{ url_for('messages.group_manage', group_id=group.id) }}" class="group-manage-link">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="vertical-align: -2px;">
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
Zarzadzaj
</a>
{% endif %}
</div>
<button class="members-toggle" id="members-toggle" type="button">
<svg width="12" height="12" fill="currentColor" viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
Uczestnicy ({{ members|length }})
</button>
<div class="members-list" id="members-list">
{% for m in members %}
<div class="member-item">
{% if m.user.avatar_path %}
<img src="{{ m.user.avatar_path }}" class="member-avatar" alt="">
{% else %}
<div class="member-initial">{{ (m.user.name or m.user.email)[0].upper() }}</div>
{% endif %}
<span class="member-name">
{% if m.user_id == current_user.id %}Ty{% else %}{{ m.user.name or m.user.email.split('@')[0] }}{% endif %}
</span>
{% if m.is_owner %}
<span class="role-badge owner">Wlasciciel</span>
{% elif m.is_moderator %}
<span class="role-badge moderator">Moderator</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{# ===== MESSAGES ===== #}
<div class="messages-section" id="messages-section">
{% if messages %}
{% for msg in messages %}
<div class="group-message">
{% if msg.sender.avatar_path %}
<img src="{{ msg.sender.avatar_path }}" class="msg-avatar" alt="">
{% else %}
<div class="msg-initial {% if msg.sender_id == current_user.id %}is-me{% endif %}">
{{ (msg.sender.name or msg.sender.email)[0].upper() }}
</div>
{% endif %}
<div class="msg-bubble">
<div class="msg-header">
<span class="msg-sender">{% if msg.sender_id == current_user.id %}Ty{% else %}{{ msg.sender.name or msg.sender.email.split('@')[0] }}{% endif %}</span>
<span class="msg-time">{{ msg.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
</div>
<div class="msg-content">{{ msg.content|linkify }}</div>
{% if msg.attachments %}
<div class="msg-attachments">
{% for att in msg.attachments %}
{% set is_image = att.mime_type and att.mime_type.startswith('image/') %}
<div class="msg-attachment-item">
{% if is_image %}
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" target="_blank">
<img src="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" alt="{{ att.filename }}" style="max-width: 200px; max-height: 120px; border-radius: var(--radius); border: 1px solid var(--border-color, #e5e7eb);">
</a>
{% else %}
<span style="font-size: 16px;">📄</span>
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" download="{{ att.filename }}" style="color: var(--primary); font-weight: 500; font-size: var(--font-size-sm);">{{ att.filename }}</a>
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ (att.file_size / 1024)|round(0)|int }} KB</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Brak wiadomosci w grupie</p>
</div>
{% endif %}
</div>
{# ===== REPLY FORM ===== #}
<div class="reply-section">
<h3>Napisz wiadomosc</h3>
<form method="POST" action="{{ url_for('messages.group_send', group_id=group.id) }}" enctype="multipart/form-data" id="reply-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<div id="quill-reply" class="quill-container" style="background: var(--surface);"></div>
<textarea name="content" id="reply-content" style="display:none;" required></textarea>
</div>
<div class="form-group" style="margin-top: 8px;">
<input type="file" name="attachments" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="font-size: var(--font-size-sm);">
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Maks. 3 pliki, 15MB lacznie</span>
</div>
<button type="submit" class="btn btn-primary">Wyslij</button>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
/* Members toggle */
(function() {
var toggle = document.getElementById('members-toggle');
var list = document.getElementById('members-list');
if (toggle && list) {
toggle.addEventListener('click', function() {
toggle.classList.toggle('open');
list.classList.toggle('visible');
});
}
})();
/* Auto-scroll to bottom */
(function() {
var section = document.getElementById('messages-section');
if (section && section.children.length > 0) {
var lastMsg = section.lastElementChild;
if (lastMsg) {
lastMsg.scrollIntoView({behavior: 'instant', block: 'end'});
}
}
})();
/* Quill reply editor */
(function() {
var replyDiv = document.getElementById('quill-reply');
if (!replyDiv) return;
var csrfToken = '{{ csrf_token() }}';
var quill = new Quill('#quill-reply', {
theme: 'snow',
placeholder: 'Napisz wiadomosc...',
modules: {
toolbar: {
container: [
['bold', 'italic'],
['link', 'image'],
['clean']
],
handlers: {
image: function() {
var input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = function() {
if (input.files && input.files[0]) uploadImage(input.files[0]);
};
}
}
}
}
});
function uploadImage(file) {
var fd = new FormData();
fd.append('image', file);
fetch('/api/messages/upload-image', {
method: 'POST',
headers: {'X-CSRFToken': csrfToken},
body: fd
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.url) {
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', data.url);
quill.setSelection(range.index + 1);
}
});
}
quill.root.addEventListener('paste', function(e) {
var items = (e.clipboardData || {}).items || [];
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.stopImmediatePropagation();
e.preventDefault();
var file = items[i].getAsFile();
if (file) uploadImage(file);
return;
}
}
}, true);
var textarea = document.getElementById('reply-content');
quill.on('text-change', function() {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
});
document.getElementById('reply-form').addEventListener('submit', function(e) {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
if (!textarea.value.trim()) {
e.preventDefault();
alert('Tresc wiadomosci jest wymagana.');
}
});
})();
{% endblock %}

View File

@ -256,6 +256,55 @@
border-color: var(--primary);
}
.messages-search {
position: relative;
margin-bottom: var(--spacing-md);
}
.messages-search input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) 36px;
border: 1.5px solid var(--border-color, #e5e7eb);
border-radius: var(--radius);
font-size: var(--font-size-sm);
background: white;
transition: var(--transition);
}
.messages-search input:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(46, 72, 114, 0.1);
}
.messages-search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
}
.messages-search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
padding: 4px;
display: none;
}
.messages-search-clear.visible {
display: block;
}
@media (max-width: 640px) {
.messages-topbar {
flex-wrap: wrap;
@ -287,15 +336,65 @@
<a href="{{ url_for('messages_inbox') }}" class="tab-link active">Odebrane</a>
<a href="{{ url_for('messages_sent') }}" class="tab-link">Wysłane</a>
</div>
<a href="{{ url_for('messages_new') }}" class="btn btn-primary btn-sm">Nowa wiadomość</a>
<div style="display: flex; gap: var(--spacing-xs);">
<a href="{{ url_for('messages.group_compose') }}" class="btn btn-sm" style="background: white; color: var(--primary); border: 1.5px solid var(--primary); font-weight: 600;">👥 Nowa grupa</a>
<a href="{{ url_for('messages_new') }}" class="btn btn-primary btn-sm">Nowa wiadomość</a>
</div>
</div>
<form class="messages-search" method="GET" action="{{ url_for('messages.messages_inbox') }}">
<span class="messages-search-icon">🔍</span>
<input type="text" name="q" value="{{ search_query or '' }}" placeholder="Szukaj w wiadomościach..." autocomplete="off">
<button type="button" class="messages-search-clear {% if search_query %}visible{% endif %}" onclick="this.previousElementSibling.value=''; this.form.submit();"></button>
</form>
{% if search_query %}
<div style="margin-bottom: var(--spacing-md); color: var(--text-muted); font-size: var(--font-size-sm);">
Wyniki wyszukiwania dla: <strong>{{ search_query }}</strong>
<a href="{{ url_for('messages.messages_inbox') }}" style="margin-left: var(--spacing-sm); color: var(--primary);">Wyczyść</a>
</div>
{% endif %}
<div class="messages-list">
{% if group_items %}
{% for gi in group_items|sort(attribute='sort_date', reverse=True) %}
<a href="{{ url_for('messages.group_view', group_id=gi.group.id) }}" class="message-item {% if gi.unread_count > 0 %}unread{% endif %}">
<div class="message-avatar" style="background: #166534; font-size: 14px;">👥</div>
<div class="message-body">
<div class="message-top-row">
<div class="message-subject">
{{ gi.group.display_name }}
<span class="message-badges">
<span style="color: var(--text-secondary); font-size: 11px; background: rgba(46,72,114,0.1); padding: 1px 6px; border-radius: 8px;">{{ gi.group.member_count }} os.</span>
</span>
</div>
<span class="message-date">{{ gi.sort_date.strftime('%d.%m.%Y %H:%M') if gi.sort_date else '' }}</span>
</div>
<div class="message-bottom-row">
{% if gi.last_message %}
<span class="message-preview">{{ (gi.last_message.sender.name or 'Ktoś') if gi.last_message.sender else 'Ktoś' }}: {{ gi.last_message.content|striptags|truncate(80) }}</span>
{% else %}
<span class="message-preview" style="color: var(--text-muted);">Brak wiadomości</span>
{% endif %}
</div>
{% if gi.unread_count > 0 %}
<span class="status-pill new-msg">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/></svg>
{{ gi.unread_count }} nowe
</span>
{% endif %}
</div>
</a>
{% endfor %}
{% endif %}
{% if messages %}
{% for msg in messages %}
<a href="{{ url_for('messages_view', message_id=msg.id) }}" class="message-item {% if not msg.is_read %}unread{% endif %}">
<div class="message-avatar">
{% if msg.sender.avatar_path %}
<img src="{{ url_for('static', filename='uploads/' + msg.sender.avatar_path) }}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
{% else %}
{{ (msg.sender.name or msg.sender.email)[0].upper() }}
{% endif %}
</div>
<div class="message-body">
<div class="message-top-row">
@ -334,9 +433,11 @@
</a>
{% endfor %}
{% else %}
{% if not group_items %}
<div class="empty-state">
<p>Brak wiadomości w skrzynce</p>
</div>
{% endif %}
{% endif %}
</div>

View File

@ -233,6 +233,55 @@
border-color: var(--primary);
}
.messages-search {
position: relative;
margin-bottom: var(--spacing-md);
}
.messages-search input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) 36px;
border: 1.5px solid var(--border-color, #e5e7eb);
border-radius: var(--radius);
font-size: var(--font-size-sm);
background: white;
transition: var(--transition);
}
.messages-search input:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(46, 72, 114, 0.1);
}
.messages-search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
}
.messages-search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
padding: 4px;
display: none;
}
.messages-search-clear.visible {
display: block;
}
@media (max-width: 640px) {
.messages-topbar {
flex-wrap: wrap;
@ -267,6 +316,18 @@
<a href="{{ url_for('messages_new') }}" class="btn btn-primary btn-sm">Nowa wiadomość</a>
</div>
<form class="messages-search" method="GET" action="{{ url_for('messages.messages_sent') }}">
<span class="messages-search-icon">🔍</span>
<input type="text" name="q" value="{{ search_query or '' }}" placeholder="Szukaj w wiadomościach..." autocomplete="off">
<button type="button" class="messages-search-clear {% if search_query %}visible{% endif %}" onclick="this.previousElementSibling.value=''; this.form.submit();"></button>
</form>
{% if search_query %}
<div style="margin-bottom: var(--spacing-md); color: var(--text-muted); font-size: var(--font-size-sm);">
Wyniki wyszukiwania dla: <strong>{{ search_query }}</strong>
<a href="{{ url_for('messages.messages_sent') }}" style="margin-left: var(--spacing-sm); color: var(--primary);">Wyczyść</a>
</div>
{% endif %}
<div class="messages-list">
{% if messages %}
{% for msg in messages %}