diff --git a/alembic/versions/0018_add_status_groups.py b/alembic/versions/0018_add_status_groups.py new file mode 100644 index 0000000..c04c848 --- /dev/null +++ b/alembic/versions/0018_add_status_groups.py @@ -0,0 +1,136 @@ +"""add status groups dictionary and status.status_group_id + +Revision ID: 0018_status_groups +Revises: 0017_transition_requirements +Create Date: 2026-02-25 20:05:00.000000 +""" + +from __future__ import annotations + +import uuid + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "0018_status_groups" +down_revision = "0017_transition_requirements" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "status_groups", + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"), + ) + op.create_index("ix_status_groups_name", "status_groups", ["name"], unique=True) + + op.add_column("statuses", sa.Column("status_group_id", postgresql.UUID(as_uuid=True), nullable=True)) + op.create_index("ix_statuses_status_group_id", "statuses", ["status_group_id"]) + + conn = op.get_bind() + groups = [ + ("Новые", 10), + ("В работе", 20), + ("Ожидание", 30), + ("Завершены", 40), + ] + group_ids: dict[str, str] = {} + for name, sort_order in groups: + group_id = str(uuid.uuid4()) + group_ids[name] = group_id + conn.execute( + sa.text( + """ + INSERT INTO status_groups (id, name, sort_order, responsible) + VALUES (:id, :name, :sort_order, :responsible) + """ + ), + { + "id": group_id, + "name": name, + "sort_order": sort_order, + "responsible": "Администратор системы", + }, + ) + + conn.execute( + sa.text( + """ + UPDATE statuses + SET status_group_id = :group_done + WHERE + COALESCE(is_terminal, false) = true + OR UPPER(COALESCE(kind, 'DEFAULT')) = 'PAID' + OR UPPER(COALESCE(code, '')) LIKE '%CLOSE%' + OR UPPER(COALESCE(code, '')) LIKE '%RESOLV%' + OR UPPER(COALESCE(code, '')) LIKE '%REJECT%' + OR UPPER(COALESCE(code, '')) LIKE '%DONE%' + OR UPPER(COALESCE(code, '')) LIKE '%PAID%' + """ + ), + {"group_done": group_ids["Завершены"]}, + ) + conn.execute( + sa.text( + """ + UPDATE statuses + SET status_group_id = :group_waiting + WHERE + status_group_id IS NULL + AND ( + UPPER(COALESCE(kind, 'DEFAULT')) = 'INVOICE' + OR UPPER(COALESCE(code, '')) LIKE '%WAIT%' + OR UPPER(COALESCE(code, '')) LIKE '%PEND%' + OR UPPER(COALESCE(code, '')) LIKE '%HOLD%' + OR UPPER(COALESCE(code, '')) LIKE '%SUSPEND%' + OR UPPER(COALESCE(code, '')) LIKE '%BLOCK%' + ) + """ + ), + {"group_waiting": group_ids["Ожидание"]}, + ) + conn.execute( + sa.text( + """ + UPDATE statuses + SET status_group_id = :group_new + WHERE + status_group_id IS NULL + AND ( + UPPER(COALESCE(code, '')) LIKE 'NEW%' + OR UPPER(COALESCE(code, '')) LIKE '%_NEW' + ) + """ + ), + {"group_new": group_ids["Новые"]}, + ) + conn.execute( + sa.text( + """ + UPDATE statuses + SET status_group_id = :group_in_progress + WHERE status_group_id IS NULL + """ + ), + {"group_in_progress": group_ids["В работе"]}, + ) + + op.alter_column("status_groups", "sort_order", server_default=None) + op.alter_column("status_groups", "created_at", server_default=None) + op.alter_column("status_groups", "updated_at", server_default=None) + op.alter_column("status_groups", "responsible", server_default=None) + + +def downgrade() -> None: + op.drop_index("ix_statuses_status_group_id", table_name="statuses") + op.drop_column("statuses", "status_group_id") + op.drop_index("ix_status_groups_name", table_name="status_groups") + op.drop_table("status_groups") diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index 0ba32ba..c60c32d 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from app.core.deps import require_role from app.db.session import get_db +from app.models.admin_user import AdminUser from app.models.request import Request from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message @@ -75,12 +76,22 @@ def create_request_message( body = str((payload or {}).get("body") or "").strip() role = str(admin.get("role") or "").upper() actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор") + actor_admin_user_id = str(admin.get("sub") or "").strip() or None + if actor_admin_user_id: + try: + actor_uuid = UUID(actor_admin_user_id) + except ValueError: + actor_uuid = None + if actor_uuid is not None: + actor_user = db.get(AdminUser, actor_uuid) + if actor_user is not None: + actor_name = str(actor_user.name or actor_user.email or actor_name) row = create_admin_or_lawyer_message( db, request=req, body=body, actor_role=role, actor_name=actor_name, - actor_admin_user_id=str(admin.get("sub") or "").strip() or None, + actor_admin_user_id=actor_admin_user_id, ) return serialize_message(row) diff --git a/app/api/admin/config.py b/app/api/admin/config.py index cea74cd..64a64b2 100644 --- a/app/api/admin/config.py +++ b/app/api/admin/config.py @@ -1,12 +1,14 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError +from uuid import UUID from app.db.session import get_db from app.core.deps import require_role from app.schemas.universal import UniversalQuery from app.schemas.admin import TopicUpsert, StatusUpsert, FormFieldUpsert from app.models.topic import Topic from app.models.status import Status +from app.models.status_group import StatusGroup from app.models.form_field import FormField from app.services.universal_query import apply_universal_query @@ -22,6 +24,7 @@ def _status_row(row: Status): "id": str(row.id), "code": row.code, "name": row.name, + "status_group_id": str(row.status_group_id) if row.status_group_id else None, "enabled": row.enabled, "sort_order": row.sort_order, "is_terminal": row.is_terminal, @@ -100,8 +103,20 @@ def query_statuses(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe @router.post("/statuses", status_code=201) def create_status(payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): + data = payload.model_dump() + raw_group = data.get("status_group_id") + if raw_group: + try: + group_id = UUID(str(raw_group)) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректная группа статусов") + if db.get(StatusGroup, group_id) is None: + raise HTTPException(status_code=400, detail="Группа статусов не найдена") + data["status_group_id"] = group_id + else: + data["status_group_id"] = None responsible = str(admin.get("email") or "").strip() or "Администратор системы" - row = Status(**payload.model_dump(), responsible=responsible) + row = Status(**data, responsible=responsible) try: db.add(row) db.commit() @@ -117,7 +132,19 @@ def update_status(id: str, payload: StatusUpsert, db: Session = Depends(get_db), row = db.query(Status).filter(Status.id == id).first() if not row: raise HTTPException(status_code=404, detail="Статус не найден") - for k, v in payload.model_dump().items(): + data = payload.model_dump() + raw_group = data.get("status_group_id") + if raw_group: + try: + group_id = UUID(str(raw_group)) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректная группа статусов") + if db.get(StatusGroup, group_id) is None: + raise HTTPException(status_code=400, detail="Группа статусов не найдена") + data["status_group_id"] = group_id + else: + data["status_group_id"] = None + for k, v in data.items(): setattr(row, k, v) try: db.add(row) diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py index 13b0be0..c579e18 100644 --- a/app/api/admin/crud.py +++ b/app/api/admin/crud.py @@ -31,6 +31,7 @@ from app.models.attachment import Attachment from app.models.message import Message from app.models.request import Request from app.models.status import Status +from app.models.status_group import StatusGroup from app.models.topic_data_template import TopicDataTemplate from app.models.topic_required_field import TopicRequiredField from app.models.topic import Topic @@ -93,6 +94,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = { "quotes": {"ADMIN": set(CRUD_ACTIONS)}, "topics": {"ADMIN": set(CRUD_ACTIONS)}, "statuses": {"ADMIN": set(CRUD_ACTIONS)}, + "status_groups": {"ADMIN": set(CRUD_ACTIONS)}, "form_fields": {"ADMIN": set(CRUD_ACTIONS)}, "clients": {"ADMIN": set(CRUD_ACTIONS)}, "table_availability": {"ADMIN": set(CRUD_ACTIONS)}, @@ -257,6 +259,7 @@ def _table_label(table_name: str) -> str: "quotes": "Цитаты", "topics": "Темы", "statuses": "Статусы", + "status_groups": "Группы статусов", "form_fields": "Поля формы", "clients": "Клиенты", "table_availability": "Доступность таблиц", @@ -359,6 +362,7 @@ def _column_label(table_name: str, column_name: str) -> str: "email": "Email", "role": "Роль", "kind": "Тип", + "status_group_id": "Группа", "status": "Статус", "status_code": "Статус", "topic_code": "Тема", @@ -441,6 +445,126 @@ def _column_label(table_name: str, column_name: str) -> str: return _humanize_identifier_ru(normalized_column) +def _pluralize_identifier(base: str) -> list[str]: + token = _normalize_table_name(base) + if not token: + return [] + candidates = [token] + if token.endswith("y"): + candidates.append(token[:-1] + "ies") + candidates.append(token + "s") + return list(dict.fromkeys(candidates)) + + +def _reference_override(table_name: str, column_name: str) -> tuple[str, str] | None: + normalized_table = _normalize_table_name(table_name) + normalized_column = _normalize_table_name(column_name) + explicit: dict[tuple[str, str], tuple[str, str]] = { + ("requests", "assigned_lawyer_id"): ("admin_users", "id"), + ("requests", "paid_by_admin_id"): ("admin_users", "id"), + ("requests", "topic_code"): ("topics", "code"), + ("requests", "status_code"): ("statuses", "code"), + ("statuses", "status_group_id"): ("status_groups", "id"), + ("topic_required_fields", "topic_code"): ("topics", "code"), + ("topic_required_fields", "field_key"): ("form_fields", "key"), + ("topic_data_templates", "topic_code"): ("topics", "code"), + ("topic_status_transitions", "topic_code"): ("topics", "code"), + ("topic_status_transitions", "from_status"): ("statuses", "code"), + ("topic_status_transitions", "to_status"): ("statuses", "code"), + ("admin_users", "primary_topic_code"): ("topics", "code"), + ("admin_user_topics", "admin_user_id"): ("admin_users", "id"), + ("admin_user_topics", "topic_code"): ("topics", "code"), + ("request_data_requirements", "request_id"): ("requests", "id"), + ("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"), + ("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"), + ("messages", "request_id"): ("requests", "id"), + ("attachments", "request_id"): ("requests", "id"), + ("attachments", "message_id"): ("messages", "id"), + ("invoices", "request_id"): ("requests", "id"), + ("invoices", "client_id"): ("clients", "id"), + ("invoices", "issued_by_admin_user_id"): ("admin_users", "id"), + ("notifications", "recipient_admin_user_id"): ("admin_users", "id"), + ("status_history", "request_id"): ("requests", "id"), + ("status_history", "changed_by_admin_id"): ("admin_users", "id"), + ("audit_log", "actor_admin_id"): ("admin_users", "id"), + } + if (normalized_table, normalized_column) in explicit: + return explicit[(normalized_table, normalized_column)] + return None + + +def _detect_reference_for_column(table_name: str, column_name: str) -> tuple[str, str] | None: + override = _reference_override(table_name, column_name) + if override is not None: + return override + + normalized = _normalize_table_name(column_name) + table_models = _table_model_map() + + if normalized.endswith("_id") and normalized not in {"id"}: + base = normalized[:-3] + for candidate in _pluralize_identifier(base): + if candidate in table_models: + return candidate, "id" + if base.endswith("_admin_user"): + return "admin_users", "id" + if base.endswith("_lawyer"): + return "admin_users", "id" + + if normalized.endswith("_code"): + base = normalized[:-5] + for candidate in _pluralize_identifier(base): + if candidate in table_models: + return candidate, "code" + + return None + + +def _reference_label_field(table_name: str, value_field: str) -> str: + explicit = { + "admin_users": "name", + "clients": "full_name", + "requests": "track_number", + "topics": "name", + "statuses": "name", + "status_groups": "name", + "form_fields": "label", + "topic_data_templates": "label", + "invoices": "invoice_number", + "messages": "body", + "attachments": "file_name", + } + if table_name in explicit: + return explicit[table_name] + + _, model = _resolve_table_model(table_name) + mapper = sa_inspect(model) + hidden = _hidden_response_fields(table_name) + blocked = {"id", value_field, "created_at", "updated_at", "responsible"} + for column in mapper.columns: + name = str(column.key) + if name in hidden or name in blocked: + continue + return name + return value_field + + +def _reference_meta_for_column(table_name: str, column_name: str) -> dict[str, str] | None: + detected = _detect_reference_for_column(table_name, column_name) + if detected is None: + return None + ref_table, value_field = detected + try: + label_field = _reference_label_field(ref_table, value_field) + except HTTPException: + return None + return { + "table": ref_table, + "value_field": value_field, + "label_field": label_field, + } + + def _default_sort_for_table(model: type) -> list[dict[str, str]]: columns = _columns_map(model) if "sort_order" in columns: @@ -466,20 +590,22 @@ def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]: kind = _column_kind(column) has_default = column.default is not None or column.server_default is not None or name in primary_keys editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys - out.append( - { - "name": name, - "label": _column_label(table_name, name), - "kind": kind, - "nullable": bool(column.nullable), - "editable": bool(editable), - "sortable": True, - "filterable": kind != "json", - "required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable), - "has_default": bool(has_default), - "is_primary_key": name in primary_keys, - } - ) + item = { + "name": name, + "label": _column_label(table_name, name), + "kind": kind, + "nullable": bool(column.nullable), + "editable": bool(editable), + "sortable": True, + "filterable": kind != "json", + "required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable), + "has_default": bool(has_default), + "is_primary_key": name in primary_keys, + } + reference = _reference_meta_for_column(table_name, name) + if reference is not None: + item["reference"] = reference + out.append(item) return out @@ -877,10 +1003,20 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any]) return data -def _apply_status_fields(payload: dict[str, Any]) -> dict[str, Any]: +def _apply_status_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: data = dict(payload) if "kind" in data: data["kind"] = normalize_status_kind_or_400(data.get("kind")) + if "status_group_id" in data: + raw_group = data.get("status_group_id") + if raw_group is None or str(raw_group).strip() == "": + data["status_group_id"] = None + else: + group_id = _parse_uuid_or_400(raw_group, "status_group_id") + group = db.get(StatusGroup, group_id) + if group is None: + raise HTTPException(status_code=400, detail="Группа статусов не найдена") + data["status_group_id"] = group_id if "invoice_template" in data: text = str(data.get("invoice_template") or "").strip() data["invoice_template"] = text or None @@ -1359,7 +1495,7 @@ def create_row( if normalized == "topic_status_transitions": clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) if normalized == "statuses": - clean_payload = _apply_status_fields(clean_payload) + clean_payload = _apply_status_fields(db, clean_payload) if normalized == "requests": clean_payload["client_id"] = resolved_request_client_id if normalized == "invoices": @@ -1426,7 +1562,7 @@ def update_row( if normalized == "topic_status_transitions": clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) if normalized == "statuses": - clean_payload = _apply_status_fields(clean_payload) + clean_payload = _apply_status_fields(db, clean_payload) if normalized == "requests" and isinstance(row, Request): if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None: client = _upsert_client_or_400( diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index ea174ab..db84d7a 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta, timezone from uuid import UUID, uuid4 @@ -8,7 +9,7 @@ from sqlalchemy import case, or_, update from app.db.session import get_db from app.core.deps import require_role -from app.schemas.universal import UniversalQuery +from app.schemas.universal import FilterClause, Page, UniversalQuery from app.schemas.admin import ( RequestAdminCreate, RequestAdminPatch, @@ -21,6 +22,7 @@ from app.models.audit_log import AuditLog from app.models.request_data_requirement import RequestDataRequirement from app.models.request import Request from app.models.status import Status +from app.models.status_group import StatusGroup from app.models.status_history import StatusHistory from app.models.topic_data_template import TopicDataTemplate from app.models.topic_status_transition import TopicStatusTransition @@ -39,41 +41,50 @@ from app.services.universal_query import apply_universal_query router = APIRouter() REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} -KANBAN_GROUP_LABELS = { - "NEW": "Новые", - "IN_PROGRESS": "В работе", - "WAITING": "Ожидание", - "DONE": "Завершены", -} +ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"} +ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"} +FALLBACK_KANBAN_GROUPS = [ + ("fallback_new", "Новые", 10), + ("fallback_in_progress", "В работе", 20), + ("fallback_waiting", "Ожидание", 30), + ("fallback_done", "Завершены", 40), +] def _status_meta_or_default(meta_map: dict[str, dict[str, object]], status_code: str) -> dict[str, object]: - return meta_map.get(status_code) or {"name": status_code, "kind": "DEFAULT", "is_terminal": False} + return meta_map.get(status_code) or { + "name": status_code, + "kind": "DEFAULT", + "is_terminal": False, + "status_group_id": None, + "status_group_name": None, + "status_group_order": None, + } -def _kanban_group_for_status(status_code: str, status_meta: dict[str, object]) -> str: +def _fallback_group_for_status(status_code: str, status_meta: dict[str, object]) -> tuple[str, str, int]: code = str(status_code or "").strip().upper() kind = str(status_meta.get("kind") or "DEFAULT").upper() name = str(status_meta.get("name") or "").upper() is_terminal = bool(status_meta.get("is_terminal")) if is_terminal: - return "DONE" + return FALLBACK_KANBAN_GROUPS[3] if kind == "PAID": - return "DONE" + return FALLBACK_KANBAN_GROUPS[3] if code.startswith("NEW") or "НОВ" in name: - return "NEW" + return FALLBACK_KANBAN_GROUPS[0] waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK") waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД") if kind == "INVOICE": - return "WAITING" + return FALLBACK_KANBAN_GROUPS[2] if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens): - return "WAITING" + return FALLBACK_KANBAN_GROUPS[2] done_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID") done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ") if any(token in code for token in done_tokens) or any(token in name for token in done_ru_tokens): - return "DONE" - return "IN_PROGRESS" + return FALLBACK_KANBAN_GROUPS[3] + return FALLBACK_KANBAN_GROUPS[1] def _parse_datetime_safe(value: object) -> datetime | None: @@ -115,6 +126,101 @@ def _extract_case_deadline(extra_fields: object) -> datetime | None: return None +def _coerce_kanban_bool(value: object) -> bool: + if isinstance(value, bool): + return value + text = str(value or "").strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + raise HTTPException(status_code=400, detail='Поле "overdue" должно быть boolean') + + +def _parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, bool]]]: + if not raw_filters: + return [], [] + try: + parsed = json.loads(raw_filters) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail="Некорректный JSON фильтров канбана") from exc + if not isinstance(parsed, list): + raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом") + + universal_filters: list[FilterClause] = [] + overdue_filters: list[tuple[str, bool]] = [] + for index, item in enumerate(parsed): + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом") + field = str(item.get("field") or "").strip() + op = str(item.get("op") or "").strip() + value = item.get("value") + if field not in ALLOWED_KANBAN_FILTER_FIELDS: + raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"') + if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}: + raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"') + if field == "overdue": + if op not in {"=", "!="}: + raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="') + overdue_filters.append((op, _coerce_kanban_bool(value))) + continue + universal_filters.append(FilterClause(field=field, op=op, value=value)) + return universal_filters, overdue_filters + + +def _apply_overdue_filters(items: list[dict[str, object]], overdue_filters: list[tuple[str, bool]]) -> list[dict[str, object]]: + if not overdue_filters: + return items + now = datetime.now(timezone.utc) + out: list[dict[str, object]] = [] + for item in items: + raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at") + deadline_at = _parse_datetime_safe(raw_deadline) + is_overdue = bool(deadline_at and deadline_at <= now) + ok = True + for op, expected in overdue_filters: + if op == "=": + ok = ok and (is_overdue == expected) + elif op == "!=": + ok = ok and (is_overdue != expected) + if not ok: + break + if ok: + out.append(item) + return out + + +def _sort_kanban_items(items: list[dict[str, object]], sort_mode: str) -> list[dict[str, object]]: + mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest" + epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) + + if mode == "lawyer": + return sorted( + items, + key=lambda row: ( + 1 if not str(row.get("assigned_lawyer_name") or "").strip() else 0, + str(row.get("assigned_lawyer_name") or "").lower(), + -int((_parse_datetime_safe(row.get("created_at")) or epoch).timestamp()), + ), + ) + + if mode == "deadline": + far_future = datetime(9999, 12, 31, tzinfo=timezone.utc) + return sorted( + items, + key=lambda row: ( + _parse_datetime_safe(row.get("sla_deadline_at") or row.get("case_deadline_at")) or far_future, + -int((_parse_datetime_safe(row.get("created_at")) or epoch).timestamp()), + ), + ) + + return sorted( + items, + key=lambda row: _parse_datetime_safe(row.get("created_at")) or epoch, + reverse=True, + ) + + def _request_uuid_or_400(request_id: str) -> UUID: try: return UUID(str(request_id)) @@ -220,6 +326,8 @@ def get_requests_kanban( db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER")), limit: int = Query(default=400, ge=1, le=1000), + filters: str | None = Query(default=None), + sort_mode: str = Query(default="created_newest"), ): role = str(admin.get("role") or "").upper() actor = str(admin.get("sub") or "").strip() @@ -235,18 +343,23 @@ def get_requests_kanban( ) ) - request_rows: list[Request] = ( - base_query - .order_by(Request.created_at.desc()) - .limit(limit) - .all() - ) - total = int(base_query.count() or 0) + normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest" + query_filters, overdue_filters = _parse_kanban_filters_or_400(filters) + if query_filters: + base_query = apply_universal_query( + base_query, + Request, + UniversalQuery( + filters=query_filters, + sort=[], + page=Page(limit=limit, offset=0), + ), + ) + + request_rows: list[Request] = base_query.all() request_id_to_row = {str(row.id): row for row in request_rows} request_ids = [row.id for row in request_rows] - request_ids_str = list(request_id_to_row.keys()) - topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()} status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()} @@ -276,15 +389,23 @@ def get_requests_kanban( status_meta_map: dict[str, dict[str, object]] = {} if status_codes: - status_rows = db.query(Status).filter(Status.code.in_(list(status_codes))).all() + status_rows = ( + db.query(Status, StatusGroup) + .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) + .filter(Status.code.in_(list(status_codes))) + .all() + ) status_meta_map = { - str(row.code): { - "name": str(row.name or row.code), - "kind": str(row.kind or "DEFAULT"), - "is_terminal": bool(row.is_terminal), - "sort_order": int(row.sort_order or 0), + str(status_row.code): { + "name": str(status_row.name or status_row.code), + "kind": str(status_row.kind or "DEFAULT"), + "is_terminal": bool(status_row.is_terminal), + "sort_order": int(status_row.sort_order or 0), + "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, + "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), + "status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None), } - for row in status_rows + for status_row, group_row in status_rows } assigned_ids = {str(row.assigned_lawyer_id or "").strip() for row in request_rows if str(row.assigned_lawyer_id or "").strip()} @@ -338,27 +459,61 @@ def get_requests_kanban( transitions_by_key.setdefault((topic_code, from_status), []).append(row) transitions_to_key.setdefault((topic_code, to_status), []).append(row) + status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all() + columns_catalog = [ + { + "key": str(group.id), + "label": str(group.name), + "sort_order": int(group.sort_order or 0), + } + for group in status_groups_rows + ] + columns_by_key = {row["key"]: row for row in columns_catalog} + items: list[dict[str, object]] = [] - group_totals = {key: 0 for key in KANBAN_GROUP_LABELS.keys()} + group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog} for row in request_rows: request_id = str(row.id) topic_code = str(row.topic_code or "").strip() status_code = str(row.status_code or "").strip() status_meta = _status_meta_or_default(status_meta_map, status_code) - status_group = _kanban_group_for_status(status_code, status_meta) - group_totals[status_group] = int(group_totals.get(status_group, 0)) + 1 - + status_group = str(status_meta.get("status_group_id") or "").strip() + status_group_name = str(status_meta.get("status_group_name") or "").strip() + status_group_order = status_meta.get("status_group_order") + if not status_group: + fallback_key, fallback_label, fallback_order = _fallback_group_for_status(status_code, status_meta) + status_group = fallback_key + status_group_name = fallback_label + status_group_order = fallback_order + if fallback_key not in columns_by_key: + columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order} + columns_catalog.append(columns_by_key[fallback_key]) + elif status_group not in columns_by_key: + columns_by_key[status_group] = { + "key": status_group, + "label": status_group_name or status_group, + "sort_order": int(status_group_order or 999), + } + columns_catalog.append(columns_by_key[status_group]) available_transitions = [] for transition in transitions_by_key.get((topic_code, status_code), []): to_status = str(transition.to_status or "").strip() if not to_status: continue to_meta = _status_meta_or_default(status_meta_map, to_status) + target_group = str(to_meta.get("status_group_id") or "").strip() + if not target_group: + target_group, fallback_label, fallback_order = _fallback_group_for_status(to_status, to_meta) + if target_group not in columns_by_key: + columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order} + columns_catalog.append(columns_by_key[target_group]) + if target_group not in group_totals: + group_totals[target_group] = 0 available_transitions.append( { "to_status": to_status, "to_status_name": str(to_meta.get("name") or to_status), - "target_group": _kanban_group_for_status(to_status, to_meta), + "target_group": target_group, "sla_hours": transition.sla_hours, } ) @@ -391,6 +546,8 @@ def get_requests_kanban( "status_code": status_code, "status_name": str(status_meta.get("name") or status_code), "status_group": status_group, + "status_group_name": status_group_name or None, + "status_group_order": int(status_group_order or 0) if status_group_order is not None else None, "assigned_lawyer_id": assigned_id, "assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id), "description": row.description, @@ -406,14 +563,37 @@ def get_requests_kanban( } ) - columns = [ - { - "key": key, - "label": label, - "total": int(group_totals.get(key, 0)), - } - for key, label in KANBAN_GROUP_LABELS.items() - ] + items = _apply_overdue_filters(items, overdue_filters) + items = _sort_kanban_items(items, normalized_sort_mode) + total = len(items) + if total > limit: + items = items[:limit] + + for row in items: + key = str(row.get("status_group") or "").strip() + if not key: + continue + group_totals[key] = int(group_totals.get(key, 0)) + 1 + + columns = [] + for item in sorted( + columns_catalog, + key=lambda row: ( + int(row.get("sort_order") or 0), + str(row.get("label") or "").lower(), + ), + ): + key = str(item.get("key") or "") + if not key: + continue + columns.append( + { + "key": key, + "label": str(item.get("label") or key), + "sort_order": int(item.get("sort_order") or 0), + "total": int(group_totals.get(key, 0)), + } + ) return { "scope": role, @@ -421,6 +601,7 @@ def get_requests_kanban( "columns": columns, "total": total, "limit": int(limit), + "sort_mode": normalized_sort_mode, "truncated": bool(total > len(items)), } diff --git a/app/models/status.py b/app/models/status.py index 5a54324..2eeadcc 100644 --- a/app/models/status.py +++ b/app/models/status.py @@ -1,4 +1,7 @@ +import uuid + from sqlalchemy import String, Boolean, Integer, Text +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from app.db.session import Base from app.models.common import UUIDMixin, TimestampMixin @@ -7,6 +10,7 @@ class Status(Base, UUIDMixin, TimestampMixin): __tablename__ = "statuses" code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False) + status_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/app/models/status_group.py b/app/models/status_group.py new file mode 100644 index 0000000..385802a --- /dev/null +++ b/app/models/status_group.py @@ -0,0 +1,12 @@ +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class StatusGroup(Base, UUIDMixin, TimestampMixin): + __tablename__ = "status_groups" + + name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index 5fd6c29..d2eaf8a 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -29,6 +29,7 @@ class TopicUpsert(BaseModel): class StatusUpsert(BaseModel): code: str name: str + status_group_id: Optional[str] = None enabled: bool = True sort_order: int = 0 is_terminal: bool = False diff --git a/app/web/admin.css b/app/web/admin.css index 8e0ea8f..7c2717b 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -175,6 +175,12 @@ color: #dbe6f5; } + .btn.secondary.active-success { + border-color: rgba(77, 190, 147, 0.48); + background: rgba(77, 190, 147, 0.22); + color: #c8f5e4; + } + .btn.danger { border-color: rgba(255, 127, 127, 0.3); background: rgba(255, 127, 127, 0.13); @@ -259,14 +265,18 @@ } .kanban-board { - display: grid; - grid-template-columns: repeat(4, minmax(260px, 1fr)); + display: flex; + flex-wrap: wrap; gap: 0.75rem; - overflow-x: auto; + overflow-x: hidden; padding-bottom: 0.25rem; + align-items: stretch; + align-content: flex-start; } .kanban-column { + flex: 1 1 300px; + min-width: 260px; border: 1px solid var(--line); border-radius: 14px; background: rgba(255, 255, 255, 0.02); @@ -321,13 +331,22 @@ display: flex; flex-direction: column; gap: 0.45rem; + cursor: pointer; + } + + .kanban-card.draggable { cursor: grab; } - .kanban-card:active { + .kanban-card.draggable:active { cursor: grabbing; } + .kanban-card:focus-visible { + outline: 2px solid rgba(137, 178, 255, 0.55); + outline-offset: 1px; + } + .kanban-card-head { display: flex; justify-content: space-between; @@ -403,11 +422,73 @@ margin-top: 0.1rem; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 0.45rem; flex-wrap: wrap; } + .kanban-update-icons { + display: inline-flex; + align-items: center; + gap: 0.35rem; + } + + .kanban-update-icon { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(122, 139, 163, 0.38); + background: rgba(111, 133, 160, 0.14); + color: #9daec4; + font-size: 0.76rem; + line-height: 1; + } + + .kanban-update-icon.is-unread { + border-color: rgba(74, 197, 143, 0.52); + background: rgba(74, 197, 143, 0.2); + color: #b9f3dd; + box-shadow: 0 0 0 2px rgba(74, 197, 143, 0.14); + } + + .kanban-deadline-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 120px; + padding: 0.18rem 0.55rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.05); + color: #dde9fa; + font-size: 0.75rem; + line-height: 1.2; + font-weight: 700; + letter-spacing: 0.01em; + white-space: nowrap; + } + + .kanban-deadline-chip.tone-ok { + border-color: rgba(76, 197, 145, 0.5); + background: rgba(76, 197, 145, 0.2); + color: #c5f8e3; + } + + .kanban-deadline-chip.tone-warn { + border-color: rgba(228, 182, 92, 0.52); + background: rgba(228, 182, 92, 0.22); + color: #f9e0ac; + } + + .kanban-deadline-chip.tone-danger { + border-color: rgba(255, 98, 98, 0.58); + background: rgba(255, 98, 98, 0.24); + color: #ffd4d4; + } + .kanban-transition-select { flex: 1; min-width: 140px; @@ -1258,6 +1339,45 @@ text-align: right; } + .chat-message-files { + margin-top: 0.35rem; + display: flex; + flex-wrap: wrap; + gap: 0.32rem; + } + + .chat-message-file-chip { + border: 1px solid rgba(130, 153, 183, 0.45); + background: rgba(31, 45, 63, 0.58); + color: #d8e6f8; + border-radius: 999px; + padding: 0.16rem 0.46rem 0.16rem 0.36rem; + display: inline-flex; + align-items: center; + gap: 0.28rem; + cursor: pointer; + max-width: 100%; + font-size: 0.75rem; + line-height: 1.2; + } + + .chat-message-file-chip:hover { + border-color: rgba(170, 198, 236, 0.65); + background: rgba(52, 74, 104, 0.66); + } + + .chat-message-file-icon { + color: #a9c1df; + flex-shrink: 0; + } + + .chat-message-file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 220px; + } + .request-chat-list li.chat-date-divider { margin: 0.32rem 0 0.24rem; padding: 0; @@ -1381,6 +1501,12 @@ width: min(980px, 100%); } + .request-preview-head-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } + .request-preview-body { width: 100%; min-height: 280px; @@ -1418,10 +1544,17 @@ margin: 0; } - .request-preview-download { + .request-preview-download-icon { text-decoration: none; } + .workspace-head-icon { + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 0.96rem; + } + .overlay { position: fixed; inset: 0; @@ -1500,7 +1633,10 @@ @media (max-width: 1160px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .kanban-board { grid-template-columns: repeat(2, minmax(240px, 1fr)); } + .kanban-column { + flex-basis: calc(50% - 0.375rem); + min-width: 240px; + } .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .triple { grid-template-columns: 1fr; } .config-layout { grid-template-columns: 1fr; } @@ -1522,8 +1658,9 @@ .filters { grid-template-columns: 1fr; } - .kanban-board { - grid-template-columns: 1fr; + .kanban-column { + flex-basis: 100%; + min-width: 0; } .filter-toolbar { flex-direction: column; diff --git a/app/web/admin.html b/app/web/admin.html index 8723440..5258b9c 100644 --- a/app/web/admin.html +++ b/app/web/admin.html @@ -4,12 +4,12 @@