Add event type handling for duties in the system
- Introduced a new `event_type` column in the `duties` table to categorize duties as 'duty', 'unavailable', or 'vacation'. - Updated the duty schedule import functionality to parse and store event types from the JSON input. - Enhanced the API response to include event types for each duty, improving the calendar display logic. - Modified the web application to visually differentiate between duty types in the calendar and duty list. - Updated tests to cover new event type functionality and ensure correct parsing and storage of duties. - Revised README documentation to reflect changes in duty event types and their representation in the system.
This commit is contained in:
@@ -97,7 +97,7 @@ To add commands, define async handlers in `handlers/commands.py` (or a new modul
|
||||
- **meta**: обязательное поле `start_date` (YYYY-MM-DD), опционально `weeks`; количество дней определяется по длине строки `duty`.
|
||||
- **schedule**: массив объектов с полями:
|
||||
- `name` — ФИО (строка);
|
||||
- `duty` — строка с разделителем `;`: каждый элемент соответствует дню с `start_date` по порядку. Пусто или пробелы — нет дежурства; символы **б**, **Б**, **в**, **Н**, **О** — день дежурства.
|
||||
- `duty` — строка с разделителем `;`: каждый элемент соответствует дню с `start_date` по порядку. Пусто или пробелы — нет события; **в**, **В**, **б**, **Б** — дежурство; **Н** — недоступен; **О** — отпуск.
|
||||
|
||||
При повторном импорте дежурства в том же диапазоне дат для каждого пользователя заменяются новыми.
|
||||
|
||||
|
||||
28
alembic/versions/003_duties_event_type.py
Normal file
28
alembic/versions/003_duties_event_type.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Duties: add event_type (duty | unavailable | vacation)
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2025-02-17
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "003"
|
||||
down_revision: Union[str, None] = "002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"duties",
|
||||
sa.Column("event_type", sa.Text(), nullable=False, server_default="duty"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("duties", "event_type")
|
||||
@@ -46,6 +46,7 @@ def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]:
|
||||
start_at=duty.start_at,
|
||||
end_at=duty.end_at,
|
||||
full_name=full_name,
|
||||
event_type=duty.event_type,
|
||||
)
|
||||
for duty, full_name in rows
|
||||
]
|
||||
|
||||
@@ -35,5 +35,7 @@ class Duty(Base):
|
||||
# UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)
|
||||
start_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
end_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# duty | unavailable | vacation
|
||||
event_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="duty")
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="duties")
|
||||
|
||||
@@ -102,9 +102,16 @@ def insert_duty(
|
||||
user_id: int,
|
||||
start_at: str,
|
||||
end_at: str,
|
||||
event_type: str = "duty",
|
||||
) -> Duty:
|
||||
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z)."""
|
||||
duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at)
|
||||
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z.
|
||||
event_type: 'duty' | 'unavailable' | 'vacation'."""
|
||||
duty = Duty(
|
||||
user_id=user_id,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
event_type=event_type,
|
||||
)
|
||||
session.add(duty)
|
||||
session.commit()
|
||||
session.refresh(duty)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Pydantic schemas for API and validation."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
@@ -38,9 +40,10 @@ class DutyInDb(DutyBase):
|
||||
|
||||
|
||||
class DutyWithUser(DutyInDb):
|
||||
"""Duty with full_name for calendar display."""
|
||||
"""Duty with full_name and event_type for calendar display."""
|
||||
|
||||
full_name: str
|
||||
event_type: Literal["duty", "unavailable", "vacation"] = "duty"
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -101,22 +101,37 @@ def _run_import(
|
||||
result: DutyScheduleResult,
|
||||
hour_utc: int,
|
||||
minute_utc: int,
|
||||
) -> tuple[int, int]:
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Returns (num_users, num_duty, num_unavailable, num_vacation)."""
|
||||
session = get_session(database_url)
|
||||
try:
|
||||
from_date_str = result.start_date.isoformat()
|
||||
to_date_str = result.end_date.isoformat()
|
||||
total_duties = 0
|
||||
for full_name, duty_dates in result.entries:
|
||||
user = get_or_create_user_by_full_name(session, full_name)
|
||||
num_duty = num_unavailable = num_vacation = 0
|
||||
for entry in result.entries:
|
||||
user = get_or_create_user_by_full_name(session, entry.full_name)
|
||||
delete_duties_in_range(session, user.id, from_date_str, to_date_str)
|
||||
for d in duty_dates:
|
||||
for d in entry.duty_dates:
|
||||
start_at = _duty_to_iso(d, hour_utc, minute_utc)
|
||||
d_next = d + timedelta(days=1)
|
||||
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
|
||||
insert_duty(session, user.id, start_at, end_at)
|
||||
total_duties += 1
|
||||
return (len(result.entries), total_duties)
|
||||
insert_duty(session, user.id, start_at, end_at, event_type="duty")
|
||||
num_duty += 1
|
||||
for d in entry.unavailable_dates:
|
||||
start_at = _duty_to_iso(d, hour_utc, minute_utc)
|
||||
d_next = d + timedelta(days=1)
|
||||
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
|
||||
insert_duty(
|
||||
session, user.id, start_at, end_at, event_type="unavailable"
|
||||
)
|
||||
num_unavailable += 1
|
||||
for d in entry.vacation_dates:
|
||||
start_at = _duty_to_iso(d, hour_utc, minute_utc)
|
||||
d_next = d + timedelta(days=1)
|
||||
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
|
||||
insert_duty(session, user.id, start_at, end_at, event_type="vacation")
|
||||
num_vacation += 1
|
||||
return (len(result.entries), num_duty, num_unavailable, num_vacation)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -149,15 +164,21 @@ async def handle_duty_schedule_document(update: Update, context: ContextTypes.DE
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
num_users, num_duties = await loop.run_in_executor(
|
||||
num_users, num_duty, num_unavailable, num_vacation = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _run_import(config.DATABASE_URL, result, hour_utc, minute_utc),
|
||||
)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Ошибка импорта: {e}")
|
||||
else:
|
||||
total = num_duty + num_unavailable + num_vacation
|
||||
parts = [f"{num_users} пользователей", f"{num_duty} дежурств"]
|
||||
if num_unavailable:
|
||||
parts.append(f"{num_unavailable} недоступностей")
|
||||
if num_vacation:
|
||||
parts.append(f"{num_vacation} отпусков")
|
||||
await update.message.reply_text(
|
||||
f"Импорт выполнен: {num_users} пользователей, {num_duties} дежурств."
|
||||
"Импорт выполнен: " + ", ".join(parts) + f" (всего {total} событий)."
|
||||
)
|
||||
finally:
|
||||
context.user_data.pop("awaiting_duty_schedule_file", None)
|
||||
|
||||
@@ -4,17 +4,29 @@ import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Символы, обозначающие день дежурства в ячейке duty (CSV с разделителем ;)
|
||||
DUTY_MARKERS = frozenset({"б", "Б", "в", "Н", "О"})
|
||||
# Символы дежурства в ячейке duty (CSV с разделителем ;)
|
||||
DUTY_MARKERS = frozenset({"б", "Б", "в", "В"})
|
||||
UNAVAILABLE_MARKER = "Н"
|
||||
VACATION_MARKER = "О"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DutyScheduleEntry:
|
||||
"""One person's schedule: full_name and three lists of dates by event type."""
|
||||
|
||||
full_name: str
|
||||
duty_dates: list[date]
|
||||
unavailable_dates: list[date]
|
||||
vacation_dates: list[date]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DutyScheduleResult:
|
||||
"""Parsed duty schedule: start_date, end_date, and per-person duty dates."""
|
||||
"""Parsed duty schedule: start_date, end_date, and per-person entries."""
|
||||
|
||||
start_date: date
|
||||
end_date: date
|
||||
entries: list[tuple[str, list[date]]] # (full_name, list of duty dates)
|
||||
entries: list[DutyScheduleEntry]
|
||||
|
||||
|
||||
class DutyScheduleParseError(Exception):
|
||||
@@ -24,12 +36,12 @@ class DutyScheduleParseError(Exception):
|
||||
|
||||
|
||||
def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
"""Parse duty-schedule JSON. Returns start_date, end_date, and list of (full_name, duty_dates).
|
||||
"""Parse duty-schedule JSON. Returns start_date, end_date, and list of DutyScheduleEntry.
|
||||
|
||||
- meta.start_date (YYYY-MM-DD) and schedule (array) required.
|
||||
- meta.weeks optional; number of days from max duty string length (split by ';').
|
||||
- For each schedule item: name (required), duty = CSV with ';'; index i = start_date + i days.
|
||||
- Cell value after strip in DUTY_MARKERS => duty day.
|
||||
- Cell value after strip: в/В/б/Б => duty, Н => unavailable, О => vacation; rest ignored.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(raw_bytes.decode("utf-8"))
|
||||
@@ -53,7 +65,7 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
|
||||
|
||||
max_days = 0
|
||||
entries: list[tuple[str, list[date]]] = []
|
||||
entries: list[DutyScheduleEntry] = []
|
||||
|
||||
for row in schedule:
|
||||
if not isinstance(row, dict):
|
||||
@@ -75,11 +87,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
max_days = max(max_days, len(cells))
|
||||
|
||||
duty_dates: list[date] = []
|
||||
unavailable_dates: list[date] = []
|
||||
vacation_dates: list[date] = []
|
||||
for i, cell in enumerate(cells):
|
||||
d = start_date + timedelta(days=i)
|
||||
if cell in DUTY_MARKERS:
|
||||
d = start_date + timedelta(days=i)
|
||||
duty_dates.append(d)
|
||||
entries.append((full_name, duty_dates))
|
||||
elif cell == UNAVAILABLE_MARKER:
|
||||
unavailable_dates.append(d)
|
||||
elif cell == VACATION_MARKER:
|
||||
vacation_dates.append(d)
|
||||
entries.append(
|
||||
DutyScheduleEntry(
|
||||
full_name=full_name,
|
||||
duty_dates=duty_dates,
|
||||
unavailable_dates=unavailable_dates,
|
||||
vacation_dates=vacation_dates,
|
||||
)
|
||||
)
|
||||
|
||||
if max_days == 0:
|
||||
end_date = start_date
|
||||
|
||||
@@ -6,7 +6,10 @@ import pytest
|
||||
|
||||
from importers.duty_schedule import (
|
||||
DUTY_MARKERS,
|
||||
UNAVAILABLE_MARKER,
|
||||
VACATION_MARKER,
|
||||
DutyScheduleParseError,
|
||||
DutyScheduleEntry,
|
||||
parse_duty_schedule,
|
||||
)
|
||||
|
||||
@@ -24,16 +27,19 @@ def test_parse_valid_schedule():
|
||||
# Petrov has 7 cells -> end = start + 6
|
||||
assert result.end_date == date(2026, 2, 22)
|
||||
assert len(result.entries) == 2
|
||||
names = [e[0] for e in result.entries]
|
||||
assert "Ivanov I.I." in names
|
||||
assert "Petrov P.P." in names
|
||||
by_name = {e[0]: e[1] for e in result.entries}
|
||||
# Ivanov: indices 2, 3, 4 are duty (б, Б, в) -> 2026-02-18, 19, 20
|
||||
ivan_dates = sorted(by_name["Ivanov I.I."])
|
||||
assert ivan_dates == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)]
|
||||
# Petrov: indices 1, 2 (Н, О) -> 2026-02-17, 18
|
||||
petr_dates = sorted(by_name["Petrov P.P."])
|
||||
assert petr_dates == [date(2026, 2, 17), date(2026, 2, 18)]
|
||||
by_name = {e.full_name: e for e in result.entries}
|
||||
assert "Ivanov I.I." in by_name
|
||||
assert "Petrov P.P." in by_name
|
||||
# Ivanov: only duty (б, Б, в) -> 2026-02-18, 19, 20
|
||||
ivan = by_name["Ivanov I.I."]
|
||||
assert sorted(ivan.duty_dates) == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)]
|
||||
assert ivan.unavailable_dates == []
|
||||
assert ivan.vacation_dates == []
|
||||
# Petrov: one Н (unavailable), one О (vacation) -> 2026-02-17, 18
|
||||
petr = by_name["Petrov P.P."]
|
||||
assert petr.duty_dates == []
|
||||
assert petr.unavailable_dates == [date(2026, 2, 17)]
|
||||
assert petr.vacation_dates == [date(2026, 2, 18)]
|
||||
|
||||
|
||||
def test_parse_empty_duty_string():
|
||||
@@ -41,7 +47,11 @@ def test_parse_empty_duty_string():
|
||||
result = parse_duty_schedule(raw)
|
||||
assert result.start_date == date(2026, 2, 1)
|
||||
assert result.end_date == date(2026, 2, 1)
|
||||
assert result.entries == [("A", [])]
|
||||
assert len(result.entries) == 1
|
||||
assert result.entries[0].full_name == "A"
|
||||
assert result.entries[0].duty_dates == []
|
||||
assert result.entries[0].unavailable_dates == []
|
||||
assert result.entries[0].vacation_dates == []
|
||||
|
||||
|
||||
def test_parse_invalid_json():
|
||||
@@ -89,4 +99,19 @@ def test_parse_schedule_item_empty_name():
|
||||
|
||||
|
||||
def test_duty_markers():
|
||||
assert "б" in DUTY_MARKERS and "Б" in DUTY_MARKERS and "в" in DUTY_MARKERS
|
||||
assert DUTY_MARKERS == frozenset({"б", "Б", "в", "В"})
|
||||
assert "Н" not in DUTY_MARKERS and "О" not in DUTY_MARKERS
|
||||
|
||||
|
||||
def test_unavailable_and_vacation_markers():
|
||||
assert UNAVAILABLE_MARKER == "Н"
|
||||
assert VACATION_MARKER == "О"
|
||||
raw = (
|
||||
'{"meta": {"start_date": "2026-02-01"}, "schedule": ['
|
||||
'{"name": "X", "duty": "\u041d; \u041e; \u0432"}]}'
|
||||
).encode("utf-8")
|
||||
result = parse_duty_schedule(raw)
|
||||
entry = result.entries[0]
|
||||
assert entry.unavailable_dates == [date(2026, 2, 1)]
|
||||
assert entry.vacation_dates == [date(2026, 2, 2)]
|
||||
assert entry.duty_dates == [date(2026, 2, 3)]
|
||||
|
||||
@@ -5,10 +5,9 @@ from datetime import date
|
||||
import pytest
|
||||
|
||||
from db import init_db
|
||||
from db.models import Base
|
||||
from db.repository import get_duties
|
||||
from db.session import get_session
|
||||
from importers.duty_schedule import DutyScheduleResult, parse_duty_schedule
|
||||
from importers.duty_schedule import DutyScheduleEntry, DutyScheduleResult, parse_duty_schedule
|
||||
|
||||
from handlers.import_duty_schedule import _run_import
|
||||
|
||||
@@ -36,17 +35,28 @@ def test_import_creates_users_and_duties(db_url):
|
||||
start_date=date(2026, 2, 16),
|
||||
end_date=date(2026, 2, 18),
|
||||
entries=[
|
||||
("Ivanov I.I.", [date(2026, 2, 16), date(2026, 2, 18)]),
|
||||
("Petrov P.P.", [date(2026, 2, 17)]),
|
||||
DutyScheduleEntry(
|
||||
full_name="Ivanov I.I.",
|
||||
duty_dates=[date(2026, 2, 16), date(2026, 2, 18)],
|
||||
unavailable_dates=[],
|
||||
vacation_dates=[],
|
||||
),
|
||||
DutyScheduleEntry(
|
||||
full_name="Petrov P.P.",
|
||||
duty_dates=[date(2026, 2, 17)],
|
||||
unavailable_dates=[],
|
||||
vacation_dates=[],
|
||||
),
|
||||
],
|
||||
)
|
||||
num_users, num_duties = _run_import(db_url, result, 6, 0)
|
||||
num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0)
|
||||
assert num_users == 2
|
||||
assert num_duties == 3
|
||||
assert num_duty == 3
|
||||
assert num_unav == 0
|
||||
assert num_vac == 0
|
||||
|
||||
session = get_session(db_url)
|
||||
try:
|
||||
# to_date inclusive: duty on 18th has start_at 2026-02-18T06:00:00Z, so use 2026-02-19
|
||||
duties = get_duties(session, "2026-02-16", "2026-02-19")
|
||||
finally:
|
||||
session.close()
|
||||
@@ -56,6 +66,8 @@ def test_import_creates_users_and_duties(db_url):
|
||||
assert "2026-02-16T06:00:00Z" in starts
|
||||
assert "2026-02-17T06:00:00Z" in starts
|
||||
assert "2026-02-18T06:00:00Z" in starts
|
||||
for d, _ in duties:
|
||||
assert d.event_type == "duty"
|
||||
|
||||
|
||||
def test_import_replaces_duties_in_range(db_url):
|
||||
@@ -63,7 +75,14 @@ def test_import_replaces_duties_in_range(db_url):
|
||||
result1 = DutyScheduleResult(
|
||||
start_date=date(2026, 2, 16),
|
||||
end_date=date(2026, 2, 17),
|
||||
entries=[("Sidorov", [date(2026, 2, 16), date(2026, 2, 17)])],
|
||||
entries=[
|
||||
DutyScheduleEntry(
|
||||
full_name="Sidorov",
|
||||
duty_dates=[date(2026, 2, 16), date(2026, 2, 17)],
|
||||
unavailable_dates=[],
|
||||
vacation_dates=[],
|
||||
)
|
||||
],
|
||||
)
|
||||
_run_import(db_url, result1, 9, 0)
|
||||
|
||||
@@ -77,7 +96,14 @@ def test_import_replaces_duties_in_range(db_url):
|
||||
result2 = DutyScheduleResult(
|
||||
start_date=date(2026, 2, 16),
|
||||
end_date=date(2026, 2, 17),
|
||||
entries=[("Sidorov", [date(2026, 2, 17)])],
|
||||
entries=[
|
||||
DutyScheduleEntry(
|
||||
full_name="Sidorov",
|
||||
duty_dates=[date(2026, 2, 17)],
|
||||
unavailable_dates=[],
|
||||
vacation_dates=[],
|
||||
)
|
||||
],
|
||||
)
|
||||
_run_import(db_url, result2, 9, 0)
|
||||
|
||||
@@ -97,9 +123,11 @@ def test_import_full_flow_parse_then_import(db_url):
|
||||
'"schedule": [{"name": "Alexey A.", "duty": "\u0431; ; \u0432"}]}'
|
||||
).encode("utf-8")
|
||||
parsed = parse_duty_schedule(raw)
|
||||
num_users, num_duties = _run_import(db_url, parsed, 6, 0)
|
||||
num_users, num_duty, num_unav, num_vac = _run_import(db_url, parsed, 6, 0)
|
||||
assert num_users == 1
|
||||
assert num_duties == 2
|
||||
assert num_duty == 2
|
||||
assert num_unav == 0
|
||||
assert num_vac == 0
|
||||
|
||||
session = get_session(db_url)
|
||||
try:
|
||||
@@ -108,3 +136,32 @@ def test_import_full_flow_parse_then_import(db_url):
|
||||
session.close()
|
||||
assert len(duties) == 2
|
||||
assert duties[0][1] == "Alexey A."
|
||||
assert duties[0][0].event_type == "duty"
|
||||
|
||||
|
||||
def test_import_event_types_unavailable_vacation(db_url):
|
||||
"""Import creates records with event_type duty, unavailable, vacation."""
|
||||
result = DutyScheduleResult(
|
||||
start_date=date(2026, 2, 16),
|
||||
end_date=date(2026, 2, 18),
|
||||
entries=[
|
||||
DutyScheduleEntry(
|
||||
full_name="Mixed User",
|
||||
duty_dates=[date(2026, 2, 16)],
|
||||
unavailable_dates=[date(2026, 2, 17)],
|
||||
vacation_dates=[date(2026, 2, 18)],
|
||||
),
|
||||
],
|
||||
)
|
||||
num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0)
|
||||
assert num_users == 1
|
||||
assert num_duty == 1 and num_unav == 1 and num_vac == 1
|
||||
|
||||
session = get_session(db_url)
|
||||
try:
|
||||
duties = get_duties(session, "2026-02-16", "2026-02-19")
|
||||
finally:
|
||||
session.close()
|
||||
assert len(duties) == 3
|
||||
types = {d[0].event_type for d in duties}
|
||||
assert types == {"duty", "unavailable", "vacation"}
|
||||
|
||||
@@ -198,18 +198,34 @@
|
||||
const key = localDateString(d);
|
||||
const isOther = d.getMonth() !== month;
|
||||
const dayDuties = dutiesByDate[key] || [];
|
||||
const dutyList = dayDuties.filter(function (x) { return x.event_type === "duty"; });
|
||||
const unavailableList = dayDuties.filter(function (x) { return x.event_type === "unavailable"; });
|
||||
const vacationList = dayDuties.filter(function (x) { return x.event_type === "vacation"; });
|
||||
const hasAny = dayDuties.length > 0;
|
||||
const isToday = key === today;
|
||||
const eventSummaries = calendarEventsByDate[key] || [];
|
||||
const hasEvent = eventSummaries.length > 0;
|
||||
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : "");
|
||||
const dutyNamesAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join("\n")) : "";
|
||||
const dutyTitleAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join(", ")) : "";
|
||||
cell.innerHTML =
|
||||
"<span class=\"num\">" + d.getDate() + "</span>" +
|
||||
(dayDuties.length ? "<span class=\"duty-marker\" data-duty-names=\"" + dutyNamesAttr + "\" title=\"" + dutyTitleAttr + "\" aria-label=\"Дежурные\">Д</span>" : "") +
|
||||
(hasEvent ? "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>" : "");
|
||||
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (hasAny ? " has-duty" : "") + (hasEvent ? " holiday" : "");
|
||||
|
||||
function namesAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join("\n")) : ""; }
|
||||
function titleAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join(", ")) : ""; }
|
||||
|
||||
let markers = "<span class=\"num\">" + d.getDate() + "</span>";
|
||||
if (dutyList.length) {
|
||||
markers += "<span class=\"duty-marker\" data-names=\"" + namesAttr(dutyList) + "\" title=\"" + titleAttr(dutyList) + "\" aria-label=\"Дежурные\">Д</span>";
|
||||
}
|
||||
if (unavailableList.length) {
|
||||
markers += "<span class=\"unavailable-marker\" data-names=\"" + namesAttr(unavailableList) + "\" title=\"" + titleAttr(unavailableList) + "\" aria-label=\"Недоступен\">Н</span>";
|
||||
}
|
||||
if (vacationList.length) {
|
||||
markers += "<span class=\"vacation-marker\" data-names=\"" + namesAttr(vacationList) + "\" title=\"" + titleAttr(vacationList) + "\" aria-label=\"Отпуск\">О</span>";
|
||||
}
|
||||
if (hasEvent) {
|
||||
markers += "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>";
|
||||
}
|
||||
cell.innerHTML = markers;
|
||||
calendarEl.appendChild(cell);
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
@@ -312,13 +328,14 @@
|
||||
document.body.appendChild(hintEl);
|
||||
}
|
||||
var hideTimeout = null;
|
||||
calendarEl.querySelectorAll(".duty-marker").forEach(function (marker) {
|
||||
var selector = ".duty-marker, .unavailable-marker, .vacation-marker";
|
||||
calendarEl.querySelectorAll(selector).forEach(function (marker) {
|
||||
marker.addEventListener("mouseenter", function () {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
var names = marker.getAttribute("data-duty-names") || "";
|
||||
var names = marker.getAttribute("data-names") || "";
|
||||
hintEl.textContent = names;
|
||||
var rect = marker.getBoundingClientRect();
|
||||
positionHint(hintEl, rect);
|
||||
@@ -333,9 +350,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
|
||||
|
||||
function renderDutyList(duties) {
|
||||
if (duties.length === 0) {
|
||||
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
|
||||
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце событий нет.</p>";
|
||||
return;
|
||||
}
|
||||
const grouped = {};
|
||||
@@ -354,7 +373,9 @@
|
||||
const endDate = new Date(d.end_at);
|
||||
const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0");
|
||||
const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0");
|
||||
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " – " + end + "</div></div>";
|
||||
const typeLabel = EVENT_TYPE_LABELS[d.event_type] || d.event_type;
|
||||
const itemClass = "duty-item duty-item--" + (d.event_type || "duty");
|
||||
html += "<div class=\"" + itemClass + "\"><span class=\"duty-item-type\">" + escapeHtml(typeLabel) + "</span> <span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " – " + end + "</div></div>";
|
||||
});
|
||||
});
|
||||
dutyListEl.innerHTML = html;
|
||||
|
||||
@@ -164,7 +164,9 @@ body {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.duty-marker {
|
||||
.duty-marker,
|
||||
.unavailable-marker,
|
||||
.vacation-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -173,12 +175,25 @@ body {
|
||||
margin-top: 2px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--duty);
|
||||
background: rgba(158, 206, 106, 0.25);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.duty-marker {
|
||||
color: var(--duty);
|
||||
background: rgba(158, 206, 106, 0.25);
|
||||
}
|
||||
|
||||
.unavailable-marker {
|
||||
color: #e0af68;
|
||||
background: rgba(224, 175, 104, 0.25);
|
||||
}
|
||||
|
||||
.vacation-marker {
|
||||
color: #7dcfff;
|
||||
background: rgba(125, 207, 255, 0.25);
|
||||
}
|
||||
|
||||
.duty-list {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -197,6 +212,20 @@ body {
|
||||
border-left: 3px solid var(--duty);
|
||||
}
|
||||
|
||||
.duty-item--unavailable {
|
||||
border-left-color: #e0af68;
|
||||
}
|
||||
|
||||
.duty-item--vacation {
|
||||
border-left-color: #7dcfff;
|
||||
}
|
||||
|
||||
.duty-item .duty-item-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.duty-item .name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user