feat(calendar): интеграция выбора отдела через API /my-departments

feat(calendar): правый календарь с выбором источника и созданием событий (Bearer)
fix(layout): выровнял колонки к центральному контейнеру, убрал лишние отступы
fix(templates): убрана несуществующая тэг-функция токена; добавлен meta api-token
feat(chat/ui): полировка chat-root, скролла и кнопки «вниз»
style(feed): «Подробнее/Скрыть» — серые без подчёркивания

- добавлен эндпоинт DepartmentViewSet.my_departments для получения доступных отделов
  (HR → все отделы, иначе только руководимые/активные)
- настроены permissions для нового экшена
- фронтенд: заменена логика дропдауна календаря
  * загрузка списка отделов через /api/v1/departments/my-departments/
  * автогенерация пунктов меню с именами отделов
  * fallback на data-dept-id при пустом ответе
- обновлена подпись текущего календаря (кнопка и модалка «Создать событие»)
- сохранена логика работы с событиями (компания/отделы), переработан только источник данных для списка отделов
— Календарь (правая колонка)
  • Добавлен дропдаун возле «Календарь» для выбора источника: «Компания» и «Мои отделы».
  • «Мои отделы» подгружаются из GET /api/v1/employees/my-departments/ (JSON: [{id, name}]).
  • Рядом с «События» добавлена кнопка «+», открывающая модал создания события.
  • Создание события уходит в POST:
      - /api/v1/calendar/company-events/
      - /api/v1/calendar/departments/<id>/events/
    Форматы: all-day (date) либо с временем (start/end ISO). Не через сессию.
  • FullCalendar переключает источник событий в зависимости от выбранного календаря и
    ходит за ними с параметрами ?start=YYYY-MM-DD&end=YYYY-MM-DD.
  • Вертикальная «На этой неделе» рендерится из тех же данных (без лишних запросов,
    если события уже в памяти FullCalendar).
  • Мобильная версия — Offcanvas + тот же функционал.

— Авторизация / API
  • Все запросы к API — через Authorization: Bearer <token>.
  • В base.html добавлен мета-тег с токеном:
      <meta name="api-token" content="{{ request.user.api_token|default:'' }}">
  • Общий helper authFetch() отключает cookie/сессию и всегда проставляет заголовки.
  • Это устраняет 401 при GET и готовит почву под POST (ранее наблюдался 405 без POST-ручек).

— Вёрстка / контейнер
  • Левую и правую колонки «привязал» к центральному блоку (container-xxl), убраны
    чрезмерные внутренние паддинги, чтобы центральный контент не сжимался.
  • Уточнены вертикальные расстояния между карточками (gap / margin), карточки теперь
    реально «прижимаются» по вертикали, где это нужно.
  • В main контент тянется на всю доступную ширину внутри контейнера.

— Чаты
  • Объяснён и оставлен padding у .chat-root (верх/низ под шапку и композер, включая safe-area).
  • Кнопка «вниз» и стили скролла — без конфликтов с обёртками.

— Шаблоны
  • Удалён несуществующий тэг `{% api_access_token %}` (исправляет TemplateSyntaxError).
  • Вставлен рабочий meta name="api-token" вместо кастомного тега.

— Кнопки «Подробнее/Скрыть»
  • Сделаны серыми и без подчёркивания (btn btn-link text-secondary text-decoration-none).

Затронутые файлы (основные):
  • backend/templates/base.html
  • backend/templates/includes/rightbar_calendar.html        (новый/полностью переписан)
  • backend/templates/feed/_feed_cards.html                  (кнопки «Подробнее/Скрыть»)
  • backend/templates/includes/sidebar.html                  (мелкие правки отступов)
  • стили в базовом шаблоне: правки контейнеров/отступов/гридов
  • (JS в rightbar_calendar): authFetch, загрузка отделов, POST создания события, weekly strip

Примечания по бэку (ожидания фронта):
  • GET  /api/v1/employees/my-departments/ → 200 OK, JSON: [{ "id": <int>, "name": <str> }, …]
  • GET  /api/v1/calendar/company-events/?start=&end=
  • GET  /api/v1/calendar/departments/<id>/events/?start=&end=
  • POST /api/v1/calendar/company-events/                      (body: {title, date|start,end, allDay?, color?})
  • POST /api/v1/calendar/departments/<id>/events/            (аналогично)
  • Все ручки принимают Authorization: Bearer <JWT/токен>.
  • Если раньше был 405 на POST — включить create() во вьюсетах и добавить в роутер.
This commit is contained in:
admin i 2025-09-08 17:19:57 +03:00
parent 142054ccc8
commit c5123eb02f
27 changed files with 796 additions and 545 deletions

BIN
backend/api.zip Normal file

Binary file not shown.

View File

View File

@ -0,0 +1,16 @@
# backend/api/templatetags/api_tags.py
from django import template
from api.client import SESSION_KEY_ACCESS # 'api.jwt_access'
register = template.Library()
@register.simple_tag
def api_access_token(request):
"""
Возвращает access-токен из сессии (если есть) для использования на фронте.
Безопасность: это тот же токен, которым ты и так ходишь на API.
"""
try:
return request.session.get(SESSION_KEY_ACCESS, "") or ""
except Exception:
return ""

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -1,103 +1,24 @@
import base64
import binascii
from collections import defaultdict
from io import BytesIO
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.files.base import ContentFile
from employees.constants import ACTION_CHOICES
from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
EmployeeAction,
EmployeeDepartment,
Position,
Skill,
DepartmentPermission,
)
from PIL import Image
from rest_framework import serializers
from ..serializers import Base64ImageField
Employee = get_user_model()
class Base64ImageField(serializers.ImageField):
"""
Двухстороннее поле:
- input: принимает строку base64 или data URI (data:image/...;base64,xxxx)
- output: возвращает data URI (data:image/...;base64,xxxx)
Совместимо с Python 3.13 (без imghdr).
"""
default_ext = "jpg"
def _detect_ext_mime(self, content: bytes) -> tuple[str, str]:
"""Определяем расширение и MIME через Pillow."""
try:
im = Image.open(BytesIO(content))
fmt = (im.format or "").upper()
ext_map = {
"JPEG": "jpg",
"JPG": "jpg",
"PNG": "png",
"WEBP": "webp",
"GIF": "gif",
"BMP": "bmp",
"TIFF": "tiff",
"ICO": "ico",
}
ext = ext_map.get(fmt, self.default_ext)
mime = Image.MIME.get(fmt, "image/jpeg")
return ext, mime
except Exception:
return self.default_ext, "image/jpeg"
def to_internal_value(self, data):
# Принимаем None/пустое
if data in (None, "", b""):
return super().to_internal_value(data)
if isinstance(data, str):
# Срезаем префикс data:image/...;base64, если есть
if data.startswith("data:image"):
try:
_, data = data.split(";base64,", 1)
except ValueError:
raise serializers.ValidationError("Некорректный формат data URI.")
try:
decoded = base64.b64decode(data)
except (TypeError, ValueError, binascii.Error):
raise serializers.ValidationError("Невалидный base64.")
ext, _ = self._detect_ext_mime(decoded)
file = ContentFile(decoded, name=f"upload.{ext}")
return super().to_internal_value(file)
return super().to_internal_value(data)
def to_representation(self, value):
if not value:
return ""
# Получаем байты
try:
file_obj = getattr(value, "file", None) or value
content = file_obj.read()
if hasattr(file_obj, "seek"):
file_obj.seek(0)
except Exception:
try:
with open(value.path, "rb") as f:
content = f.read()
except Exception:
return ""
b64 = base64.b64encode(content).decode("ascii")
_, mime = self._detect_ext_mime(content)
return f"data:{mime};base64,{b64}"
class EmailSerializer(serializers.Serializer):
email = serializers.EmailField()
@ -701,3 +622,9 @@ class RemoveMemberInput(serializers.Serializer):
)
attrs["employee_id"] = emp
return attrs
class DepartmentBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Department
fields = ("id", "name")

View File

