diff --git a/app/modules/project/db.py b/app/modules/project/db.py index 46de369..672a374 100644 --- a/app/modules/project/db.py +++ b/app/modules/project/db.py @@ -494,6 +494,195 @@ class ProjectStore: store.norm_str(detail)), ) + # ════════════════════════════════════════════════════════════ + # 댓글 (task_comments) + # ════════════════════════════════════════════════════════════ + def list_comments(self, *, task_id: int) -> list[dict[str, Any]]: + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT * FROM task_comments WHERE task_id = %s " + "ORDER BY created_at ASC, id ASC", + (task_id,), + ).fetchall() + return [self._serialize(r) for r in rows] + + def add_comment( + self, *, task_id: int, author_email: str, author_name: str, body: str + ) -> dict[str, Any]: + text = store.norm_str(body) + if not text: + raise ValueError("댓글 내용이 비었습니다.") + with self._pool.connection() as conn: + row = conn.execute( + "INSERT INTO task_comments (task_id, author_email, author_name, body) " + "VALUES (%s,%s,%s,%s) RETURNING *", + (task_id, store.norm_str(author_email, lower=True), + store.norm_str(author_name), text), + ).fetchone() + return self._serialize(row) + + def delete_comment(self, *, comment_id: int, requester: str, is_admin: bool) -> None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT author_email FROM task_comments WHERE id = %s", (comment_id,) + ).fetchone() + if not row: + raise KeyError(comment_id) + if not is_admin and row["author_email"] != store.norm_str(requester, lower=True): + raise PermissionError("본인 댓글만 삭제할 수 있습니다.") + conn.execute("DELETE FROM task_comments WHERE id = %s", (comment_id,)) + + # ════════════════════════════════════════════════════════════ + # 첨부 (task_attachments) — 메타만 저장. 파일은 라우터가 디스크 처리. + # ════════════════════════════════════════════════════════════ + def list_attachments(self, *, task_id: int) -> list[dict[str, Any]]: + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT * FROM task_attachments WHERE task_id = %s " + "ORDER BY created_at DESC, id DESC", + (task_id,), + ).fetchall() + return [self._serialize(r) for r in rows] + + def add_attachment( + self, *, task_id: int, filename: str, stored_name: str, + content_type: str, size_bytes: int, uploaded_by: str, + ) -> dict[str, Any]: + with self._pool.connection() as conn: + row = conn.execute( + "INSERT INTO task_attachments " + "(task_id, filename, stored_name, content_type, size_bytes, uploaded_by) " + "VALUES (%s,%s,%s,%s,%s,%s) RETURNING *", + (task_id, store.norm_str(filename), stored_name, + store.norm_str(content_type), int(size_bytes), + store.norm_str(uploaded_by, lower=True)), + ).fetchone() + return self._serialize(row) + + def get_attachment(self, *, attachment_id: int) -> dict[str, Any] | None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT * FROM task_attachments WHERE id = %s", (attachment_id,) + ).fetchone() + return self._serialize(row) if row else None + + def delete_attachment(self, *, attachment_id: int) -> dict[str, Any]: + """삭제 후 행(파일 경로 정리용 stored_name 포함)을 반환.""" + with self._pool.connection() as conn: + row = conn.execute( + "DELETE FROM task_attachments WHERE id = %s RETURNING *", (attachment_id,) + ).fetchone() + if not row: + raise KeyError(attachment_id) + return self._serialize(row) + + # ════════════════════════════════════════════════════════════ + # 단계 순서/이름 편집 + # ════════════════════════════════════════════════════════════ + def update_stage( + self, *, stage_id: int, name: str | None = None, + is_done_stage: bool | None = None, + ) -> dict[str, Any]: + sets: list[str] = [] + params: list[Any] = [] + if name is not None: + nm = store.norm_str(name) + if not nm: + raise ValueError("단계 이름은 필수입니다.") + sets.append("name = %s") + params.append(nm) + if is_done_stage is not None: + sets.append("is_done_stage = %s") + params.append(bool(is_done_stage)) + if not sets: + raise ValueError("변경할 내용이 없습니다.") + params.append(stage_id) + with self._pool.connection() as conn: + row = conn.execute( + f"UPDATE project_stages SET {', '.join(sets)} WHERE id = %s RETURNING *", + params, + ).fetchone() + if not row: + raise KeyError(stage_id) + return self._serialize(row) + + def reorder_stages(self, *, project_id: int, ordered_ids: list[int]) -> None: + """주어진 순서대로 sort_order 재배정. 해당 프로젝트 단계만 갱신.""" + with self._pool.connection() as conn: + with conn.transaction(): + for i, sid in enumerate(ordered_ids): + conn.execute( + "UPDATE project_stages SET sort_order = %s " + "WHERE id = %s AND project_id = %s", + (i, int(sid), project_id), + ) + + # ════════════════════════════════════════════════════════════ + # 알림센터 (project_notifications) + # ════════════════════════════════════════════════════════════ + def add_notification( + self, *, recipient_email: str, actor_email: str = "", + project_id: int | None = None, task_id: int | None = None, + type: str = "", title: str = "", body: str = "", + ) -> None: + rcpt = store.norm_str(recipient_email, lower=True) + if not rcpt: + return + # 본인이 본인에게 보내는 알림은 생략(아사나도 자기 행동은 알림 안 함) + if rcpt == store.norm_str(actor_email, lower=True): + return + with self._pool.connection() as conn: + conn.execute( + "INSERT INTO project_notifications " + "(recipient_email, actor_email, project_id, task_id, type, title, body) " + "VALUES (%s,%s,%s,%s,%s,%s,%s)", + (rcpt, store.norm_str(actor_email, lower=True), project_id, task_id, + type, store.norm_str(title), store.norm_str(body)), + ) + + def list_notifications( + self, *, recipient_email: str, unread_only: bool = False, limit: int = 100 + ) -> list[dict[str, Any]]: + rcpt = store.norm_str(recipient_email, lower=True) + clause = "WHERE recipient_email = %s" + params: list[Any] = [rcpt] + if unread_only: + clause += " AND is_read = FALSE" + params.append(int(limit)) + with self._pool.connection() as conn: + rows = conn.execute( + f"SELECT * FROM project_notifications {clause} " + "ORDER BY created_at DESC, id DESC LIMIT %s", + params, + ).fetchall() + return [self._serialize(r) for r in rows] + + def count_unread(self, *, recipient_email: str) -> int: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT COUNT(*) AS n FROM project_notifications " + "WHERE recipient_email = %s AND is_read = FALSE", + (store.norm_str(recipient_email, lower=True),), + ).fetchone() + return int(row["n"] or 0) + + def mark_read(self, *, notification_id: int, recipient_email: str) -> None: + with self._pool.connection() as conn: + conn.execute( + "UPDATE project_notifications SET is_read = TRUE " + "WHERE id = %s AND recipient_email = %s", + (notification_id, store.norm_str(recipient_email, lower=True)), + ) + + def mark_all_read(self, *, recipient_email: str) -> int: + with self._pool.connection() as conn: + cur = conn.execute( + "UPDATE project_notifications SET is_read = TRUE " + "WHERE recipient_email = %s AND is_read = FALSE", + (store.norm_str(recipient_email, lower=True),), + ) + return cur.rowcount or 0 + # ════════════════════════════════════════════════════════════ # 직렬화 # ════════════════════════════════════════════════════════════ @@ -513,7 +702,7 @@ class ProjectStore: out[k] = int(out[k]) except (TypeError, ValueError): pass - for k in ("is_done_stage",): + for k in ("is_done_stage", "is_read"): if k in out and out[k] is not None: out[k] = bool(out[k]) return out diff --git a/app/modules/project/router.py b/app/modules/project/router.py index d910890..a1e7866 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -16,16 +16,36 @@ from __future__ import annotations import calendar as _calendar import logging +import uuid from datetime import date +from pathlib import Path from typing import Any -from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi import ( + APIRouter, + BackgroundTasks, + Body, + Depends, + File, + Form, + HTTPException, + Request, + UploadFile, +) +from fastapi.responses import ( + FileResponse, + HTMLResponse, + JSONResponse, + RedirectResponse, +) from app.timezone import today_kst from . import store +# 첨부 업로드 제한 +_MAX_ATTACH_BYTES = 20 * 1024 * 1024 # 20MB + logger = logging.getLogger("project.router") router = APIRouter(prefix="/project", tags=["project"]) @@ -101,6 +121,39 @@ def _notify( bg.add_task(send_email, to=recipients, subject=subject, body=body) +def _task_recipients( + st: Any, project: dict[str, Any], task: dict[str, Any], *, exclude: str = "" +) -> list[str]: + """업무 관련 인앱 알림 수신자 = 담당자 + 프로젝트 owner + 멤버 (행위자 제외).""" + emails: set[str] = set() + if task.get("assignee_email"): + emails.add(store.norm_str(task["assignee_email"], lower=True)) + if project.get("owner_email"): + emails.add(store.norm_str(project["owner_email"], lower=True)) + try: + for m in st.list_members(project_id=project["id"]): + emails.add(store.norm_str(m["user_email"], lower=True)) + except Exception: # noqa: BLE001 + pass + emails.discard(store.norm_str(exclude, lower=True)) + return [e for e in emails if e] + + +def _inbox( + st: Any, *, recipients: list[str], actor: str, project_id: int | None, + task_id: int | None, ntype: str, title: str, body: str = "", +) -> None: + """인앱 알림(알림센터) 생성. 실패해도 본 작업을 막지 않는다.""" + for r in recipients: + try: + st.add_notification( + recipient_email=r, actor_email=actor, project_id=project_id, + task_id=task_id, type=ntype, title=title, body=body, + ) + except Exception: # noqa: BLE001 + logger.warning("알림 생성 실패: %s", r) + + def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse: from app.main import build_erp_nav, render_template # noqa: WPS433 from app.store import is_admin # noqa: WPS433 @@ -605,7 +658,7 @@ async def api_create_task( ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) - # 생성과 동시에 담당자 지정 시 관리자 알림 + # 생성과 동시에 담당자 지정 시 관리자 메일 + 담당자 인앱 알림 if task.get("assignee_email"): _notify( bg, request, @@ -623,6 +676,12 @@ async def api_create_task( f"등록자: {user['email']}\n" ), ) + _inbox( + st, recipients=[task["assignee_email"]], actor=user["email"], + project_id=project_id, task_id=task["id"], ntype="assigned", + title=f"업무 배정: {task['title']}", + body=f"{project['name']} · 마감 {task.get('due_date') or '-'}", + ) return JSONResponse({"task": task}, status_code=201) @@ -647,6 +706,7 @@ async def api_update_task( raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.") project_name = task.get("project_name") or "" + project = st.get_project(project_id=existing["project_id"]) or {"id": existing["project_id"], "name": project_name} if task.get("_newly_assigned"): _notify( bg, request, @@ -664,6 +724,12 @@ async def api_update_task( f"변경자: {user['email']}\n" ), ) + _inbox( + st, recipients=[task["_newly_assigned"]], actor=user["email"], + project_id=existing["project_id"], task_id=task_id, ntype="assigned", + title=f"업무 배정: {task['title']}", + body=f"{project_name} · 마감 {task.get('due_date') or '-'}", + ) if task.get("_newly_completed"): _notify( bg, request, @@ -677,6 +743,11 @@ async def api_update_task( f"완료 처리: {user['email']}\n" ), ) + _inbox( + st, recipients=_task_recipients(st, project, task, exclude=user["email"]), + actor=user["email"], project_id=existing["project_id"], task_id=task_id, + ntype="completed", title=f"업무 완료: {task['title']}", body=project_name, + ) return JSONResponse({"task": task}) @@ -697,3 +768,249 @@ async def api_activity(request: Request, project_id: int) -> JSONResponse: _require_user(request) st = _db_or_503(request) return JSONResponse({"activity": st.list_activity(project_id=project_id, limit=100)}) + + +# ──────────────────────────────────────────────────────────── +# JSON API — 댓글 +# ──────────────────────────────────────────────────────────── +@router.get("/api/tasks/{task_id}/comments") +async def api_list_comments(request: Request, task_id: int) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + task = st.get_task(task_id=task_id) + if task is None: + raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.") + _require_manage(request, st, user, task["project_id"]) + return JSONResponse({"comments": st.list_comments(task_id=task_id)}) + + +@router.post("/api/tasks/{task_id}/comments") +async def api_add_comment( + request: Request, task_id: int, payload: dict[str, Any] = Body(...) +) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + task = st.get_task(task_id=task_id) + if task is None: + raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.") + _require_manage(request, st, user, task["project_id"]) + try: + comment = st.add_comment( + task_id=task_id, + author_email=user["email"], + author_name=user.get("name") or user["email"].split("@")[0], + body=payload.get("body", ""), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + project = st.get_project(project_id=task["project_id"]) or {"id": task["project_id"], "name": task.get("project_name", "")} + _inbox( + st, recipients=_task_recipients(st, project, task, exclude=user["email"]), + actor=user["email"], project_id=task["project_id"], task_id=task_id, + ntype="comment", title=f"새 댓글: {task['title']}", + body=comment["body"][:120], + ) + return JSONResponse({"comment": comment}, status_code=201) + + +@router.delete("/api/comments/{comment_id}") +async def api_delete_comment(request: Request, comment_id: int) -> JSONResponse: + from app.store import is_admin # noqa: WPS433 + + user = _require_user(request) + st = _db_or_503(request) + try: + st.delete_comment(comment_id=comment_id, requester=user["email"], is_admin=is_admin(user)) + except KeyError: + raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.") + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + return JSONResponse({"ok": True}) + + +# ──────────────────────────────────────────────────────────── +# JSON API — 첨부파일 +# ──────────────────────────────────────────────────────────── +def _attach_dir(request: Request, task_id: int) -> Path: + base = Path(str(getattr(request.app.state, "data_dir", "."))) + d = base / "project" / str(task_id) + d.mkdir(parents=True, exist_ok=True) + return d + + +@router.get("/api/tasks/{task_id}/attachments") +async def api_list_attachments(request: Request, task_id: int) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + task = st.get_task(task_id=task_id) + if task is None: + raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.") + _require_manage(request, st, user, task["project_id"]) + return JSONResponse({"attachments": st.list_attachments(task_id=task_id)}) + + +@router.post("/api/tasks/{task_id}/attachments") +async def api_upload_attachment( + request: Request, task_id: int, file: UploadFile = File(...) +) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + task = st.get_task(task_id=task_id) + if task is None: + raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.") + _require_manage(request, st, user, task["project_id"]) + + data = await file.read() + if len(data) > _MAX_ATTACH_BYTES: + raise HTTPException(status_code=413, detail="파일이 너무 큽니다(최대 20MB).") + if not data: + raise HTTPException(status_code=400, detail="빈 파일입니다.") + + orig = (file.filename or "file").replace("\\", "_").replace("/", "_").strip() + suffix = Path(orig).suffix[:16] + stored = f"{uuid.uuid4().hex}{suffix}" + dest = _attach_dir(request, task_id) / stored + dest.write_bytes(data) + + att = st.add_attachment( + task_id=task_id, filename=orig, stored_name=stored, + content_type=file.content_type or "application/octet-stream", + size_bytes=len(data), uploaded_by=user["email"], + ) + return JSONResponse({"attachment": att}, status_code=201) + + +@router.get("/api/attachments/{attachment_id}/download") +async def api_download_attachment(request: Request, attachment_id: int): + user = _require_user(request) + st = _db_or_503(request) + att = st.get_attachment(attachment_id=attachment_id) + if att is None: + raise HTTPException(status_code=404, detail="첨부를 찾을 수 없습니다.") + task = st.get_task(task_id=att["task_id"]) + if task is None: + raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.") + _require_manage(request, st, user, task["project_id"]) + path = _attach_dir(request, att["task_id"]) / att["stored_name"] + if not path.exists(): + raise HTTPException(status_code=404, detail="파일이 디스크에 없습니다.") + return FileResponse( + str(path), filename=att["filename"], + media_type=att.get("content_type") or "application/octet-stream", + ) + + +@router.delete("/api/attachments/{attachment_id}") +async def api_delete_attachment(request: Request, attachment_id: int) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + att = st.get_attachment(attachment_id=attachment_id) + if att is None: + raise HTTPException(status_code=404, detail="첨부를 찾을 수 없습니다.") + task = st.get_task(task_id=att["task_id"]) + if task is not None: + _require_manage(request, st, user, task["project_id"]) + removed = st.delete_attachment(attachment_id=attachment_id) + try: + (_attach_dir(request, removed["task_id"]) / removed["stored_name"]).unlink(missing_ok=True) + except Exception: # noqa: BLE001 + logger.warning("첨부 파일 삭제 실패: %s", removed.get("stored_name")) + return JSONResponse({"ok": True}) + + +# ──────────────────────────────────────────────────────────── +# JSON API — 단계 이름/순서 편집 +# ──────────────────────────────────────────────────────────── +@router.put("/api/projects/{project_id}/stages/order") +async def api_reorder_stages( + request: Request, project_id: int, payload: dict[str, Any] = Body(...) +) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + _require_manage(request, st, user, project_id) + ids = payload.get("ordered_ids") or [] + if not isinstance(ids, list) or not ids: + raise HTTPException(status_code=400, detail="ordered_ids 가 필요합니다.") + st.reorder_stages(project_id=project_id, ordered_ids=[int(i) for i in ids]) + return JSONResponse({"stages": st.list_stages(project_id=project_id)}) + + +@router.put("/api/projects/{project_id}/stages/{stage_id}") +async def api_update_stage( + request: Request, project_id: int, stage_id: int, payload: dict[str, Any] = Body(...) +) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + _require_manage(request, st, user, project_id) + try: + stage = st.update_stage( + stage_id=stage_id, + name=payload.get("name"), + is_done_stage=payload.get("is_done_stage"), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except KeyError: + raise HTTPException(status_code=404, detail="단계를 찾을 수 없습니다.") + return JSONResponse({"stage": stage}) + + +# ──────────────────────────────────────────────────────────── +# 알림센터 (인앱) +# ──────────────────────────────────────────────────────────── +@router.get("/api/notifications") +async def api_notifications(request: Request) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + unread_only = request.query_params.get("unread") in ("1", "true") + items = st.list_notifications( + recipient_email=user["email"], unread_only=unread_only, limit=100 + ) + return JSONResponse({"notifications": items, "unread": st.count_unread(recipient_email=user["email"])}) + + +@router.get("/api/notifications/unread-count") +async def api_notif_count(request: Request) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + return JSONResponse({"unread": st.count_unread(recipient_email=user["email"])}) + + +@router.post("/api/notifications/{notification_id}/read") +async def api_notif_read(request: Request, notification_id: int) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + st.mark_read(notification_id=notification_id, recipient_email=user["email"]) + return JSONResponse({"ok": True}) + + +@router.post("/api/notifications/read-all") +async def api_notif_read_all(request: Request) -> JSONResponse: + user = _require_user(request) + st = _db_or_503(request) + n = st.mark_all_read(recipient_email=user["email"]) + return JSONResponse({"ok": True, "marked": n}) + + +@router.get("/inbox", response_class=HTMLResponse) +async def inbox_page(request: Request) -> HTMLResponse: + from app.main import build_erp_nav, render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + user = _require_user(request) + st = _store(request) + if st is None: + return _render_config_needed(request, user) + items = st.list_notifications(recipient_email=user["email"], limit=100) + return render_template( + request, + "project/inbox.html", + { + "user": user, + "is_admin": is_admin(user), + "nav_items": build_erp_nav(user, active="project"), + "page_title": "알림센터", + "page_subtitle": "내게 온 프로젝트 알림", + "notifications": items, + }, + ) diff --git a/app/modules/project/templates/project/inbox.html b/app/modules/project/templates/project/inbox.html new file mode 100644 index 0000000..f184022 --- /dev/null +++ b/app/modules/project/templates/project/inbox.html @@ -0,0 +1,49 @@ +{% extends "erp_base.html" %} + +{% block head_extra %} + + +{% endblock %} + +{% block content %} +
=p)break;if(u[f+m]==c)m=1;else if(d[f+v]==c)v=1;else{var g=av(u).call(u,d[f+v]),y=e.get(u[f+m]),b=e.get(d[f+v]);this.options.groupOrderSwap(y,b,e),e.update(y),e.update(b);var _=u[f+m];u[f+m]=d[f+v],u[g]=_,f++}}}}}},{key:"_onGroupDragEnd",value:function(t){if(this.groupTouchParams.isDragging=!1,this.options.groupEditable.order&&this.groupTouchParams.group){t.stopPropagation();var e=this,i=e.groupTouchParams.group.groupId,n=e.groupsData.getDataSet(),r=wE.extend({},n.get(i));e.options.onMoveGroup(r,(function(t){if(t)t[n._idProp]=i,n.update(t);else{var r=n.getIds({order:e.options.groupOrder});if(!wE.equalArray(r,e.groupTouchParams.originalOrder))for(var o=e.groupTouchParams.originalOrder,s=Math.min(o.length,r.length),a=0;a=s)break;var l=av(r).call(r,o[a]),h=n.get(r[a]),u=n.get(o[a]);e.options.groupOrderSwap(h,u,n),n.update(h),n.update(u);var d=r[a];r[a]=o[a],r[l]=d,a++}}})),e.body.emitter.emit("groupDragged",{groupId:i}),this.toggleGroupDragClassName(this.groupTouchParams.group),this.groupTouchParams.group=null}}},{key:"_onSelectItem",value:function(t){if(this.options.selectable){var e=t.srcEvent&&(t.srcEvent.ctrlKey||t.srcEvent.metaKey),i=t.srcEvent&&t.srcEvent.shiftKey;if(e||i)this._onMultiSelectItem(t);else{var n=this.getSelection(),r=this.itemFromTarget(t),o=r&&r.selectable?[r.id]:[];this.setSelection(o);var s=this.getSelection();(s.length>0||n.length>0)&&this.body.emitter.emit("select",{items:s,event:t})}}}},{key:"_onMouseOver",value:function(t){var e=this.itemFromTarget(t);if(e&&e!==this.itemFromRelatedTarget(t)){var i=e.getTitle();if(this.options.showTooltips&&i){null==this.popup&&(this.popup=new IA(this.body.dom.root,this.options.tooltip.overflowMethod||"flip")),this.popup.setText(i);var n=this.body.dom.centerContainer,r=n.getBoundingClientRect();this.popup.setPosition(t.clientX-r.left+n.offsetLeft,t.clientY-r.top+n.offsetTop),this.setPopupTimer(this.popup)}else this.clearPopupTimer(),null!=this.popup&&this.popup.hide();this.body.emitter.emit("itemover",{item:e.id,event:t})}}},{key:"_onMouseOut",value:function(t){var e=this.itemFromTarget(t);e&&(e!==this.itemFromRelatedTarget(t)&&(this.clearPopupTimer(),null!=this.popup&&this.popup.hide(),this.body.emitter.emit("itemout",{item:e.id,event:t})))}},{key:"_onMouseMove",value:function(t){if(this.itemFromTarget(t)&&(null!=this.popupTimer&&this.setPopupTimer(this.popup),this.options.showTooltips&&this.options.tooltip.followMouse&&this.popup&&!this.popup.hidden)){var e=this.body.dom.centerContainer,i=e.getBoundingClientRect();this.popup.setPosition(t.clientX-i.left+e.offsetLeft,t.clientY-i.top+e.offsetTop),this.popup.show()}}},{key:"_onMouseWheel",value:function(t){this.touchParams.itemIsDragging&&this._onDragEnd(t)}},{key:"_onUpdateItem",value:function(t){if(this.options.selectable&&(this.options.editable.updateTime||this.options.editable.updateGroup)){var e=this;if(t){var i=e.itemsData.get(t.id);this.options.onUpdate(i,(function(t){t&&e.itemsData.update(t)}))}}}},{key:"_onDropObjectOnItem",value:function(t){var e=this.itemFromTarget(t),i=JSON.parse(t.dataTransfer.getData("text"));this.options.onDropObjectOnItem(i,e)}},{key:"_onAddItem",value:function(t){if(this.options.selectable&&this.options.editable.add){var e,i,n=this,r=this.options.snap||null,o=this.dom.frame.getBoundingClientRect(),s=this.options.rtl?o.right-t.center.x:t.center.x-o.left,a=this.body.util.toTime(s),l=this.body.util.getScale(),h=this.body.util.getStep();"drop"==t.type?((i=JSON.parse(t.dataTransfer.getData("text"))).content=i.content?i.content:"new item",i.start=i.start?i.start:r?r(a,l,h):a,i.type=i.type||"box",i[this.itemsData.idProp]=i.id||VM(),"range"!=i.type||i.end||(e=this.body.util.toTime(s+this.props.width/5),i.end=r?r(e,l,h):e)):((i={start:r?r(a,l,h):a,content:"new item"})[this.itemsData.idProp]=VM(),"range"===this.options.type&&(e=this.body.util.toTime(s+this.props.width/5),i.end=r?r(e,l,h):e));var u=this.groupFromTarget(t);u&&(i.group=u.groupId),i=this._cloneItemData(i),this.options.onAdd(i,(function(e){e&&(n.itemsData.add(e),"drop"==t.type&&n.setSelection([e.id]))}))}}},{key:"_onMultiSelectItem",value:function(t){var e=this;if(this.options.selectable){var n=this.itemFromTarget(t);if(n){var r=this.options.multiselect?this.getSelection():[];if((t.srcEvent&&t.srcEvent.shiftKey||!1||this.options.sequentialSelection)&&this.options.multiselect){var o=this.itemsData.get(n.id).group,s=void 0;this.options.multiselectPerGroup&&r.length>0&&(s=this.itemsData.get(r[0]).group),this.options.multiselectPerGroup&&null!=s&&s!=o||r.push(n.id);var a=i._getItemRange(this.itemsData.get(r));if(!this.options.multiselectPerGroup||s==o)for(var l in r=[],this.items)if(this.items.hasOwnProperty(l)){var h=this.items[l],u=h.data.start,d=void 0!==h.data.end?h.data.end:u;!(u>=a.min&&d<=a.max)||this.options.multiselectPerGroup&&s!=this.itemsData.get(h.id).group||h instanceof AA||r.push(h.id)}}else{var c=av(r).call(r,n.id);-1==c?r.push(n.id):_f(r).call(r,c,1)}var p=mm(r).call(r,(function(t){return e.getItemById(t).selectable}));this.setSelection(p),this.body.emitter.emit("select",{items:this.getSelection(),event:t})}}}},{key:"itemFromElement",value:function(t){for(var e=t;e;){if(e.hasOwnProperty("vis-item"))return e["vis-item"];e=e.parentNode}return null}},{key:"itemFromTarget",value:function(t){return this.itemFromElement(t.target)}},{key:"itemFromRelatedTarget",value:function(t){return this.itemFromElement(t.relatedTarget)}},{key:"groupFromTarget",value:function(t){var e=t.center?t.center.y:t.clientY,i=this.groupIds;i.length<=0&&this.groupsData&&(i=this.groupsData.getIds({order:this.options.groupOrder}));for(var n=0;n