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:
parent
142054ccc8
commit
c5123eb02f
BIN
backend/api.zip
Normal file
BIN
backend/api.zip
Normal file
Binary file not shown.
0
backend/api/templatetags/__init__.py
Normal file
0
backend/api/templatetags/__init__.py
Normal file
16
backend/api/templatetags/api_tags.py
Normal file
16
backend/api/templatetags/api_tags.py
Normal 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 ""
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -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")
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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",
|
||||
|
||||
82
backend/api/v1/serializers.py
Normal file
82
backend/api/v1/serializers.py
Normal 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}"
|
||||
@ -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)),
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user