diff --git a/alembic/versions/0017_add_transition_requirements.py b/alembic/versions/0017_add_transition_requirements.py new file mode 100644 index 0000000..691fe5d --- /dev/null +++ b/alembic/versions/0017_add_transition_requirements.py @@ -0,0 +1,27 @@ +"""add transition requirements fields for status designer + +Revision ID: 0017_transition_requirements +Revises: 0016_table_availability +Create Date: 2026-02-25 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0017_transition_requirements" +down_revision = "0016_table_availability" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("topic_status_transitions", sa.Column("required_data_keys", sa.JSON(), nullable=True)) + op.add_column("topic_status_transitions", sa.Column("required_mime_types", sa.JSON(), nullable=True)) + + +def downgrade(): + op.drop_column("topic_status_transitions", "required_mime_types") + op.drop_column("topic_status_transitions", "required_data_keys") diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py index 655d550..13b0be0 100644 --- a/app/api/admin/crud.py +++ b/app/api/admin/crud.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib +import json import pkgutil import uuid from datetime import date, datetime, timezone @@ -52,6 +53,7 @@ from app.services.request_read_markers import ( from app.services.request_status import apply_status_change_effects from app.services.status_flow import transition_allowed_for_topic from app.services.request_templates import validate_required_topic_fields_or_400 +from app.services.status_transition_requirements import validate_transition_requirements_or_400 from app.services.billing_flow import apply_billing_transition_effects, normalize_status_kind_or_400 from app.services.universal_query import apply_universal_query @@ -399,6 +401,8 @@ def _column_label(table_name: str, column_name: str) -> str: "options": "Опции", "field_key": "Поле формы", "sla_hours": "SLA (часы)", + "required_data_keys": "Обязательные данные шага", + "required_mime_types": "Обязательные файлы шага", "avatar_url": "Аватар", "file_name": "Имя файла", "mime_type": "MIME-тип", @@ -742,6 +746,40 @@ def _as_positive_int_or_400(value: Any, field_name: str) -> int: return number +def _normalize_string_list_or_400(value: Any, field_name: str) -> list[str] | None: + if value is None: + return None + + source = value + if isinstance(source, str): + text = source.strip() + if not text: + return None + if text.startswith("["): + try: + source = json.loads(text) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть JSON-массивом строк') + else: + source = [chunk.strip() for chunk in text.replace("\n", ",").split(",")] + + if not isinstance(source, (list, tuple, set)): + raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть массивом строк') + + out: list[str] = [] + seen: set[str] = set() + for item in source: + text = str(item or "").strip() + if not text: + continue + lowered = text.lower() + if lowered in seen: + continue + seen.add(lowered) + out.append(text) + return out + + def _apply_topic_required_fields_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: data = dict(payload) if "topic_code" in data: @@ -831,6 +869,10 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any]) data["sla_hours"] = None else: data["sla_hours"] = _as_positive_int_or_400(raw, "sla_hours") + if "required_data_keys" in data: + data["required_data_keys"] = _normalize_string_list_or_400(data.get("required_data_keys"), "required_data_keys") + if "required_mime_types" in data: + data["required_mime_types"] = _normalize_string_list_or_400(data.get("required_mime_types"), "required_mime_types") return data @@ -1432,6 +1474,16 @@ def update_row( detail="Переход статуса не разрешен для выбранной темы", ) if before_status != after_status and isinstance(row, Request): + extra_fields_override = clean_payload.get("extra_fields") + if not isinstance(extra_fields_override, dict): + extra_fields_override = row.extra_fields if isinstance(row.extra_fields, dict) else None + validate_transition_requirements_or_400( + db, + row, + from_status=before_status, + to_status=after_status, + extra_fields_override=extra_fields_override, + ) billing_note = apply_billing_transition_effects( db, req=row, diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index fa834da..ea174ab 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -1,7 +1,7 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from sqlalchemy import case, or_, update @@ -33,11 +33,86 @@ from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_law from app.services.request_status import actor_admin_uuid, apply_status_change_effects from app.services.status_flow import transition_allowed_for_topic from app.services.request_templates import validate_required_topic_fields_or_400 +from app.services.status_transition_requirements import normalize_string_list, validate_transition_requirements_or_400 from app.services.billing_flow import apply_billing_transition_effects 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": "Завершены", +} + + +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} + + +def _kanban_group_for_status(status_code: str, status_meta: dict[str, object]) -> str: + 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" + if kind == "PAID": + return "DONE" + if code.startswith("NEW") or "НОВ" in name: + return "NEW" + waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK") + waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД") + if kind == "INVOICE": + return "WAITING" + if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens): + return "WAITING" + 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" + + +def _parse_datetime_safe(value: object) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + text = str(value).strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _extract_case_deadline(extra_fields: object) -> datetime | None: + if not isinstance(extra_fields, dict): + return None + deadline_keys = ( + "deadline_at", + "deadline", + "due_date", + "due_at", + "case_deadline", + "court_date", + "hearing_date", + "next_action_deadline", + ) + for key in deadline_keys: + parsed = _parse_datetime_safe(extra_fields.get(key)) + if parsed: + return parsed + return None def _request_uuid_or_400(request_id: str) -> UUID: @@ -140,6 +215,216 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe } +@router.get("/kanban") +def get_requests_kanban( + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER")), + limit: int = Query(default=400, ge=1, le=1000), +): + role = str(admin.get("role") or "").upper() + actor = str(admin.get("sub") or "").strip() + + base_query = db.query(Request) + if role == "LAWYER": + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + base_query = base_query.filter( + or_( + Request.assigned_lawyer_id == actor, + Request.assigned_lawyer_id.is_(None), + ) + ) + + request_rows: list[Request] = ( + base_query + .order_by(Request.created_at.desc()) + .limit(limit) + .all() + ) + total = int(base_query.count() or 0) + + 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()} + + transition_rows: list[TopicStatusTransition] = [] + if topic_codes: + transition_rows = ( + db.query(TopicStatusTransition) + .filter( + TopicStatusTransition.enabled.is_(True), + TopicStatusTransition.topic_code.in_(list(topic_codes)), + ) + .order_by( + TopicStatusTransition.topic_code.asc(), + TopicStatusTransition.from_status.asc(), + TopicStatusTransition.sort_order.asc(), + TopicStatusTransition.to_status.asc(), + ) + .all() + ) + for row in transition_rows: + from_code = str(row.from_status or "").strip() + to_code = str(row.to_status or "").strip() + if from_code: + status_codes.add(from_code) + if to_code: + status_codes.add(to_code) + + 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_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), + } + for 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()} + lawyer_name_map: dict[str, str] = {} + if assigned_ids: + valid_lawyer_ids: list[UUID] = [] + for raw in assigned_ids: + try: + valid_lawyer_ids.append(UUID(raw)) + except ValueError: + continue + if valid_lawyer_ids: + lawyer_rows = db.query(AdminUser).filter(AdminUser.id.in_(valid_lawyer_ids)).all() + lawyer_name_map = { + str(row.id): str(row.name or row.email or row.id) + for row in lawyer_rows + } + + history_rows: list[StatusHistory] = [] + if request_ids: + history_rows = ( + db.query(StatusHistory) + .filter(StatusHistory.request_id.in_(request_ids)) + .order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.desc()) + .all() + ) + + current_status_changed_at: dict[str, datetime] = {} + previous_status_by_request: dict[str, str] = {} + for row in history_rows: + request_id = str(row.request_id) + request_row = request_id_to_row.get(request_id) + if request_row is None: + continue + current_status = str(request_row.status_code or "").strip() + to_status = str(row.to_status or "").strip() + if not current_status or to_status != current_status: + continue + if request_id not in current_status_changed_at and row.created_at: + current_status_changed_at[request_id] = row.created_at + previous_status_by_request[request_id] = str(row.from_status or "").strip() + + transitions_by_key: dict[tuple[str, str], list[TopicStatusTransition]] = {} + transitions_to_key: dict[tuple[str, str], list[TopicStatusTransition]] = {} + for row in transition_rows: + topic_code = str(row.topic_code or "").strip() + from_status = str(row.from_status or "").strip() + to_status = str(row.to_status or "").strip() + if not topic_code or not from_status or not to_status: + continue + transitions_by_key.setdefault((topic_code, from_status), []).append(row) + transitions_to_key.setdefault((topic_code, to_status), []).append(row) + + items: list[dict[str, object]] = [] + group_totals = {key: 0 for key in KANBAN_GROUP_LABELS.keys()} + 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 + + 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) + 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), + "sla_hours": transition.sla_hours, + } + ) + + case_deadline = _extract_case_deadline(row.extra_fields) + entered_at = current_status_changed_at.get(request_id) or row.created_at + previous_status = previous_status_by_request.get(request_id) + transition_candidates = transitions_to_key.get((topic_code, status_code), []) + matched_transition = None + if previous_status: + for transition in transition_candidates: + if str(transition.from_status or "").strip() == previous_status: + matched_transition = transition + break + if matched_transition is None and transition_candidates: + matched_transition = transition_candidates[0] + + sla_deadline = None + if entered_at and matched_transition and matched_transition.sla_hours is not None: + sla_deadline = entered_at + timedelta(hours=int(matched_transition.sla_hours)) + + assigned_id = str(row.assigned_lawyer_id or "").strip() or None + items.append( + { + "id": request_id, + "track_number": row.track_number, + "client_name": row.client_name, + "client_phone": row.client_phone, + "topic_code": row.topic_code, + "status_code": status_code, + "status_name": str(status_meta.get("name") or status_code), + "status_group": status_group, + "assigned_lawyer_id": assigned_id, + "assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id), + "description": row.description, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "lawyer_has_unread_updates": bool(row.lawyer_has_unread_updates), + "lawyer_unread_event_type": row.lawyer_unread_event_type, + "client_has_unread_updates": bool(row.client_has_unread_updates), + "client_unread_event_type": row.client_unread_event_type, + "case_deadline_at": case_deadline.isoformat() if case_deadline else None, + "sla_deadline_at": sla_deadline.isoformat() if sla_deadline else None, + "available_transitions": available_transitions, + } + ) + + columns = [ + { + "key": key, + "label": label, + "total": int(group_totals.get(key, 0)), + } + for key, label in KANBAN_GROUP_LABELS.items() + ] + + return { + "scope": role, + "rows": items, + "columns": columns, + "total": total, + "limit": int(limit), + "truncated": bool(total > len(items)), + } + + @router.post("", status_code=201) def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): actor_role = str(admin.get("role") or "").upper() @@ -227,6 +512,13 @@ def update_request( next_status, ): raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы") + validate_transition_requirements_or_400( + db, + row, + from_status=old_status, + to_status=next_status, + extra_fields_override=row.extra_fields if isinstance(row.extra_fields, dict) else None, + ) billing_note = apply_billing_transition_effects( db, req=row, @@ -418,7 +710,7 @@ def get_request_status_route( add_code(current_status) - transition_by_to_status: dict[str, dict[str, str | int | None]] = {} + transition_by_to_status: dict[str, dict[str, object]] = {} for row in transitions: to_code = str(row.to_status or "").strip() if not to_code: @@ -428,6 +720,8 @@ def get_request_status_route( transition_by_to_status[to_code] = { "from_status": str(row.from_status or "").strip() or None, "sla_hours": row.sla_hours, + "required_data_keys": normalize_string_list(row.required_data_keys), + "required_mime_types": normalize_string_list(row.required_mime_types), "sort_order": int(row.sort_order or 0), } @@ -461,6 +755,12 @@ def get_request_status_route( sla_hours = transition_meta.get("sla_hours") if sla_hours is not None: note_parts.append(f"SLA: {sla_hours} ч") + required_data_keys = transition_meta.get("required_data_keys") or [] + if required_data_keys: + note_parts.append("Данные: " + ", ".join(str(item) for item in required_data_keys)) + required_mime_types = transition_meta.get("required_mime_types") or [] + if required_mime_types: + note_parts.append("Файлы: " + ", ".join(str(item) for item in required_mime_types)) kind = str(meta.get("kind") or "DEFAULT") if kind == "INVOICE": note_parts.append("Этап выставления счета") @@ -474,6 +774,8 @@ def get_request_status_route( "kind": kind, "state": state, "sla_hours": sla_hours, + "required_data_keys": required_data_keys, + "required_mime_types": required_mime_types, "changed_at": changed_at_by_status.get(code), "note": " • ".join(note_parts), } diff --git a/app/models/topic_status_transition.py b/app/models/topic_status_transition.py index 7ca4488..948d968 100644 --- a/app/models/topic_status_transition.py +++ b/app/models/topic_status_transition.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Integer, Boolean, UniqueConstraint +from sqlalchemy import String, Integer, Boolean, UniqueConstraint, JSON from sqlalchemy.orm import Mapped, mapped_column from app.db.session import Base @@ -21,4 +21,6 @@ class TopicStatusTransition(Base, UUIDMixin, TimestampMixin): to_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) sla_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + required_data_keys: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) + required_mime_types: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/app/services/status_transition_requirements.py b/app/services/status_transition_requirements.py new file mode 100644 index 0000000..e7f4bc0 --- /dev/null +++ b/app/services/status_transition_requirements.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.attachment import Attachment +from app.models.request import Request +from app.models.topic_status_transition import TopicStatusTransition + + +def normalize_string_list(value: Any) -> list[str]: + if not isinstance(value, (list, tuple, set)): + return [] + out: list[str] = [] + seen: set[str] = set() + for item in value: + text = str(item or "").strip() + if not text: + continue + lowered = text.lower() + if lowered in seen: + continue + seen.add(lowered) + out.append(text) + return out + + +def _is_missing_value(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + return not value.strip() + if isinstance(value, (list, tuple, dict, set)): + return len(value) == 0 + return False + + +def _find_transition_rule( + db: Session, + topic_code: str | None, + from_status: str, + to_status: str, +) -> TopicStatusTransition | None: + topic = str(topic_code or "").strip() + from_code = str(from_status or "").strip() + to_code = str(to_status or "").strip() + if not topic or not from_code or not to_code or from_code == to_code: + return None + return ( + db.query(TopicStatusTransition) + .filter( + TopicStatusTransition.topic_code == topic, + TopicStatusTransition.from_status == from_code, + TopicStatusTransition.to_status == to_code, + TopicStatusTransition.enabled.is_(True), + ) + .order_by(TopicStatusTransition.sort_order.asc(), TopicStatusTransition.created_at.asc()) + .first() + ) + + +def _mime_matches(requirement: str, value: str) -> bool: + required = str(requirement or "").strip().lower() + actual = str(value or "").strip().lower() + if not required or not actual: + return False + if required.endswith("/*"): + return actual.startswith(required[:-1]) + return actual == required + + +def validate_transition_requirements_or_400( + db: Session, + req: Request, + from_status: str, + to_status: str, + *, + extra_fields_override: dict[str, Any] | None = None, +) -> None: + transition = _find_transition_rule( + db, + topic_code=str(req.topic_code or "").strip() or None, + from_status=from_status, + to_status=to_status, + ) + if transition is None: + return + + required_data_keys = normalize_string_list(transition.required_data_keys) + required_mime_types = normalize_string_list(transition.required_mime_types) + if not required_data_keys and not required_mime_types: + return + + payload = extra_fields_override if isinstance(extra_fields_override, dict) else req.extra_fields + if not isinstance(payload, dict): + payload = {} + missing_data_keys = [key for key in required_data_keys if _is_missing_value(payload.get(key))] + + available_mime_types = [ + str(mime_type or "").strip().lower() + for (mime_type,) in db.query(Attachment.mime_type).filter(Attachment.request_id == req.id).all() + if str(mime_type or "").strip() + ] + missing_mime_types: list[str] = [] + for required in required_mime_types: + if any(_mime_matches(required, mime) for mime in available_mime_types): + continue + missing_mime_types.append(required) + + if not missing_data_keys and not missing_mime_types: + return + + parts: list[str] = [] + if missing_data_keys: + parts.append("обязательные данные: " + ", ".join(missing_data_keys)) + if missing_mime_types: + parts.append("обязательные файлы: " + ", ".join(missing_mime_types)) + raise HTTPException(status_code=400, detail="Переход требует заполнения шага: " + "; ".join(parts)) diff --git a/app/web/admin.css b/app/web/admin.css index 1397361..8e0ea8f 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -252,6 +252,178 @@ color: #f6dab0; } + .kanban-wrap { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .kanban-board { + display: grid; + grid-template-columns: repeat(4, minmax(260px, 1fr)); + gap: 0.75rem; + overflow-x: auto; + padding-bottom: 0.25rem; + } + + .kanban-column { + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.02); + min-height: 460px; + display: flex; + flex-direction: column; + transition: border-color 0.18s ease, background 0.18s ease; + } + + .kanban-column.drag-over { + border-color: rgba(212, 168, 106, 0.55); + background: rgba(212, 168, 106, 0.08); + } + + .kanban-column-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + padding: 0.7rem 0.75rem; + border-bottom: 1px solid var(--line); + } + + .kanban-column-head b { + font-size: 0.95rem; + color: #e5eefb; + } + + .kanban-column-head span { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.12rem 0.45rem; + font-size: 0.76rem; + color: var(--muted); + background: rgba(255, 255, 255, 0.04); + } + + .kanban-column-body { + padding: 0.65rem; + display: flex; + flex-direction: column; + gap: 0.6rem; + overflow-y: auto; + max-height: 68vh; + } + + .kanban-card { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + padding: 0.6rem; + display: flex; + flex-direction: column; + gap: 0.45rem; + cursor: grab; + } + + .kanban-card:active { + cursor: grabbing; + } + + .kanban-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + } + + .kanban-card-head code { + color: #f5dbb5; + font-size: 0.8rem; + font-weight: 700; + } + + .kanban-status-badge { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.72rem; + line-height: 1.2; + color: #dce8f9; + background: rgba(255, 255, 255, 0.06); + max-width: 68%; + text-align: right; + word-break: break-word; + } + + .kanban-status-badge.group-new { + border-color: rgba(76, 160, 255, 0.5); + color: #b8d8ff; + } + + .kanban-status-badge.group-in_progress { + border-color: rgba(212, 168, 106, 0.5); + color: #f8d8aa; + } + + .kanban-status-badge.group-waiting { + border-color: rgba(137, 165, 218, 0.5); + color: #c9dbff; + } + + .kanban-status-badge.group-done { + border-color: rgba(77, 190, 147, 0.55); + color: #b7efda; + } + + .kanban-card-desc { + margin: 0; + color: #e8f0fb; + font-size: 0.86rem; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 2.8rem; + } + + .kanban-card-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.4rem; + color: var(--muted); + font-size: 0.76rem; + } + + .danger-text { + color: #ffb7b7; + } + + .kanban-card-actions { + margin-top: 0.1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.45rem; + flex-wrap: wrap; + } + + .kanban-transition-select { + flex: 1; + min-width: 140px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: #dbe7f8; + padding: 0.34rem 0.55rem; + font-size: 0.78rem; + } + + .kanban-empty { + margin: 0.3rem 0 0; + text-align: center; + } + .filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -609,6 +781,116 @@ min-width: 640px; } + .status-designer { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.65rem; + background: rgba(255, 255, 255, 0.015); + margin-bottom: 0.72rem; + } + + .status-designer-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.7rem; + flex-wrap: wrap; + margin-bottom: 0.6rem; + } + + .status-designer-head h4 { + margin: 0; + font-size: 0.95rem; + } + + .status-designer-head p { + margin: 0.3rem 0 0; + font-size: 0.82rem; + } + + .status-designer-controls { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + } + + .status-designer-controls select { + min-width: 220px; + max-width: 340px; + } + + .status-designer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.55rem; + } + + .status-node-card { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.52rem; + background: rgba(255, 255, 255, 0.018); + display: grid; + gap: 0.46rem; + } + + .status-node-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.45rem; + } + + .status-node-head b { + display: block; + color: #e6effd; + margin-bottom: 0.1rem; + font-size: 0.9rem; + } + + .status-node-head code { + font-size: 0.74rem; + color: #aac0dd; + } + + .status-node-terminal { + border: 1px solid rgba(214, 144, 95, 0.45); + border-radius: 999px; + padding: 0.14rem 0.48rem; + font-size: 0.68rem; + color: #f3d2a8; + white-space: nowrap; + } + + .status-node-links li { + margin-bottom: 0.34rem; + } + + .status-link-chip { + width: 100%; + text-align: left; + border: 1px solid rgba(118, 145, 184, 0.45); + border-radius: 10px; + background: rgba(45, 67, 98, 0.28); + color: #dce8f8; + padding: 0.34rem 0.46rem; + cursor: pointer; + display: grid; + gap: 0.2rem; + } + + .status-link-chip small { + color: #9eb1ca; + font-size: 0.72rem; + line-height: 1.35; + } + + .status-link-chip:hover { + border-color: rgba(138, 168, 210, 0.7); + background: rgba(71, 102, 148, 0.3); + } + .json { border: 1px solid var(--line); border-radius: 12px; @@ -836,6 +1118,17 @@ justify-content: center; } + .file-action-btn { + width: 34px; + height: 34px; + border-radius: 10px; + color: #d8e4f5; + } + + .request-file-link-icon { + text-decoration: none; + } + .request-attachments-head { display: flex; align-items: center; @@ -855,8 +1148,44 @@ max-height: 480px; } + .request-chat-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + margin-bottom: 0.58rem; + flex-wrap: wrap; + } + + .request-chat-tabs { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.2rem; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.02); + } + + .tab-btn { + border: none; + background: transparent; + color: #9fb3cf; + border-radius: 999px; + padding: 0.26rem 0.64rem; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; + } + + .tab-btn.active { + background: rgba(90, 126, 194, 0.38); + color: #e8f1ff; + box-shadow: inset 0 0 0 1px rgba(104, 145, 223, 0.45); + } + .request-chat-list { - max-height: 520px; + max-height: 470px; overflow: auto; display: flex; flex-direction: column; @@ -929,16 +1258,18 @@ text-align: right; } - .chat-date-divider { + .request-chat-list li.chat-date-divider { margin: 0.32rem 0 0.24rem; padding: 0; border: none; background: transparent; display: flex; justify-content: center; + max-width: none; + width: 100%; } - .chat-date-divider span { + .request-chat-list li.chat-date-divider span { display: inline-flex; align-items: center; justify-content: center; @@ -952,6 +1283,100 @@ line-height: 1.2; } + .request-chat-composer-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + } + + .composer-attach-btn { + width: 36px; + height: 36px; + border-radius: 50%; + } + + .request-chat-composer-dropzone { + border: 1px dashed rgba(111, 140, 186, 0.42); + border-radius: 12px; + padding: 0.55rem; + background: rgba(255, 255, 255, 0.015); + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; + } + + .request-chat-composer-dropzone.drag-active { + border-color: rgba(119, 165, 241, 0.88); + background: rgba(87, 128, 206, 0.12); + box-shadow: inset 0 0 0 1px rgba(119, 165, 241, 0.32); + } + + .request-drop-hint { + margin-top: 0.38rem; + font-size: 0.77rem; + } + + .request-pending-files { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin-top: -0.18rem; + } + + .pending-file-chip { + display: inline-flex; + align-items: center; + gap: 0.34rem; + border: 1px solid rgba(112, 142, 191, 0.4); + border-radius: 999px; + background: rgba(49, 73, 109, 0.32); + padding: 0.2rem 0.34rem 0.2rem 0.42rem; + max-width: min(100%, 360px); + min-height: 30px; + } + + .pending-file-icon { + color: #a4bde0; + font-size: 0.8rem; + line-height: 1; + flex-shrink: 0; + } + + .pending-file-name { + font-size: 0.79rem; + color: #dce7f8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .pending-file-remove { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid rgba(156, 184, 224, 0.4); + background: rgba(17, 29, 44, 0.72); + color: #d7e4f8; + cursor: pointer; + line-height: 1; + padding: 0; + font-size: 0.88rem; + flex-shrink: 0; + } + + .request-files-tab .request-modal-list { + max-height: 520px; + overflow: auto; + } + + .request-files-tab-actions { + margin-top: 0.55rem; + display: flex; + justify-content: flex-start; + gap: 0.45rem; + flex-wrap: wrap; + } + .request-preview-modal { width: min(980px, 100%); } @@ -1075,6 +1500,7 @@ @media (max-width: 1160px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .kanban-board { grid-template-columns: repeat(2, minmax(240px, 1fr)); } .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .triple { grid-template-columns: 1fr; } .config-layout { grid-template-columns: 1fr; } @@ -1096,6 +1522,9 @@ .filters { grid-template-columns: 1fr; } + .kanban-board { + grid-template-columns: 1fr; + } .filter-toolbar { flex-direction: column; align-items: stretch; diff --git a/app/web/admin.html b/app/web/admin.html index 7099c96..8723440 100644 --- a/app/web/admin.html +++ b/app/web/admin.html @@ -4,12 +4,12 @@