@ -38,7 +38,7 @@ from employees.models import (
Skill,
)
from phonenumbers import PhoneNumberFormat
from rest_framework import filters, status, viewsets
from rest_framework import filters, generics, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import (
AllowAny,
@ -48,6 +48,7 @@ from rest_framework.permissions import (
)
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.authentication import JWTAuthentication
from ..permissions import (
ASSIGN_ROLE_PERM,
@ -60,6 +61,7 @@ from ..permissions import (
)
from .serializers import (
AddMemberInput,
DepartmentBriefSerializer,
DepartmentRoleSerializer,
DepartmentSerializer,
EmailSerializer,
@ -927,6 +929,39 @@ class DepartmentViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
@action(detail=False, methods=["get"], url_path="my-departments")
def my_departments(self, request) -> Response:
"""Вернёт список отделов, доступных текущему пользователю.
Логика:
Только отделы, где пользователь является руководителем
или имеет активную связь в ``EmployeeDepartment`` (is_active=True).
Args:
request: Текущий HTTP-запрос DRF.
Returns:
Response: JSON-массив отделов в формате ``DepartmentSerializer`` (many=True),
отсортированный по названию и id.
Raises:
NotAuthenticated: Если пользователь не авторизован (обрабатывается классом
разрешений в ``get_permissions``).
"""
user = request.user
qs = self.get_queryset()
active_link_exists = EmployeeDepartment.objects.filter(
department_id=OuterRef("pk"),
employee_id=user.id,
is_active=True,
)
user_qs = qs.filter(Q(head_id=user.id) | Exists(active_link_exists)).distinct()
user_qs = user_qs.order_by("name", "id")
data = DepartmentBriefSerializer(user_qs, many=True).data
return Response(data)
class EmployeeViewSet(viewsets.ModelViewSet):
"""

View File

@ -1,22 +1,25 @@
# backend/api/v1/feed/serializers.py
from __future__ import annotations
from typing import Any, Dict, Optional
from django.contrib.auth import get_user_model
from rest_framework import serializers
from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
from feed.models import Comment, Post, PostLike
from ..serializers import Base64ImageField
Employee = get_user_model()
class AuthorMiniSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
avatar_url = serializers.SerializerMethodField()
avatar = Base64ImageField(read_only=True)
class Meta:
model = Employee
fields = ["id", "first_name", "last_name", "full_name", "avatar_url"]
fields = ["id", "first_name", "last_name", "full_name", "avatar"]
def get_full_name(self, obj):
fn = (obj.first_name or "").strip()
@ -24,19 +27,6 @@ class AuthorMiniSerializer(serializers.ModelSerializer):
nm = f"{fn} {ln}".strip()
return nm
def get_avatar_url(self, obj):
# пробуем несколько распространённых имён поля/метода
try:
for attr in ("avatar", "photo", "image"):
f = getattr(obj, attr, None)
if f and getattr(f, "url", None):
return f.url
if hasattr(obj, "get_avatar_url"):
return obj.get_avatar_url()
except Exception:
pass
return None
class CommentMiniSerializer(serializers.ModelSerializer):
author = AuthorMiniSerializer(read_only=True)
@ -70,8 +60,8 @@ class PostListSerializer(serializers.ModelSerializer):
fields = [
"id",
"type",
"department", # write/read PK
"department_id", # read-only alias
"department",
"department_id",
"title",
"body",
"image",

View File

@ -0,0 +1,82 @@
import base64
import binascii
from io import BytesIO
from django.core.files.base import ContentFile
from PIL import Image
from rest_framework import serializers
class Base64ImageField(serializers.ImageField):
"""
Двухстороннее поле:
- input: принимает строку base64 или data URI (data:image/...;base64,xxxx)
- output: возвращает data URI (data:image/...;base64,xxxx)
Совместимо с Python 3.13 (без imghdr).
"""
default_ext = "jpg"
def _detect_ext_mime(self, content: bytes) -> tuple[str, str]:
"""Определяем расширение и MIME через Pillow."""
try:
im = Image.open(BytesIO(content))
fmt = (im.format or "").upper()
ext_map = {
"JPEG": "jpg",
"JPG": "jpg",
"PNG": "png",
"WEBP": "webp",
"GIF": "gif",
"BMP": "bmp",
"TIFF": "tiff",
"ICO": "ico",
}
ext = ext_map.get(fmt, self.default_ext)
mime = Image.MIME.get(fmt, "image/jpeg")
return ext, mime
except Exception:
return self.default_ext, "image/jpeg"
def to_internal_value(self, data):
# Принимаем None/пустое
if data in (None, "", b""):
return super().to_internal_value(data)
if isinstance(data, str):
# Срезаем префикс data:image/...;base64, если есть
if data.startswith("data:image"):
try:
_, data = data.split(";base64,", 1)
except ValueError:
raise serializers.ValidationError("Некорректный формат data URI.")
try:
decoded = base64.b64decode(data)
except (TypeError, ValueError, binascii.Error):
raise serializers.ValidationError("Невалидный base64.")
ext, _ = self._detect_ext_mime(decoded)
file = ContentFile(decoded, name=f"upload.{ext}")
return super().to_internal_value(file)
return super().to_internal_value(data)
def to_representation(self, value):
if not value:
return ""
# Получаем байты
try:
file_obj = getattr(value, "file", None) or value
content = file_obj.read()
if hasattr(file_obj, "seek"):
file_obj.seek(0)
except Exception:
try:
with open(value.path, "rb") as f:
content = f.read()
except Exception:
return ""
b64 = base64.b64encode(content).decode("ascii")
_, mime = self._detect_ext_mime(content)
return f"data:{mime};base64,{b64}"

View File

@ -1,18 +1,20 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .calendar.viewsets import CompanyEventsViewSet, DepartmentEventsViewSet
from .calendar.views import CompanyEventsViewSet, DepartmentEventsViewSet
from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet,
EmployeeActionViewSet, EmployeeViewSet,
PositionViewSet, RegisterAPIView,
ResendEmailAPIView, SkillViewSet,
VerifyEmailAPIView, GroupViewSet)
GroupViewSet, PositionViewSet,
RegisterAPIView, ResendEmailAPIView,
SkillViewSet, VerifyEmailAPIView)
from .feed.views import CommentViewSet, PostViewSet
app_name = "v1"
router = DefaultRouter()
router.register(r"calendar/company-events", CompanyEventsViewSet, basename="company-events")
router.register(
r"calendar/company-events", CompanyEventsViewSet, basename="company-events"
)
router.register(r"departments", DepartmentViewSet, basename="departments")
router.register(r"employees", EmployeeViewSet, basename="employees")
router.register(r"employee-actions", EmployeeActionViewSet, basename="employee-actions")
@ -27,6 +29,10 @@ urlpatterns = [
path("auth/register/", RegisterAPIView.as_view(), name="register"),
path("auth/resend-email/", ResendEmailAPIView.as_view(), name="resend-email"),
path("auth/verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"),
path("calendar/departments/<int:pk>/events/", DepartmentEventsViewSet.as_view({"get": "list"}), name="department-events",),
path(
"calendar/departments/<int:pk>/events/",
DepartmentEventsViewSet.as_view({"get": "list"}),
name="department-events",
),
path("", include(router.urls)),
]

View File

@ -79,6 +79,32 @@ def _fetch_employee_posts(api, employee_id: int):
return _extract_results(data)
def _fetch_department_posts(api, department_id: int) -> list[dict]:
"""Возвращает публикации отдела (type=department), отсортированные по пину и дате.
Args:
api: Инициализированный API-клиент (get_api_client(request)).
department_id (int): ID отдела.
Returns:
list[dict]: Список публикаций для шаблона.
Raises:
RuntimeError: Если ответ API не JSON (редко, мы перехватываем безопасно).
ValueError: Если department_id некорректный (не int).
"""
# Пробуем явный фильтр по типу и отделу; fallback — только department
for url in (
f"v1/posts/?type=department&department={int(department_id)}&ordering=-pinned,-created_at",
f"v1/posts/?department={int(department_id)}&ordering=-pinned,-created_at",
):
r = api.get(url)
if r.ok:
data = r.json or []
return _extract_results(data)
return []
def _json_body(request: HttpRequest) -> Dict[str, Any]:
"""Безопасно парсим JSON; если не JSON — возвращаем пустой dict."""
try:
@ -106,7 +132,6 @@ def _api_ok_or_error(resp) -> Tuple[bool, Dict[str, Any], int]:
return resp.ok, data, getattr(resp, "status", 500)
# =========================
# Сотрудники
# =========================
@ -536,6 +561,7 @@ def department_detail(request, pk: int):
"can_manage": bool(perms.get("can_manage")),
"can_change_head": bool(perms.get("can_change_head")),
"can_assign_roles": bool(perms.get("can_assign_roles")),
"posts": _fetch_department_posts(api, pk)
}
)
return render(request, "employees/department_detail.html", ctx)

View File

@ -1,4 +1,4 @@
{% load static %}
{% load static api_tags %}
<!doctype html>
<html lang="ru" data-bs-theme="auto">
<head>
@ -7,6 +7,10 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="color-scheme" content="light dark">
{% if user.is_authenticated %}
<meta name="api-access" content="{% api_access_token request %}">
{% endif %}
<title>{% block title %}EUSRR{% endblock %}</title>
<!-- Bootstrap / Icons / FullCalendar -->
@ -19,8 +23,8 @@
<!-- Базовые стили приложения (+ feed) -->
<style>
:root{
--sidebar-w: 260px;
--rightbar-w: 370px;
--sidebar-w: 190px;
--rightbar-w: 350px;
--navbar-h: 60px;
--bs-primary:#007AFF;
--bs-link-color: var(--bs-primary);
@ -63,12 +67,13 @@
.app-layout{ display:block; }
@media (min-width: 992px){
.app-layout.with-sidebar.with-rightbar{
display:grid; grid-template-columns: var(--sidebar-w) 1fr var(--rightbar-w); align-items:start;
display:grid; grid-template-columns: var(--sidebar-w) 1fr var(--rightbar-w); align-items:start;
}
.app-layout.with-sidebar:not(.with-rightbar){
display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:start;
}
}
/* левый sidebar (sticky ≥lg) */
#sidebar{ --bs-offcanvas-width: var(--sidebar-w); border-radius:0 13px 13px 0; }
@ -105,7 +110,7 @@
.feed-header{ display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; }
.feed-header .title{ font-weight:800; letter-spacing:.2px; margin:0; }
.feed-stream{ display:flex; flex-direction:column; gap:1rem; }
.feed-stream{ display:flex; flex-direction:column; gap:0; }
.feed-card{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
@ -136,19 +141,21 @@
}
[data-bs-theme="dark"] .feed-pin{ background:#3b2e04; color:#ffda6a; border-color:#4f3f0a; }
.feed-body{ padding:.25rem 1rem 1rem 1rem; position:relative; }
.feed-title{ font-weight:700; margin:.25rem 0 .35rem 0; font-size:1.05rem; }
.feed-body{ padding:.25rem .75rem .5rem .75rem; position:relative; }
.feed-title {
font: 700 1.3rem/1.5 var(--bs-body-font-family);
margin: 0.25rem 0 0.35rem;
color: #4B4B4B; /* Альтернатива: rgba(0,0,0,.8) */
}
.feed-img{ position:relative; border-top:1px solid var(--bs-border-color); background: var(--bs-tertiary-bg); }
.feed-img{ position:relative; border-top:1px solid var(--bs-border-color); background: var(--bs-tertiary-bg); }
.feed-img img{ display:block; width:100%; height:auto; aspect-ratio:16/9; object-fit:cover; }
.feed-actions{
display:flex; align-items:center; gap:.5rem; padding:.6rem 1rem .9rem 1rem;
display:flex; align-items:center; gap:.5rem; padding:0rem 1rem .9rem 1rem;
border-top:1px solid var(--bs-border-color); position:relative; z-index:2;
}
.btn-ghost{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
color: var(--bs-body-color);
padding:.35rem .6rem; border-radius:999px; font-size:.9rem;
@ -204,241 +211,242 @@
.readmore-toggle{ position: relative; z-index: 2; }
</style>
<style>
/* Base card / layout */
.ios-card{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
border-radius: var(--feed-radius);
background: var(--bs-body-bg);
box-shadow: var(--feed-shadow);
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
overflow:hidden;
}
.ios-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
/* Base card / layout */
.ios-card{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
border-radius: var(--feed-radius);
background: var(--bs-body-bg);
box-shadow: var(--feed-shadow);
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
overflow:hidden;
}
.ios-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
.ios-header{
position: sticky; top: var(--navbar-h, 0px);
z-index: 5; padding:.75rem 0;
backdrop-filter: saturate(180%) blur(12px);
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
border-bottom: none !important;
}
.ios-title{ font-weight: 800; letter-spacing: -.01em; }
.row.g-ios{ align-items: flex-start; }
.ios-header{
position: sticky; top: var(--navbar-h, 0px);
z-index: 5; padding:.75rem 0;
backdrop-filter: saturate(180%) blur(12px);
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
border-bottom: none !important;
}
.ios-title{ font-weight: 800; letter-spacing: -.01em; }
.row.g-ios{ align-items: flex-start; }
.ios-ava, .ios-ava--ph{
display: grid; place-items: center; background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color); font-size: 3rem; line-height: 1;
width: 100%; height: 100%; border-radius: 28px;
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ava-wrap{ position: relative; width:132px; height:132px; margin:0 auto 12px; display:block; }
.ios-ava, .ios-ava--ph{
display: grid; place-items: center; background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color); font-size: 3rem; line-height: 1;
width: 100%; height: 100%; border-radius: 28px;
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ava-wrap{ position: relative; width:132px; height:132px; margin:0 auto 12px; display:block; }
.btn-icon{
width:36px; height:36px; padding:0; border-radius:9999px; display:grid; place-items:center;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.btn-icon:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.btn-icon{
width:36px; height:36px; padding:0; border-radius:9999px; display:grid; place-items:center;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.btn-icon:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.ios-name{ font-weight:800; font-size:1.25rem; }
.ios-role{ font-size:.9rem; color: var(--bs-secondary-color); }
.ios-name{ font-weight:800; font-size:1.25rem; }
.ios-role{ font-size:.9rem; color: var(--bs-secondary-color); }
.chips{ display:flex; flex-wrap:wrap; gap:.5rem; }
.chip{
padding:.35rem .65rem; border-radius:9999px; font-size:.85rem; line-height:1;
background: var(--bs-secondary-bg); color: var(--bs-body-color);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.chips{ display:flex; flex-wrap:wrap; gap:.5rem; }
.chip{
padding:.35rem .65rem; border-radius:9999px; font-size:.85rem; line-height:1;
background: var(--bs-secondary-bg); color: var(--bs-body-color);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ios-collapse-trigger{
width:100%; display:flex; align-items:center; justify-content:space-between; gap:.75rem;
padding:.6rem .9rem; cursor:pointer; border-radius:14px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.ios-collapse-caret{ transition: transform .25s ease; }
.ios-collapse-trigger[aria-expanded="true"] .ios-collapse-caret{ transform: rotate(180deg); }
.ios-collapse-trigger{
width:100%; display:flex; align-items:center; justify-content:space-between; gap:.75rem;
padding:.6rem .9rem; cursor:pointer; border-radius:14px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.ios-collapse-caret{ transition: transform .25s ease; }
.ios-collapse-trigger[aria-expanded="true"] .ios-collapse-caret{ transform: rotate(180deg); }
.ios-list{
border-radius:14px; overflow:hidden; background: var(--bs-body-bg);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell{
display:flex; align-items:center; gap:.75rem; padding:.85rem 1rem;
border-bottom:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell:last-child{ border-bottom:0; }
.ios-cell i{ width:1.25rem; text-align:center; opacity:.9; }
.ios-cell a{ text-decoration:none; color:inherit; }
.ios-list{
border-radius:14px; overflow:hidden; background: var(--bs-body-bg);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell{
display:flex; align-items:center; gap:.75rem; padding:.85rem 1rem;
border-bottom:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell:last-child{ border-bottom:0; }
.ios-cell i{ width:1.25rem; text-align:center; opacity:.9; }
.ios-cell a{ text-decoration:none; color:inherit; }
.ios-section-title{ font-weight:700; font-size:1rem; margin-bottom:.5rem; }
.rounded-ios{ border-radius:14px !important; }
.ios-section-title{ font-weight:700; font-size:1rem; margin-bottom:.5rem; }
.rounded-ios{ border-radius:14px !important; }
/* Departments list */
.ios-cell.dept-item{ justify-content: space-between; }
.ios-cell.dept-head{ border-left: 0; background: transparent; }
/* Departments list */
.ios-cell.dept-item{ justify-content: space-between; }
.ios-cell.dept-head{ border-left: 0; background: transparent; }
.dept-line{ display:flex; align-items:center; gap:.5rem; }
.dept-name{ font-weight:700; }
.dept-role{ color: var(--bs-secondary-color); }
.dept-line{ display:flex; align-items:center; gap:.5rem; }
.dept-name{ font-weight:700; }
.dept-role{ color: var(--bs-secondary-color); }
.badge-head{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px;
background: color-mix(in srgb, var(--bs-warning) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-warning) 60%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-warning) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1;
white-space: nowrap;
}
.badge-head .bi{ font-size:.95rem; line-height:1; }
.badge-head{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px;
background: color-mix(in srgb, var(--bs-warning) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-warning) 60%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-warning) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1;
white-space: nowrap;
}
.badge-head .bi{ font-size:.95rem; line-height:1; }
/* NEW: blue pill like .badge-head, for the gear edit button */
.badge-gear{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px; cursor:pointer;
background: color-mix(in srgb, var(--bs-primary) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-primary) 65%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-primary) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1; white-space: nowrap;
}
.badge-gear .bi{ font-size:.95rem; line-height:1; }
.badge-gear:hover{ background: color-mix(in srgb, var(--bs-primary) 20%, var(--bs-body-bg)); }
/* NEW: blue pill like .badge-head, for the gear edit button */
.badge-gear{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px; cursor:pointer;
background: color-mix(in srgb, var(--bs-primary) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-primary) 65%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-primary) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1; white-space: nowrap;
}
.badge-gear .bi{ font-size:.95rem; line-height:1; }
.badge-gear:hover{ background: color-mix(in srgb, var(--bs-primary) 20%, var(--bs-body-bg)); }
@media (max-width: 420px){
.badge-head .lbl, .badge-gear .lbl{ display:none; }
}
@media (max-width: 420px){
.badge-head .lbl, .badge-gear .lbl{ display:none; }
}
.ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-collapse-trigger + .collapse > .ios-list,
.ios-collapse-trigger + .collapse > .chips { margin-top: .5rem; }
.ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-collapse-trigger + .collapse > .ios-list,
.ios-collapse-trigger + .collapse > .chips { margin-top: .5rem; }
.collapsing { overflow: hidden; transition: height .35s ease; }
.collapsing { overflow: hidden; transition: height .35s ease; }
.ios-acc{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
border-radius:14px; background: var(--bs-body-bg); overflow: hidden;
}
.ios-acc + .ios-acc{ margin-top:.75rem; }
.ios-acc .ios-collapse-trigger{ border:0; border-radius:0; background: transparent; width:100%; padding:.6rem .9rem; }
.ios-acc .ios-collapse-trigger:focus{ box-shadow:none; }
.ios-acc .collapse{ margin:0 !important; }
.ios-acc .ios-list{ border:0; border-radius:0; }
.ios-acc .chips{ margin:0; }
.ios-acc .ios-acc-body{
padding:.5rem .25rem .75rem;
border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ios-acc{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
border-radius:14px; background: var(--bs-body-bg); overflow: hidden;
}
.ios-acc + .ios-acc{ margin-top:.75rem; }
.ios-acc .ios-collapse-trigger{ border:0; border-radius:0; background: transparent; width:100%; padding:.6rem .9rem; }
.ios-acc .ios-collapse-trigger:focus{ box-shadow:none; }
.ios-acc .collapse{ margin:0 !important; }
.ios-acc .ios-list{ border:0; border-radius:0; }
.ios-acc .chips{ margin:0; }
.ios-acc .ios-acc-body{
padding:.5rem .25rem .75rem;
border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
/* Activity block adjustments */
#activity .p-4{ padding:0 !important; }
#activity .ios-section-title{ padding:1rem 1rem .5rem !important; margin:0 !important; }
#activity .activity-feed{ margin:0 !important; }
#activity .activity-feed .feed-card{
width:100% !important; max-width:none !important; border:0 !important; box-shadow:none !important;
border-radius:0 !important; margin:0 !important;
}
#activity .activity-feed{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .feed-stream{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .row > *{ flex:0 0 100% !important; max-width:100% !important; }
#activity .feed-actions{ border-top: 0 !important; }
#activity .collapse .bg-body-tertiary.border{ border: 0 !important; }
/* Activity block adjustments */
#activity .p-4{ padding:0 !important; }
#activity .ios-section-title{ padding:1rem 1rem .5rem !important; margin:0 !important; }
#activity .activity-feed{ margin:0 !important; }
#activity .activity-feed .feed-card{
width:100% !important; max-width:none !important; border:0 !important; box-shadow:none !important;
border-radius:0 !important; margin:0 !important;
}
#activity .activity-feed{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .feed-stream{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .row > *{ flex:0 0 100% !important; max-width:100% !important; }
#activity .feed-actions{ border-top: 0 !important; }
#activity .collapse .bg-body-tertiary.border{ border: 0 !important; }
/* Skills UI */
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;cursor:pointer;border-style:dashed}
#skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease}
#skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem}
.chip.chip-skill{position:relative;padding:.35rem .65rem}
.chip.chip-skill .lbl{transition:opacity .15s ease}
.chip.chip-skill .chip-del-btn{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:transparent;border:0;padding:0;margin:0;cursor:pointer;
color:var(--bs-danger);font-size:1rem;line-height:1;
visibility:hidden;opacity:0;transition:opacity .15s ease;z-index:1
}
.chip.chip-skill .chip-del-btn .bi{pointer-events:none}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .lbl{opacity:0}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .chip-del-btn,
#skillsBlock[data-editable="1"] .chip.chip-skill:focus-within .chip-del-btn{visibility:visible;opacity:1}
.skills-empty{color:var(--bs-secondary-color);font-size:.875rem;margin-right:.25rem}
/* Skills UI */
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;cursor:pointer;border-style:dashed}
#skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease}
#skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem}
.chip.chip-skill{position:relative;padding:.35rem .65rem}
.chip.chip-skill .lbl{transition:opacity .15s ease}
.chip.chip-skill .chip-del-btn{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:transparent;border:0;padding:0;margin:0;cursor:pointer;
color:var(--bs-danger);font-size:1rem;line-height:1;
visibility:hidden;opacity:0;transition:opacity .15s ease;z-index:1
}
.chip.chip-skill .chip-del-btn .bi{pointer-events:none}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .lbl{opacity:0}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .chip-del-btn,
#skillsBlock[data-editable="1"] .chip.chip-skill:focus-within .chip-del-btn{visibility:visible;opacity:1}
.skills-empty{color:var(--bs-secondary-color);font-size:.875rem;margin-right:.25rem}
/* Dept inline editor */
.dept-edit-btn{
width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:999px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
}
.dept-edit-btn:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.dept-editor-wrap{ flex: 0 0 100%; width:100%; margin-top:.5rem; }
.dept-editor form{ display:flex; align-items:center; gap:.5rem; }
.dept-role-select{ min-width: 180px; }
/* полностью прячем роль, когда открыт редактор */
.dept-item.is-editing .dept-role{ display:none !important; }
/* Dept inline editor */
.dept-edit-btn{
width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:999px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
}
.dept-edit-btn:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.dept-editor-wrap{ flex: 0 0 100%; width:100%; margin-top:.5rem; }
.dept-editor form{ display:flex; align-items:center; gap:.5rem; }
.dept-role-select{ min-width: 180px; }
/* полностью прячем роль, когда открыт редактор */
.dept-item.is-editing .dept-role{ display:none !important; }
/* не даём блоку с кнопками сжиматься/уезжать */
.dept-item > .d-flex{
flex: 0 1 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: .35rem .5rem;
max-width: 100%;
}
/* 2) Левую часть реально можно ужать с многоточием */
.dept-line{ min-width: 0; }
/* не даём блоку с кнопками сжиматься/уезжать */
.dept-item > .d-flex{
flex: 0 1 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: .35rem .5rem;
max-width: 100%;
}
/* 2) Левую часть реально можно ужать с многоточием */
.dept-line{ min-width: 0; }
/* 3) Сами пилюли не растягиваются */
.badge-head,
.badge-gear{ flex: 0 0 auto; }
/* 3) Сами пилюли не растягиваются */
.badge-head,
.badge-gear{ flex: 0 0 auto; }
/* 4) Строка отдела допускает перенос элементов (на всякий) */
.ios-cell{ flex-wrap: wrap; }
/* 4) Строка отдела допускает перенос элементов (на всякий) */
.ios-cell{ flex-wrap: wrap; }
/* 5) При открытом редакторе полностью прячем роль */
.dept-item.is-editing .dept-role{ display:none !important; }
/* 5) При открытом редакторе полностью прячем роль */
.dept-item.is-editing .dept-role{ display:none !important; }
/* 6) На узких — прячем подписи внутри пилюль, чтобы всё поместилось */
@media (max-width: 576px){
.badge-head .lbl,
.badge-gear .lbl{ display:none; }
}
/* (аккуратная связка кнопки-разделителя и панели списка) — у тебя была потеряна скобка */
.ios-acc .ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-acc .ios-acc-body { border-top: 0; }
/* 6) На узких — прячем подписи внутри пилюль, чтобы всё поместилось */
@media (max-width: 576px){
.badge-head .lbl,
.badge-gear .lbl{ display:none; }
}
/* (аккуратная связка кнопки-разделителя и панели списка) — у тебя была потеряна скобка */
.ios-acc .ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-acc .ios-acc-body { border-top: 0; }
/* Группа иконок у заголовка */
.header-icon-group .btn-icon{ width:36px; height:36px; }
/* Группа иконок у заголовка */
.header-icon-group .btn-icon{ width:36px; height:36px; }
/* Варианты окраски, дружат со светлой/тёмной темой */
.btn-icon--primary{
color: var(--bs-primary);
border-color: color-mix(in srgb, var(--bs-primary) 30%, var(--bs-border-color));
}
.btn-icon--primary:hover{
background: color-mix(in srgb, var(--bs-primary) 16%, var(--bs-body-bg));
}
/* Варианты окраски, дружат со светлой/тёмной темой */
.btn-icon--primary{
color: var(--bs-primary);
border-color: color-mix(in srgb, var(--bs-primary) 30%, var(--bs-border-color));
}
.btn-icon--primary:hover{
background: color-mix(in srgb, var(--bs-primary) 16%, var(--bs-body-bg));
}
.btn-icon--success{
color: var(--bs-success);
border-color: color-mix(in srgb, var(--bs-success) 30%, var(--bs-border-color));
}
.btn-icon--success:hover{
background: color-mix(in srgb, var(--bs-success) 16%, var(--bs-body-bg));
}
.btn-icon--success{
color: var(--bs-success);
border-color: color-mix(in srgb, var(--bs-success) 30%, var(--bs-border-color));
}
.btn-icon--success:hover{
background: color-mix(in srgb, var(--bs-success) 16%, var(--bs-body-bg));
}
.btn-icon--danger{
color: var(--bs-danger);
border-color: color-mix(in srgb, var(--bs-danger) 30%, var(--bs-border-color));
}
.btn-icon--danger:hover{
background: color-mix(in srgb, var(--bs-danger) 16%, var(--bs-body-bg));
}
.btn-icon--danger{
color: var(--bs-danger);
border-color: color-mix(in srgb, var(--bs-danger) 30%, var(--bs-border-color));
}
.btn-icon--danger:hover{
background: color-mix(in srgb, var(--bs-danger) 16%, var(--bs-body-bg));
}
/* Акцентированный фокус для клавиатуры */
.btn-icon:focus-visible{
outline: 2px solid color-mix(in srgb, currentColor 60%, transparent);
outline-offset: 2px;
}
</style>
/* Акцентированный фокус для клавиатуры */
.btn-icon:focus-visible{
outline: 2px solid color-mix(in srgb, currentColor 60%, transparent);
outline-offset: 2px;
}
.container-xxl { max-width: 1820px; padding-inline: var(--bs-gutter-x,.75rem); }
</style>
{% block extra_css %}{% endblock %}
</head>
@ -446,18 +454,20 @@
{% include "includes/navbar.html" %}
<div class="app-layout container-fluid px-0 {% if user.is_authenticated %}with-sidebar with-rightbar{% endif %}">
{% if user.is_authenticated %}
{% include "includes/sidebar.html" %}
{% endif %}
<div class="container-xxl px-2 px-lg-3">
<div class="app-layout {% if user.is_authenticated %}with-sidebar with-rightbar{% endif %}">
{% if user.is_authenticated %}
{% include "includes/sidebar.html" %}
{% endif %}
<main class="main py-3 px-3 px-lg-4">
{% block content %}{% endblock %}
</main>
<main class="main py-3">
{% block content %}{% endblock %}
</main>
{% if user.is_authenticated %}
{% include "includes/rightbar_calendar.html" %}
{% endif %}
{% if user.is_authenticated %}
{% include "includes/rightbar_calendar.html" %}
{% endif %}
</div>
</div>
<!-- JS -->

View File

@ -8,21 +8,12 @@
main.main{ padding-bottom:0 !important; }
/* ===== Контейнер страницы чата ===== */
.chat-root{
--chat-top-gap: 20px;
--chat-bottom-gap: 48px;
height: calc(100dvh - var(--navbar-h));
padding: var(--chat-top-gap) 0 calc(var(--chat-bottom-gap) + env(safe-area-inset-bottom));
box-sizing: border-box;
overflow: hidden;
display: flex; flex-direction: column;
background: var(--bs-body-bg);
}
@supports(height: 100svh){
.chat-root{ height: calc(100svh - var(--navbar-h)); }
}
.chat-root > .row{ height:100%; min-height:0; }
.chat-col{ display:flex; flex-direction:column; height:100%; min-height:0; }
.chat-col{ display:flex; flex-direction:column; height:93%; min-height:0; }
.feed-header{ flex:0 0 auto; }
/* ===== Карточка чата ===== */
@ -129,8 +120,8 @@
{% block content %}
<div class="container chat-root">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8 chat-col">
<div class="row gx-0">
<div class="col-12 chat-col">
<!-- Заголовок -->
<div class="feed-header">

View File

@ -40,8 +40,8 @@
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
{# Заголовок + ПОИСК (iOS-стиль) #}
<div class="feed-header d-flex align-items-center">

View File

@ -224,9 +224,9 @@
<div class="feed-author">Публикации отдела</div>
</div>
</div>
<div class="feed-body">
<div>
{% if posts %}
{% include "feed/_feed_cards.html" with items=posts only %}
{% include "feed/_feed_cards.html" with items=posts %}
{% else %}
<div class="text-secondary small">Публикаций пока нет.</div>
{% endif %}

View File

@ -23,8 +23,8 @@
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
<div class="feed-header d-flex align-items-center">
<i class="bi-diagram-3 fs-2 text-primary"></i>

View File

@ -406,16 +406,21 @@
</div>
{% endif %}
<article class="ios-card" id="activity">
<div class="p-4">
<div class="ios-section-title mb-3"><i class="bi-activity me-1"></i>Активность</div>
<section class="feed-card mb-3">
<div class="feed-hd">
<i class="bi-activity me-1"></i>
<div class="feed-meta">
<div class="feed-author">Активность</div>
</div>
</div>
<div>
{% if posts %}
<div class="activity-feed">
{% include "feed/_feed_cards.html" with items=posts only %}
</div>
{% include "feed/_feed_cards.html" with items=posts %}
{% else %}
<div class="text-secondary small">Публикаций пока нет.</div>
{% endif %}
</div>
</article>
</section>
</div>
</div>
</div>

View File

@ -109,8 +109,8 @@
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
<div class="feed-header d-flex align-items-center">
<i class="bi-people fs-2 text-primary"></i>

View File

@ -1,13 +1,13 @@
{% for post in items %}
{% firstof post.id post.pk as post_id %}
<article class="feed-card" data-post-card id="post-{{ post_id }}">
<article data-post-card id="post-{{ post_id }}">
<header class="feed-hd">
{# --- Аватар автора --- #}
<div class="feed-ava d-flex align-items-center justify-content-center">
{% with a=post.author %}
{% if a %}
{% firstof a.avatar.url a.avatar_url as ava_url %}
{% firstof a.avatar as ava_url %}
{% if ava_url %}
<img src="{{ ava_url }}" alt="">
{% else %}
@ -20,6 +20,7 @@
</div>
{# --- Метаданные (имя автора, дата, отдел) --- #}
<div class="feed-meta">
<div class="feed-author">
{% with a=post.author %}
@ -46,7 +47,7 @@
{% endif %}
{% endwith %}
</div>
<div class="feed-sub">
{% if post.created_at %}
{{ post.created_at_display }}
@ -63,8 +64,16 @@
<span class="feed-pin"><i class="bi-pin-angle"></i></span>
{% endif %}
</header>
<div class="feed-body position-relative">
{% if post.image %}
{% firstof post.image.url post.image as img_url %}
{% if img_url %}
<div class="feed-img">
{% if post_id %}<a href="{% url 'feed:post_detail' post_id %}" class="stretched-link"></a>{% endif %}
<img src="{{ img_url }}" alt="{{ post.title|default:'Публикация' }}">
</div>
{% endif %}
{% endif %}
<div class="feed-body">
<h3 class="feed-title mb-1">
{% if post_id %}
<a href="{% url 'feed:post_detail' post_id %}" class="text-decoration-none stretched-link">
@ -79,6 +88,16 @@
{% if body|wordcount > 42 %}
<div class="feed-text mb-1" id="excerpt-{{ post_id }}">
{{ body|truncatewords:42 }}
<button class="btn p-0 mt-1 small text-secondary text-decoration-none bg-transparent border-0 readmore-toggle"
id="more-{{ post_id }}"
type="button"
data-bs-toggle="collapse"
data-bs-target="#full-{{ post_id }}"
aria-controls="full-{{ post_id }}"
aria-expanded="false">
<span class="txt-more">Подробнее</span>
</button>
</div>
<div id="full-{{ post_id }}" class="collapse"
@ -86,19 +105,19 @@
data-btn="#more-{{ post_id }}">
<div class="feed-full mt-1">
{{ body|linebreaksbr }}
</div>
</div>
<button class="btn p-0 mt-1 small text-secondary text-decoration-none bg-transparent readmore-toggle"
id="more-{{ post_id }}"
type="button"
data-bs-toggle="collapse"
data-bs-target="#full-{{ post_id }}"
aria-controls="full-{{ post_id }}"
aria-expanded="false">
<span class="txt-more">Скрыть</span>
<button class="btn btn-link px-0 mt-1 small readmore-toggle"
id="more-{{ post_id }}"
type="button"
data-bs-toggle="collapse"
data-bs-target="#full-{{ post_id }}"
aria-controls="full-{{ post_id }}"
aria-expanded="false">
<span class="txt-more">Подробнее</span>
<span class="txt-less d-none">Скрыть</span>
</button>
</button>
</div>
</div>
{% else %}
<div class="feed-full">
{{ body|linebreaksbr }}
@ -106,23 +125,11 @@
{% endif %}
{% endwith %}
</div>
{% if post.image %}
{% firstof post.image.url post.image as img_url %}
{% if img_url %}
<div class="feed-img position-relative">
{% if post_id %}<a href="{% url 'feed:post_detail' post_id %}" class="stretched-link"></a>{% endif %}
<img src="{{ img_url }}" alt="{{ post.title|default:'Публикация' }}">
</div>
{% endif %}
{% endif %}
<footer class="feed-actions">
<footer class="feed-actions border-0">
{# Кнопка/ссылка комментариев #}
{% if post_id %}
{% if user.is_authenticated %}
<button class="btn btn-ghost d-inline-flex align-items-center gap-1"
<button class="btn btn-ghost d-inline-flex align-items-center gap-1 "
type="button"
data-bs-toggle="collapse"
data-bs-target="#ccoll-{{ post_id }}"
@ -143,7 +150,7 @@
{% if post.attachment %}
{% firstof post.attachment.url post.attachment as att_url %}
{% if att_url %}
<a href="{{ att_url }}" class="btn btn-ghost d-inline-flex align-items-center gap-1">
<a href="{{ att_url }}" class="btn btn-ghost d-inline-flex align-items-center gap-1 ">
<i class="bi-paperclip"></i><span>Вложение</span>
</a>
{% endif %}
@ -156,7 +163,7 @@
<form method="post" action="{% url 'feed:toggle_like' post_id %}" class="ms-2">
{% csrf_token %}
<button type="submit"
class="btn btn-ghost d-inline-flex align-items-center gap-1 {% if post.is_liked %}is-liked{% endif %}"
class="btn btn-ghost d-inline-flex align-items-center gap-1{% if post.is_liked %}is-liked{% endif %}"
data-like-post="{{ post_id }}"
data-like-url="{% url 'feed:toggle_like' post_id %}"
aria-pressed="{% if post.is_liked %}true{% else %}false{% endif %}">
@ -186,7 +193,7 @@
{# last_comment_list — есть только в старом ORM-пути; при API просто не покажется #}
{% with last=post.last_comment_list|first %}
{% if last %}
<div class="bg-body-tertiary border rounded p-2">
<div>
<div class="small mb-1">
{% firstof last.author.id last.author.pk as last_author_id %}
<a href="{% url 'employees:employee_detail' last_author_id %}"

View File

@ -110,8 +110,8 @@
</style>
<div class="container">
<div class="row justify-content-center mb-4">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
<div class="feed-header d-flex align-items-center gap-3">
<i class="bi-megaphone fs-2 text-primary"></i>
@ -179,15 +179,19 @@
</div>
{% endif %}
<div class="feed-stream">
{% if page %}
{% include "feed/_feed_cards.html" with items=page.object_list %}
{% elif posts %}
{% include "feed/_feed_cards.html" with items=posts %}
{% else %}
<div class="alert alert-info shadow-sm mt-2">Пока нет новостей.</div>
{% endif %}
</div>
<section class="feed-card mb-3">
<div class="feed-hd">
</div>
<div>
{% if page %}
{% include "feed/_feed_cards.html" with items=page.object_list %}
{% elif posts %}
{% include "feed/_feed_cards.html" with items=posts %}
{% else %}
<div class="alert alert-info shadow-sm mt-2">Пока нет новостей.</div>
{% endif %}
</div>
</section>
{% if page %}
<div class="mt-4">

View File

@ -9,8 +9,8 @@
{% firstof post.department.id post.department.pk post.department_id as dept_id %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
<!-- Навигация и действия -->
<div class="d-flex align-items-center gap-2 mb-3">

View File

@ -36,7 +36,6 @@
padding: .75rem;
}
/* Правый столбец (десктоп): контейнер без скролла, скролл внутри .card-body */
@media (min-width: 992px){
.rightbar{
position: sticky; top: var(--navbar-h);
@ -59,22 +58,13 @@
}
.fc-theme-standard .fc-scrollgrid{ border: 0 !important; }
.fc-theme-standard td, .fc-theme-standard th{ border: 0 !important; }
.fc .fc-toolbar{
padding: .7rem .9rem;
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
justify-content: space-between;
}
.fc .fc-toolbar{ padding: .7rem .9rem; background: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); justify-content: space-between; }
.fc .fc-toolbar-title{ font-size: 1rem; font-weight: 600; color: var(--bs-body-color); }
.fc .fc-button{
border-radius: 8px; font-size: .82rem; padding: .25rem .5rem;
background: transparent; border: none; color: var(--bs-primary);
}
.fc .fc-button{ border-radius: 8px; font-size: .82rem; padding: .25rem .5rem; background: transparent; border: none; color: var(--bs-primary); }
.fc .fc-button:hover{ background: rgba(var(--bs-primary-rgb), .1); }
.fc .fc-daygrid-day-frame{ padding: 2px 3px !important; }
.fc .fc-daygrid-day-number{
font-size: .82rem; width: 100%; height: 1.6rem;
display: flex; align-items: center; justify-content: center;
font-size: .82rem; width: 100%; height: 1.6rem; display: flex; align-items: center; justify-content: center;
font-weight: 600; color: var(--bs-body-color); text-decoration: none !important;
}
.fc .fc-day-today{ background: none !important; }
@ -101,66 +91,59 @@
}
/* ====== Вертикальная лента недели ====== */
.week-strip-header{
margin-top: .6rem; display: flex; align-items: center; justify-content: space-between;
color: var(--bs-secondary-color); font-size: .8rem;
}
.week-strip-header{ margin-top: .6rem; display: flex; align-items: center; justify-content: space-between; color: var(--bs-secondary-color); font-size: .8rem; }
.week-vertical{ margin-top: .6rem; display: flex; flex-direction: column; gap: .4rem; }
.week-vertical .week-row{
display: grid; grid-template-columns: 56px 1fr; align-items: start; gap: .6rem;
padding: .5rem .6rem; border-radius: 12px;
background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 86%);
border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 70%);
}
.week-vertical .week-row{ display: grid; grid-template-columns: 56px 1fr; align-items: start; gap: .6rem; padding: .5rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 86%); border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 70%); }
.week-vertical .week-row:hover{ background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 82%); }
.date-badge{
display: grid; place-items: center; border-radius: 10px; padding: .35rem .25rem;
background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 92%);
border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 75%);
text-align: center; line-height: 1.05;
}
.date-badge{ display: grid; place-items: center; border-radius: 10px; padding: .35rem .25rem; background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 92%); border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 75%); text-align: center; line-height: 1.05; }
.date-badge .dow{ font-size: .7rem; font-weight: 700; color: var(--bs-primary); text-transform: uppercase; }
.date-badge .md { font-size: .74rem; font-weight: 700; color: var(--bs-body-color); }
.week-vertical .content .title{
font-size: .8rem; font-weight: 600; color: var(--bs-body-color);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; text-overflow: ellipsis; white-space: normal;
}
.week-vertical .content .title{ font-size: .8rem; font-weight: 600; color: var(--bs-body-color); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; white-space: normal; }
.week-vertical .content .meta{ margin-top: .15rem; font-size: .72rem; color: var(--bs-secondary-color); }
/* ====== Offcanvas (мобайл) — ПРИНУДИТЕЛЬНАЯ ВЫСОТА И СКРОЛЛ ====== */
#rightbarOffcanvas{
/* фиксируем высоту панели под реальную высоту экрана */
height: 100dvh; /* современные браузеры */
height: 100svh; /* iOS 16+ (переопределит) */
}
/* Offcanvas (мобайл) */
#rightbarOffcanvas{ height: 100dvh; height: 100svh; }
#rightbarOffcanvas .offcanvas-body{
display: flex; flex-direction: column; gap: .6rem;
height: calc(100dvh - var(--rb-offcanvas-head, 56px));
height: calc(100svh - var(--rb-offcanvas-head, 56px)); /* iOS */
min-height: 0;
overflow-y: auto; /* <- главный скролл */
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
height: calc(100svh - var(--rb-offcanvas-head, 56px));
min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
#rightbarOffcanvas .calendar-wrap{ flex: 0 0 auto; }
#rightbarOffcanvas .week-vertical{ flex: 0 0 auto; } /* без вложенных скроллов */
#rightbarOffcanvas .week-vertical{ flex: 0 0 auto; }
.calendar-fab{
position: fixed; right: 1rem; bottom: 1rem; z-index: 1060;
width: 52px; height: 52px; border-radius: 9999px; box-shadow: 0 8px 24px rgba(0,0,0,.18);
}
.calendar-fab{ position: fixed; right: 1rem; bottom: 1rem; z-index: 1060; width: 52px; height: 52px; border-radius: 9999px; box-shadow: 0 8px 24px rgba(0,0,0,.18); }
</style>
{# десктопная правая колонка #}
<div class="rightbar d-none d-lg-block">
{# передай сюда один id или список через запятую: "12" или "12,34" #}
<div class="rightbar d-none d-lg-block px-0">
<div class="card rightbar-card" data-dept-id="{{ dept_ids|default:'' }}">
<div class="card-header d-flex align-items-center justify-content-between">
<h6 class="card-title fw-semibold mb-0">Календарь</h6>
<span class="card-sub text-muted">события</span>
<div class="d-flex align-items-center gap-2">
<h6 class="card-title fw-semibold mb-0">Календарь</h6>
{# Выпадающий выбор: Компания / Отделы #}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
id="calendarChooserBtn" data-bs-toggle="dropdown" aria-expanded="false">
Компания
</button>
<ul class="dropdown-menu" aria-labelledby="calendarChooserBtn" id="calendarChooserMenu">
<li><button class="dropdown-item" type="button" data-cal="company">Компания</button></li>
<li><hr class="dropdown-divider"></li>
{# пункты отделов добавим из JS по data-dept-id #}
</ul>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="card-sub text-muted">События</span>
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#eventCreateModal"
aria-label="Создать событие"><i class="bi-plus-lg"></i></button>
</div>
</div>
<div class="card-body">
<div id="calendarRight" class="calendar-wrap"></div>
@ -180,12 +163,8 @@
<i class="bi-calendar-event"></i>
</button>
<div class="offcanvas offcanvas-end d-lg-none"
id="rightbarOffcanvas"
tabindex="-1"
data-bs-scroll="true"
aria-labelledby="rightbarOffcanvasLabel"
style="--bs-offcanvas-width: min(92vw, 420px);">
<div class="offcanvas offcanvas-end d-lg-none" id="rightbarOffcanvas" tabindex="-1"
data-bs-scroll="true" style="--bs-offcanvas-width: min(92vw, 420px);">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="rightbarOffcanvasLabel">Календарь</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
@ -201,24 +180,84 @@
</div>
</div>
{# Модал создания события #}
<div class="modal fade" id="eventCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="eventForm">
<div class="modal-header">
<h5 class="modal-title">Новое событие</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">Заголовок</label>
<input type="text" class="form-control" name="title" required>
</div>
<div class="row g-2 align-items-end">
<div class="col-6">
<label class="form-label">Начало</label>
<input type="datetime-local" class="form-control" name="start">
</div>
<div class="col-6">
<label class="form-label">Окончание</label>
<input type="datetime-local" class="form-control" name="end">
</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" value="1" id="allDayChk" name="all_day">
<label class="form-check-label" for="allDayChk">Весь день</label>
</div>
<small class="text-secondary d-block mt-2">
Календарь: <span id="eventTargetLabel">Компания</span>
</small>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
if (!window.FullCalendar) return;
/* ===== endpoints ===== */
const API_COMPANY = '/api/v1/calendar/company-events/';
const holder = document.querySelector('.rightbar-card');
const deptIds = (holder?.dataset.deptId || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
/* ===== токен из meta ===== */
function getAccessToken(){
const meta = document.querySelector('meta[name="api-access"]');
const m = meta && meta.getAttribute('content') || '';
if (m) return m.trim();
try{ return localStorage.getItem('api.access') || ''; }catch(_){ return ''; }
}
function authHeaders(){
const t = getAccessToken();
return t ? { 'Authorization': 'Bearer ' + t } : {};
}
const deptUrls = deptIds.map(id => `/api/v1/calendar/departments/${encodeURIComponent(id)}/events/`);
/* ===== endpoints ===== */
const API_COMPANY = '/api/v1/calendar/company-events/';
const API_MY_DEPTS = '/api/v1/departments/my-departments/'; // <-- новый эндпоинт
const holder = document.querySelector('.rightbar-card');
// оставим обратную совместимость: если в data-атрибуте переданы id отделов
const legacyDeptIds = (holder?.dataset.deptId || '').split(',').map(s=>s.trim()).filter(Boolean);
const deptUrl = (id) => `/api/v1/calendar/departments/${encodeURIComponent(id)}/events/`;
/* ===== элементы UI ===== */
const chooserBtn = document.getElementById('calendarChooserBtn');
const chooserMenu = document.getElementById('calendarChooserMenu');
const eventTargetLabel = document.getElementById('eventTargetLabel');
/* ===== состояние ===== */
let departments = []; // [{id, name}]
const state = { type: 'company', deptId: null };
/* ===== utils ===== */
const INLINE_SIZE_PX = '10px';
const dayMs = 86400000, hourMs = 3600000;
const isDateOnly = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
const toDate = v => {
if (!v) return null;
@ -235,6 +274,101 @@
const endOfWeek = d => { const s=startOfWeek(d); const e=new Date(s); e.setDate(s.getDate()+7); return e; };
const overlaps = (ev, ws, we) => ev.start < we && ev.end > ws;
async function fetchJSON(url){
const r = await fetch(url, { headers: { 'Accept':'application/json', ...authHeaders() } });
if (r.status === 401) { console.warn('401 от API — нужен валидный access токен'); return []; }
if (!r.ok) { console.error('HTTP', r.status, url); return []; }
const data = await r.json();
return Array.isArray(data) ? data : (data.results || data.items || data.events || []);
}
function qsRange(start, end){
const s = start.toISOString().slice(0,10);
const e = end.toISOString().slice(0,10);
return `?start=${s}&end=${e}`;
}
/* ===== загрузка отделов через /my-departments ===== */
async function loadDepartments(){
// подчистим старые пункты отделов (если были)
chooserMenu.querySelectorAll('[data-cal="dept"]').forEach(n => n.closest('li')?.remove());
let raw = await fetchJSON(API_MY_DEPTS).catch(()=>[]);
if (!Array.isArray(raw) || !raw.length){
// fallback: построим из legacy id
departments = legacyDeptIds.map(id => ({ id, name: `Отдел #${id}` }));
} else {
departments = raw.map(d => ({
id: d.id ?? d.pk ?? d.uuid ?? d.slug,
name: d.name ?? d.title ?? d.code ?? `Отдел #${d.id ?? d.pk ?? ''}`
})).filter(d => d.id != null);
}
if (!departments.length){
// если отделов нет — оставляем только "Компания"
setChooserLabel();
return;
}
const divider = chooserMenu.querySelector('.dropdown-divider') || (() => {
const li = document.createElement('li'); li.innerHTML = '<hr class="dropdown-divider">';
chooserMenu.appendChild(li); return li.querySelector('hr');
})();
const frag = document.createDocumentFragment();
departments.forEach(d => {
const li = document.createElement('li');
li.innerHTML = `<button class="dropdown-item" type="button" data-cal="dept" data-id="${d.id}">${d.name}</button>`;
frag.appendChild(li);
});
chooserMenu.appendChild(frag);
// если уже выбран отдел, но его нет в списке — вернёмся к "Компания"
if (state.type === 'dept' && !departments.some(d => String(d.id) === String(state.deptId))){
state.type = 'company'; state.deptId = null;
}
// если ничего не выбрано и хочется автоподставить первый отдел — раскомментируй:
// if (state.type === 'company' && departments.length) { state.type='dept'; state.deptId=departments[0].id; }
setChooserLabel();
}
function currentDeptLabel(){
if (state.type !== 'dept') return 'Компания';
const d = departments.find(x => String(x.id) === String(state.deptId));
return d?.name || `Отдел #${state.deptId}`;
}
function setChooserLabel(){
const label = state.type === 'company' ? 'Компания' : currentDeptLabel();
chooserBtn.textContent = label;
if (eventTargetLabel) eventTargetLabel.textContent = label;
}
/* ===== обработчик выбора из дропдауна ===== */
chooserMenu.addEventListener('click', (e)=>{
const btn = e.target.closest('[data-cal]'); if (!btn) return;
const type = btn.dataset.cal;
if (type === 'company'){
state.type='company'; state.deptId=null;
} else {
state.type='dept'; state.deptId=btn.dataset.id;
}
setChooserLabel();
[deskCalendar, mobCalendar].forEach(cal => cal?.refetchEvents());
updateWeekLists(); // источники учтём внутри
});
/* ===== комбинированная загрузка событий ===== */
async function fetchEventsCombined(start, end){
const q = qsRange(start, end);
let urls = [];
if (state.type === 'company') urls = [API_COMPANY + q];
else if (state.type === 'dept' && state.deptId) urls = [deptUrl(state.deptId) + q];
if (!urls.length) return [];
const parts = await Promise.all(urls.map(u => fetchJSON(u).catch(()=>[])));
return parts.flat();
}
function normalizeEvent(ev){
const title = pick(ev, ['title','name','summary','text']) || '';
const rs = pick(ev, ['start','start_date','date_start','date','date_from']);
@ -246,29 +380,6 @@
return { title, start:s, end:e, allDay, color: ev.color || null, recurrence: ev.recurrence || null };
}
async function fetchJSON(url){
const r = await fetch(url, { credentials: 'same-origin' });
if (!r.ok) return [];
const data = await r.json();
return Array.isArray(data) ? data : (data.results || data.items || data.events || []);
}
function qsRange(start, end){
// API понимает и start/end, и from/to — отдаём короткие даты
const s = start.toISOString().slice(0,10);
const e = end.toISOString().slice(0,10);
return `?start=${s}&end=${e}`;
}
async function fetchEventsCombined(start, end){
const q = qsRange(start, end);
const urls = [API_COMPANY + q, ...deptUrls.map(u=> u + q)];
if (urls.length === 0) return [];
const parts = await Promise.all(urls.map(u => fetchJSON(u).catch(() => [])));
// плоский массив
return parts.flat();
}
function renderVertical(container, rangeLabel, events, ws, we){
if (!container) return;
container.innerHTML = '';
@ -280,8 +391,7 @@
const dfMD = new Intl.DateTimeFormat('ru-RU', { day:'2-digit', month:'2-digit' });
const list = events
.map(normalizeEvent)
.filter(Boolean)
.map(normalizeEvent).filter(Boolean)
.filter(ev => overlaps(ev, ws, we))
.sort((a,b)=> a.start - b.start);
@ -293,11 +403,12 @@
list.forEach(ev=>{
const row = document.createElement('div'); row.className = 'week-row';
const badge = document.createElement('div'); badge.className = 'date-badge';
badge.innerHTML = `<div class="dow">${dfDow.format(ev.start)}</div><div class="md">${dfMD.format(ev.start)}</div>`;
const dow = dfDow.format(ev.start), md = dfMD.format(ev.start);
badge.innerHTML = `<div class="dow">${dow}</div><div class="md">${md}</div>`;
const cont = document.createElement('div'); cont.className = 'content';
const title = document.createElement('div'); title.className = 'title'; title.textContent = ev.title || 'Без названия'; title.title = ev.title || '';
const meta = document.createElement('div'); meta.className = 'meta';
const span = ev.end - ev.start > dayMs ? `${dfMD.format(ev.start)} — ${dfMD.format(new Date(ev.end - 1))}` : `${dfMD.format(ev.start)}`;
const span = ev.end - ev.start > dayMs ? `${dfMD.format(ev.start)} — ${dfMD.format(new Date(ev.end - 1))}` : `${md}`;
meta.textContent = span;
cont.append(title, meta);
row.append(badge, cont);
@ -307,9 +418,9 @@
async function updateWeekLists(sourceCal){
const now = new Date(); const ws = startOfWeek(now), we = endOfWeek(now);
// если календарь уже загрузил события — используем их, чтобы не делать лишний запрос
let items = sourceCal ? (sourceCal.getEvents?.() || []).map(e => ({
title: e.title, start: e.start, end: e.end, allDay: e.allDay, color: e.extendedProps?.color, recurrence: e.extendedProps?.recurrence
title: e.title, start: e.start, end: e.end, allDay: e.allDay,
color: e.extendedProps?.color, recurrence: e.extendedProps?.recurrence
})) : [];
if (!items.length){
items = await fetchEventsCombined(new Date(ws.getTime()-3*dayMs), new Date(we.getTime()+3*dayMs));
@ -324,12 +435,8 @@
const h = oc?.querySelector('.offcanvas-header')?.offsetHeight || 56;
if (oc) oc.style.setProperty('--rb-offcanvas-head', h + 'px');
};
setHeadH();
window.addEventListener('resize', setHeadH);
oc?.addEventListener('shown.bs.offcanvas', () => {
setHeadH();
mobCalendar?.updateSize();
});
setHeadH(); window.addEventListener('resize', setHeadH);
oc?.addEventListener('shown.bs.offcanvas', () => { setHeadH(); mobCalendar?.updateSize(); });
/* ===== FullCalendar init ===== */
const fcOpts = {
@ -338,7 +445,6 @@
headerToolbar: { left: 'prev,next today', center: 'title', right: '' },
height: 'auto',
displayEventTime: false,
// Ключевая часть: подсовываем объединённые события из новых эндпоинтов
events: async (info, success, failure) => {
try{
const data = await fetchEventsCombined(info.start, info.end);
@ -354,30 +460,21 @@
return { domNodes: [el] };
},
eventDidMount: info => {
// цвет из API (если задан)
if (info.event.extendedProps?.color){
info.el.style.backgroundColor = info.event.extendedProps.color;
info.el.style.borderColor = info.event.extendedProps.color;
info.el.style.color = 'var(--bs-body-bg)';
}
// отметим ежегодные (если надо)
if (info.event.extendedProps?.recurrence === 'annual'){
info.el.style.outline = '1px dashed currentColor';
info.el.style.outlineOffset = '-2px';
info.el.style.outline = '1px dashed currentColor'; info.el.style.outlineOffset = '-2px';
}
const t = info.el.querySelector('.fc-event-main, .fc-event-title');
if (t){
t.style.fontSize = INLINE_SIZE_PX;
t.style.lineHeight = '1.02';
t.style.letterSpacing = '-0.2px';
t.style.display = '-webkit-box';
t.style.webkitLineClamp = '3';
t.style.webkitBoxOrient = 'vertical';
t.style.overflow = 'hidden';
t.style.whiteSpace = 'normal';
t.style.textOverflow = 'ellipsis';
t.style.fontWeight = '600';
t.style.fontFamily = '"Arial Narrow","Roboto Condensed",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif';
const s = t.style;
s.fontSize='10px'; s.lineHeight='1.02'; s.letterSpacing='-0.2px'; s.display='-webkit-box';
s.webkitLineClamp='3'; s.webkitBoxOrient='vertical'; s.overflow='hidden'; s.whiteSpace='normal';
s.textOverflow='ellipsis'; s.fontWeight='600';
s.fontFamily='"Arial Narrow","Roboto Condensed",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif';
}
}
};
@ -388,7 +485,65 @@
const mob = document.getElementById('calendarRightMobile');
if (mob){ mobCalendar = new FullCalendar.Calendar(mob, fcOpts); mobCalendar.on('loading', (l)=>{ if(!l) updateWeekLists(mobCalendar); }); mobCalendar.render(); }
// загрузим отделы и обновим подпись селектора
loadDepartments().then(()=> setChooserLabel());
setTimeout(()=> updateWeekLists(deskCalendar || mobCalendar), 100);
[deskCalendar, mobCalendar].forEach(cal => cal?.on('datesSet', ()=> updateWeekLists(cal)));
/* ===== создание события (POST только с Bearer) ===== */
const form = document.getElementById('eventForm');
form?.addEventListener('submit', async (e)=>{
e.preventDefault();
const fd = new FormData(form);
const title = (fd.get('title')||'').toString().trim();
const allDay = !!fd.get('all_day');
let start = fd.get('start')?.toString() || '';
let end = fd.get('end')?.toString() || '';
if (!title){
alert('Укажите заголовок'); return;
}
let url = '';
let payload = {};
if (state.type === 'company'){
url = API_COMPANY;
const date = (start || new Date().toISOString().slice(0,16)).slice(0,10);
payload = { title, date };
}else if (state.type === 'dept' && state.deptId){
url = deptUrl(state.deptId);
if (!start) start = new Date().toISOString().slice(0,16);
if (!end){
const dt = new Date(start); dt.setHours(dt.getHours()+1);
end = dt.toISOString().slice(0,16);
}
payload = allDay
? { title, start: start.slice(0,10), end: end.slice(0,10), allDay: true }
: { title, start: new Date(start).toISOString(), end: new Date(end).toISOString(), allDay: false };
}else{
alert('Выберите календарь'); return;
}
try{
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept':'application/json', ...authHeaders() },
body: JSON.stringify(payload)
});
if (r.status === 401){ alert('Сессия API истекла. Перезайдите.'); return; }
if (!r.ok){
const t = await r.text();
console.error('Create event failed', r.status, t);
alert('Не удалось создать событие'); return;
}
document.querySelector('#eventCreateModal .btn-close')?.click();
form.reset();
[deskCalendar, mobCalendar].forEach(cal => cal?.refetchEvents());
updateWeekLists();
}catch(err){
console.error(err); alert('Ошибка сети');
}
});
});
</script>
</script>

View File

@ -107,7 +107,7 @@
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body d-flex flex-column h-100 px-3 px-lg-2">
<div class="offcanvas-body d-flex flex-column h-100 px-0">
<nav class="side-nav">
<a href="{% url 'feed:feed_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'feed_list' %}active{% endif %}"

View File

@ -5,8 +5,8 @@
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
<!-- Заголовок -->
<div class="feed-header">

View File

@ -5,8 +5,8 @@
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
{# — Шапка страницы — #}
<div class="feed-header">

View File

@ -5,8 +5,8 @@
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
{# — Шапка — #}
<div class="feed-header">

View File

@ -6,8 +6,8 @@
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
<div class="row gx-0">
<div class="col-12">
{# — Шапка: заголовок + кнопка назад — #}
<div class="feed-header">