feat: replace Planka with in-app Kanban + add OMG page
- Remove Planka: docker-compose services, apache /board/ proxy, env vars, custom CSS dir
- Add Kanban board at /tasks: 4 columns (To Do / Doing / Testing / Done),
native HTML5 drag-and-drop, card modal (TaskForm reuse), per-column "+" button
- Add 'testing' status to Task model validator and frontend union type
- Add GET /api/tasks/{id} endpoint (was missing, frontend already called it)
- Enrich DevOps clone: live-fetches description, AC, assignee, iteration,
comments and attachments from ADO; renders as Markdown in task.notes
- Add /omg page: standalone project/client/job# registry with inline editing
and create/edit/delete dialog; backed by new omg_entries table (migration 0008)
- Add omg router to main.py; add OMG + Tasks to sidebar and router
- Fix dead /planner link on Dashboard -> /tasks
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7c15f884c1
commit
36118cb759
63 changed files with 1036 additions and 114 deletions
|
|
@ -37,7 +37,3 @@ REPORT_EMAIL=you@example.com
|
|||
DAILY_REPORT_HOUR=20
|
||||
WEEKLY_REPORT_DAY=6
|
||||
WEEKLY_REPORT_HOUR=21
|
||||
|
||||
# Planka kanban board (run: openssl rand -hex 64)
|
||||
PLANKA_SECRET_KEY=changeme-use-openssl-rand-hex-64
|
||||
PLANKA_BASE_URL=https://optical-dev.oliver.solutions/board
|
||||
|
|
|
|||
34
alembic/versions/0008_kanban_and_omg.py
Normal file
34
alembic/versions/0008_kanban_and_omg.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Add omg_entries table; kanban testing status is string-only, no schema change needed.
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-05-07
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0008"
|
||||
down_revision = "0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"omg_entries",
|
||||
sa.Column("id", UUID(as_uuid=False), primary_key=True),
|
||||
sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("client", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("job_number", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("notes", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_omg_entries_user_id", "omg_entries", ["user_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_omg_entries_user_id", table_name="omg_entries")
|
||||
op.drop_table("omg_entries")
|
||||
|
|
@ -22,12 +22,3 @@
|
|||
RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
</Location>
|
||||
|
||||
# Planka kanban board
|
||||
<Location /board/>
|
||||
ProxyPreserveHost On
|
||||
ProxyPass http://127.0.0.1:1337/
|
||||
ProxyPassReverse http://127.0.0.1:1337/
|
||||
RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
</Location>
|
||||
|
|
|
|||
|
|
@ -26,48 +26,5 @@ services:
|
|||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
planka:
|
||||
image: ghcr.io/plankanban/planka:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1337:1337"
|
||||
environment:
|
||||
BASE_URL: ${PLANKA_BASE_URL:-https://optical-dev.oliver.solutions/board}
|
||||
DATABASE_URL: postgresql://planka:planka@planka-db:5432/planka
|
||||
SECRET_KEY: ${PLANKA_SECRET_KEY}
|
||||
TRUST_PROXY: "1"
|
||||
DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL:-}
|
||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-}
|
||||
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
CUSTOM_UI_OVERRIDE_STYLESHEETS: /app/public/custom/planka.css
|
||||
volumes:
|
||||
- planka-avatars:/app/public/user-avatars
|
||||
- planka-backgrounds:/app/public/project-background-images
|
||||
- planka-attachments:/app/private/attachments
|
||||
- ./planka-custom:/app/public/custom:ro
|
||||
depends_on:
|
||||
planka-db:
|
||||
condition: service_healthy
|
||||
|
||||
planka-db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: planka
|
||||
POSTGRES_USER: planka
|
||||
POSTGRES_PASSWORD: planka
|
||||
volumes:
|
||||
- planka-db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U planka -d planka"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
planka-db-data:
|
||||
planka-avatars:
|
||||
planka-backgrounds:
|
||||
planka-attachments:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ def _ensure_static_dir() -> None:
|
|||
)
|
||||
from src.middleware.logging import LoggingMiddleware
|
||||
from src.routers import admin, auth, dashboard, events, ingest, keys, projects
|
||||
from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports
|
||||
from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports, omg
|
||||
from src.services.scheduler import scheduler, setup_scheduler
|
||||
|
||||
BASE = settings.BASE_PATH
|
||||
|
|
@ -91,6 +91,7 @@ for router in [
|
|||
devops.router,
|
||||
exports.router,
|
||||
reports.router,
|
||||
omg.router,
|
||||
]:
|
||||
app.include_router(router)
|
||||
|
||||
|
|
|
|||
|
|
@ -308,6 +308,21 @@ class AiFlag(Base):
|
|||
user: Mapped["User"] = relationship(foreign_keys=[user_id])
|
||||
|
||||
|
||||
class OmgEntry(Base):
|
||||
__tablename__ = "omg_entries"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
|
||||
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
client: Mapped[str] = mapped_column(String(255), default="")
|
||||
job_number: Mapped[str] = mapped_column(String(100), default="")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id])
|
||||
|
||||
|
||||
class AssistantMessage(Base):
|
||||
"""Persisted chat history for the AI assistant per user."""
|
||||
__tablename__ = "assistant_messages"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from src.auth import CurrentUser
|
|||
from src.database import get_db
|
||||
from src.models import AzureIntegration, AzureWorkItem, Task
|
||||
from src.schemas import AzureIntegrationIn, AzureIntegrationOut, AzureWorkItemOut, SyncReport, TaskOut
|
||||
from src.services.crypto import encrypt
|
||||
from src.services.crypto import decrypt, encrypt
|
||||
|
||||
router = APIRouter(prefix="/api/devops", tags=["devops"])
|
||||
|
||||
|
|
@ -122,10 +122,49 @@ async def clone_work_item_to_task(
|
|||
if not wi or wi.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Work item not found")
|
||||
|
||||
# Load integration to get live ADO content (description, AC, comments, attachments)
|
||||
integ_result = await db.execute(
|
||||
select(AzureIntegration).where(AzureIntegration.user_id == user.id)
|
||||
)
|
||||
integ = integ_result.scalar_one_or_none()
|
||||
|
||||
notes = ""
|
||||
if integ:
|
||||
try:
|
||||
from src.services.azure_devops.client import ADOClient
|
||||
from src.services.azure_devops.format import render_notes_markdown
|
||||
|
||||
pat = decrypt(integ.pat_encrypted)
|
||||
client = ADOClient(org=integ.organization, project=integ.project, pat=pat)
|
||||
detail = await client.get_work_item(wi.ado_id)
|
||||
fields = detail.get("fields", {})
|
||||
relations = detail.get("relations") or []
|
||||
|
||||
description_html = fields.get("System.Description", "") or ""
|
||||
ac_html = fields.get("Microsoft.VSTS.Common.AcceptanceCriteria", "") or ""
|
||||
assignee_obj = fields.get("System.AssignedTo") or {}
|
||||
assignee = assignee_obj.get("displayName", "") if isinstance(assignee_obj, dict) else str(assignee_obj)
|
||||
iteration = fields.get("System.IterationPath", "") or ""
|
||||
|
||||
comments = await client.get_work_item_comments(wi.ado_id)
|
||||
attachments = [r for r in relations if r.get("rel") == "AttachedFile"]
|
||||
|
||||
notes = render_notes_markdown(
|
||||
description_html=description_html,
|
||||
ac_html=ac_html,
|
||||
assignee=assignee,
|
||||
iteration=iteration,
|
||||
comments=comments,
|
||||
attachments=attachments,
|
||||
wi_url=wi.url,
|
||||
)
|
||||
except Exception:
|
||||
pass # Fall through with empty notes if ADO call fails
|
||||
|
||||
task = Task(
|
||||
user_id=user.id,
|
||||
title=wi.title,
|
||||
notes="",
|
||||
notes=notes,
|
||||
planned_date=date.today(),
|
||||
estimate_hours=0.0,
|
||||
status="todo",
|
||||
|
|
|
|||
70
src/routers/omg.py
Normal file
70
src/routers/omg.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""OMG Entries — simple project/client/job registry."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import OmgEntry
|
||||
from src.schemas import OmgEntryIn, OmgEntryOut, OmgEntryUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/omg", tags=["omg"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[OmgEntryOut])
|
||||
async def list_omg_entries(user: CurrentUser, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(OmgEntry).where(OmgEntry.user_id == user.id).order_by(OmgEntry.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=OmgEntryOut, status_code=201)
|
||||
async def create_omg_entry(
|
||||
body: OmgEntryIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
entry = OmgEntry(
|
||||
user_id=user.id,
|
||||
name=body.name,
|
||||
client=body.client,
|
||||
job_number=body.job_number,
|
||||
notes=body.notes,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return OmgEntryOut.model_validate(entry)
|
||||
|
||||
|
||||
@router.patch("/{entry_id}", response_model=OmgEntryOut)
|
||||
async def update_omg_entry(
|
||||
entry_id: str,
|
||||
body: OmgEntryUpdate,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
entry = await db.get(OmgEntry, entry_id)
|
||||
if not entry or entry.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(entry, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return OmgEntryOut.model_validate(entry)
|
||||
|
||||
|
||||
@router.delete("/{entry_id}", status_code=204)
|
||||
async def delete_omg_entry(
|
||||
entry_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
entry = await db.get(OmgEntry, entry_id)
|
||||
if not entry or entry.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
|
|
@ -130,6 +130,18 @@ async def create_task(
|
|||
return await _task_out(task, db)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskOut)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.get(Task, task_id)
|
||||
if not task or task.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return await _task_out(task, db)
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskOut)
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ class TaskIn(BaseModel):
|
|||
notes: str = ""
|
||||
planned_date: date
|
||||
estimate_hours: float = Field(default=0.0, ge=0)
|
||||
status: str = Field(default="todo", pattern="^(todo|doing|done|cancelled)$")
|
||||
status: str = Field(default="todo", pattern="^(todo|doing|testing|done|cancelled)$")
|
||||
priority: int = Field(default=3, ge=1, le=5)
|
||||
sort_index: int = 0
|
||||
project_id: str | None = None
|
||||
|
|
@ -237,7 +237,7 @@ class TaskUpdate(BaseModel):
|
|||
notes: str | None = None
|
||||
planned_date: date | None = None
|
||||
estimate_hours: float | None = Field(default=None, ge=0)
|
||||
status: str | None = Field(default=None, pattern="^(todo|doing|done|cancelled)$")
|
||||
status: str | None = Field(default=None, pattern="^(todo|doing|testing|done|cancelled)$")
|
||||
priority: int | None = Field(default=None, ge=1, le=5)
|
||||
sort_index: int | None = None
|
||||
project_id: str | None = None
|
||||
|
|
@ -441,3 +441,31 @@ class AssistantChatIn(BaseModel):
|
|||
|
||||
class SessionCategoryIn(BaseModel):
|
||||
category: str | None = Field(default=None, pattern="^(coding|thinking|deployment|meeting|review|other)?$")
|
||||
|
||||
|
||||
# ── OMG Entries ───────────────────────────────────────────────────────────────
|
||||
|
||||
class OmgEntryIn(BaseModel):
|
||||
name: str = Field(max_length=255)
|
||||
client: str = ""
|
||||
job_number: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class OmgEntryUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
client: str | None = None
|
||||
job_number: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class OmgEntryOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
client: str
|
||||
job_number: str
|
||||
notes: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
|
|||
70
src/services/azure_devops/format.py
Normal file
70
src/services/azure_devops/format.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Format Azure DevOps work item content as Markdown for task notes."""
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _strip_html(html: str) -> str:
|
||||
if not html:
|
||||
return ""
|
||||
text = re.sub(r"<br\s*/?>", "\n", html, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"</p>", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<li[^>]*>", "\n- ", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = re.sub(r" ", " ", text)
|
||||
text = re.sub(r"<", "<", text)
|
||||
text = re.sub(r">", ">", text)
|
||||
text = re.sub(r"&", "&", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def render_notes_markdown(
|
||||
description_html: str,
|
||||
ac_html: str,
|
||||
assignee: str,
|
||||
iteration: str,
|
||||
comments: list[dict[str, Any]],
|
||||
attachments: list[dict[str, Any]],
|
||||
wi_url: str,
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
|
||||
desc = _strip_html(description_html)
|
||||
if desc:
|
||||
parts.append(f"## Description\n\n{desc}")
|
||||
|
||||
ac = _strip_html(ac_html)
|
||||
if ac:
|
||||
parts.append(f"## Acceptance Criteria\n\n{ac}")
|
||||
|
||||
meta_lines = []
|
||||
if assignee:
|
||||
meta_lines.append(f"**Assignee:** {assignee}")
|
||||
if iteration:
|
||||
meta_lines.append(f"**Iteration:** {iteration}")
|
||||
if meta_lines:
|
||||
parts.append("\n".join(meta_lines))
|
||||
|
||||
if comments:
|
||||
comment_lines = [f"## Comments ({len(comments)})"]
|
||||
for c in comments[:10]:
|
||||
author = (c.get("createdBy") or {}).get("displayName", "Unknown")
|
||||
date = (c.get("createdDate") or "")[:10]
|
||||
text = _strip_html(c.get("text") or "")
|
||||
text_preview = text[:200].replace("\n", " ")
|
||||
comment_lines.append(f"- **{date} · {author}:** {text_preview}")
|
||||
parts.append("\n".join(comment_lines))
|
||||
|
||||
if attachments:
|
||||
att_lines = [f"## Attachments ({len(attachments)})"]
|
||||
for a in attachments:
|
||||
name = (a.get("attributes") or {}).get("name", "file")
|
||||
url = a.get("url", "")
|
||||
att_lines.append(f"- [{name}]({url})")
|
||||
parts.append("\n".join(att_lines))
|
||||
|
||||
if wi_url:
|
||||
parts.append(f"[Open in Azure DevOps]({wi_url})")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
|
@ -1 +1 @@
|
|||
import{d as p,u as y,y as h,c as r,a as t,e as n,k as v,w as d,f as b,s as m,o as s,F as g,r as k,t as a,q as u,h as A}from"./index-DxmLkgMg.js";import{a as w}from"./admin-Hqgc_gdo.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-gLtslFdL.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-CVzGyv6L.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-DMwaztwt.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6"},C={key:0,class:"flex items-center justify-center h-20"},D={class:"w-full"},E={class:"px-4 py-3"},F={class:"text-sm font-medium text-foreground"},R={class:"px-4 py-3 text-sm text-muted-foreground"},U={class:"px-4 py-3"},j={class:"px-4 py-3"},q={class:"px-4 py-3 text-xs text-muted-foreground"},H=p({__name:"AdminView",setup(I){const x=y(),_=b(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){_.push("/");return}l.value=!0;try{const c=await w.users();i.value=c.data}finally{l.value=!1}}),(c,o)=>(s(),r("div",N,[o[1]||(o[1]=t("h2",{class:"text-lg font-semibold text-foreground mb-6"},"Admin — Users",-1)),l.value?(s(),r("div",C,[n(V,{class:"text-primary"})])):(s(),v(B,{key:1},{default:d(()=>[n(S,{class:"p-0"},{default:d(()=>[t("table",D,[o[0]||(o[0]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"User"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Email"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Role"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Status"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Joined")])],-1)),t("tbody",null,[(s(!0),r(g,null,k(i.value,e=>(s(),r("tr",{key:e.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",E,[t("p",F,a(e.username),1)]),t("td",R,a(e.email),1),t("td",U,[n(f,{variant:e.role==="admin"?"default":"secondary",class:"text-xs"},{default:d(()=>[u(a(e.role),1)]),_:2},1032,["variant"])]),t("td",j,[n(f,{variant:e.is_active?"success":"outline",class:"text-xs"},{default:d(()=>[u(a(e.is_active?"Active":"Inactive"),1)]),_:2},1032,["variant"])]),t("td",q,a(A($)(e.created_at)),1)]))),128))])])]),_:1})]),_:1}))]))}});export{H as default};
|
||||
import{d as p,u as y,y as h,c as r,a as t,e as n,k as v,w as d,f as b,s as m,o as s,F as g,r as k,t as a,q as u,h as A}from"./index-DMlmI4VG.js";import{a as w}from"./admin-DvZ7jcBF.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-B5oRrbOE.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-B0XCy3Qk.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-CxhsEqE4.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6"},C={key:0,class:"flex items-center justify-center h-20"},D={class:"w-full"},E={class:"px-4 py-3"},F={class:"text-sm font-medium text-foreground"},R={class:"px-4 py-3 text-sm text-muted-foreground"},U={class:"px-4 py-3"},j={class:"px-4 py-3"},q={class:"px-4 py-3 text-xs text-muted-foreground"},H=p({__name:"AdminView",setup(I){const x=y(),_=b(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){_.push("/");return}l.value=!0;try{const c=await w.users();i.value=c.data}finally{l.value=!1}}),(c,o)=>(s(),r("div",N,[o[1]||(o[1]=t("h2",{class:"text-lg font-semibold text-foreground mb-6"},"Admin — Users",-1)),l.value?(s(),r("div",C,[n(V,{class:"text-primary"})])):(s(),v(B,{key:1},{default:d(()=>[n(S,{class:"p-0"},{default:d(()=>[t("table",D,[o[0]||(o[0]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"User"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Email"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Role"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Status"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Joined")])],-1)),t("tbody",null,[(s(!0),r(g,null,k(i.value,e=>(s(),r("tr",{key:e.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",E,[t("p",F,a(e.username),1)]),t("td",R,a(e.email),1),t("td",U,[n(f,{variant:e.role==="admin"?"default":"secondary",class:"text-xs"},{default:d(()=>[u(a(e.role),1)]),_:2},1032,["variant"])]),t("td",j,[n(f,{variant:e.is_active?"success":"outline",class:"text-xs"},{default:d(()=>[u(a(e.is_active?"Active":"Inactive"),1)]),_:2},1032,["variant"])]),t("td",q,a(A($)(e.created_at)),1)]))),128))])])]),_:1})]),_:1}))]))}});export{H as default};
|
||||
1
src/static/assets/AppLayout-B0UoMuf7.js
Normal file
1
src/static/assets/AppLayout-B0UoMuf7.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{c as a}from"./utils-7WVCegLb.js";import{d as n,o,c as s,n as d,h as i,p as c}from"./index-DxmLkgMg.js";const f=n({__name:"Badge",props:{variant:{default:"default"},class:{}},setup(r){const e=r;return(t,l)=>(o(),s("span",{class:d(i(a)("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",{"bg-primary text-primary-foreground":e.variant==="default","bg-secondary text-secondary-foreground":e.variant==="secondary","bg-destructive text-destructive-foreground":e.variant==="destructive","border border-border text-foreground":e.variant==="outline","bg-emerald-500/20 text-emerald-400":e.variant==="success","bg-amber-500/20 text-amber-400":e.variant==="warning"},e.class))},[c(t.$slots,"default")],2))}});export{f as _};
|
||||
import{c as a}from"./utils-7WVCegLb.js";import{d as n,o,c as s,n as d,h as i,p as c}from"./index-DMlmI4VG.js";const f=n({__name:"Badge",props:{variant:{default:"default"},class:{}},setup(r){const e=r;return(t,l)=>(o(),s("span",{class:d(i(a)("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",{"bg-primary text-primary-foreground":e.variant==="default","bg-secondary text-secondary-foreground":e.variant==="secondary","bg-destructive text-destructive-foreground":e.variant==="destructive","border border-border text-foreground":e.variant==="outline","bg-emerald-500/20 text-emerald-400":e.variant==="success","bg-amber-500/20 text-amber-400":e.variant==="warning"},e.class))},[c(t.$slots,"default")],2))}});export{f as _};
|
||||
|
|
@ -1 +1 @@
|
|||
import{_ as c}from"./Spinner.vue_vue_type_script_setup_true_lang-DMwaztwt.js";import{c as l}from"./utils-7WVCegLb.js";import{d as u,c as f,n as m,k as b,i as v,p as g,j as p,o as n}from"./index-DxmLkgMg.js";const y=["type","disabled"],z=u({__name:"Button",props:{variant:{default:"default"},size:{default:"md"},loading:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},type:{default:"button"},class:{}},emits:["click"],setup(t,{emit:s}){const e=t,a=s,r=p(()=>l("inline-flex items-center justify-center rounded-md font-medium transition-colors","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:pointer-events-none disabled:opacity-50",{"bg-primary text-primary-foreground hover:bg-primary/90":e.variant==="default","border border-input bg-background hover:bg-accent hover:text-accent-foreground":e.variant==="outline","hover:bg-accent hover:text-accent-foreground":e.variant==="ghost","bg-destructive text-destructive-foreground hover:bg-destructive/90":e.variant==="destructive","bg-secondary text-secondary-foreground hover:bg-secondary/80":e.variant==="secondary","underline-offset-4 hover:underline text-primary":e.variant==="link","h-8 px-3 text-xs":e.size==="sm","h-10 px-4 py-2 text-sm":e.size==="md","h-11 px-8 text-base":e.size==="lg","h-9 w-9 p-0":e.size==="icon"},e.class));return(i,o)=>(n(),f("button",{class:m(r.value),type:t.type,disabled:t.disabled||t.loading,onClick:o[0]||(o[0]=d=>a("click",d))},[t.loading?(n(),b(c,{key:0,size:"sm",class:"mr-2"})):v("",!0),g(i.$slots,"default")],10,y))}});export{z as _};
|
||||
import{_ as c}from"./Spinner.vue_vue_type_script_setup_true_lang-CxhsEqE4.js";import{c as l}from"./utils-7WVCegLb.js";import{d as u,c as f,n as m,k as b,i as v,p as g,j as p,o as n}from"./index-DMlmI4VG.js";const y=["type","disabled"],z=u({__name:"Button",props:{variant:{default:"default"},size:{default:"md"},loading:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},type:{default:"button"},class:{}},emits:["click"],setup(t,{emit:s}){const e=t,a=s,r=p(()=>l("inline-flex items-center justify-center rounded-md font-medium transition-colors","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:pointer-events-none disabled:opacity-50",{"bg-primary text-primary-foreground hover:bg-primary/90":e.variant==="default","border border-input bg-background hover:bg-accent hover:text-accent-foreground":e.variant==="outline","hover:bg-accent hover:text-accent-foreground":e.variant==="ghost","bg-destructive text-destructive-foreground hover:bg-destructive/90":e.variant==="destructive","bg-secondary text-secondary-foreground hover:bg-secondary/80":e.variant==="secondary","underline-offset-4 hover:underline text-primary":e.variant==="link","h-8 px-3 text-xs":e.size==="sm","h-10 px-4 py-2 text-sm":e.size==="md","h-11 px-8 text-base":e.size==="lg","h-9 w-9 p-0":e.size==="icon"},e.class));return(i,o)=>(n(),f("button",{class:m(r.value),type:t.type,disabled:t.disabled||t.loading,onClick:o[0]||(o[0]=d=>a("click",d))},[t.loading?(n(),b(c,{key:0,size:"sm",class:"mr-2"})):v("",!0),g(i.$slots,"default")],10,y))}});export{z as _};
|
||||
File diff suppressed because one or more lines are too long
2
src/static/assets/CalendarView-sdfXfQnJ.js
Normal file
2
src/static/assets/CalendarView-sdfXfQnJ.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{c as e}from"./utils-7WVCegLb.js";import{d as o,c as n,n as t,h as c,p,o as l}from"./index-DxmLkgMg.js";const _=o({__name:"Card",props:{class:{}},setup(s){const a=s;return(r,d)=>(l(),n("div",{class:t(c(e)("rounded-lg border bg-card text-card-foreground shadow-sm",a.class))},[p(r.$slots,"default")],2))}}),f=o({__name:"CardContent",props:{class:{}},setup(s){const a=s;return(r,d)=>(l(),n("div",{class:t(c(e)("p-6 pt-0",a.class))},[p(r.$slots,"default")],2))}});export{_,f as a};
|
||||
import{c as e}from"./utils-7WVCegLb.js";import{d as o,c as n,n as t,h as c,p,o as l}from"./index-DMlmI4VG.js";const _=o({__name:"Card",props:{class:{}},setup(s){const a=s;return(r,d)=>(l(),n("div",{class:t(c(e)("rounded-lg border bg-card text-card-foreground shadow-sm",a.class))},[p(r.$slots,"default")],2))}}),f=o({__name:"CardContent",props:{class:{}},setup(s){const a=s;return(r,d)=>(l(),n("div",{class:t(c(e)("p-6 pt-0",a.class))},[p(r.$slots,"default")],2))}});export{_,f as a};
|
||||
|
|
@ -1 +1 @@
|
|||
import{c as t}from"./utils-7WVCegLb.js";import{d as n,o,c as r,n as c,h as l,p}from"./index-DxmLkgMg.js";const f=n({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(a,i)=>(o(),r("div",{class:c(l(t)("flex flex-col space-y-1.5 p-6",e.class))},[p(a.$slots,"default")],2))}}),_=n({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(a,i)=>(o(),r("h3",{class:c(l(t)("text-lg font-semibold leading-none tracking-tight",e.class))},[p(a.$slots,"default")],2))}});export{f as _,_ as a};
|
||||
import{c as t}from"./utils-7WVCegLb.js";import{d as n,o,c as r,n as c,h as l,p}from"./index-DMlmI4VG.js";const f=n({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(a,i)=>(o(),r("div",{class:c(l(t)("flex flex-col space-y-1.5 p-6",e.class))},[p(a.$slots,"default")],2))}}),_=n({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(a,i)=>(o(),r("h3",{class:c(l(t)("text-lg font-semibold leading-none tracking-tight",e.class))},[p(a.$slots,"default")],2))}});export{f as _,_ as a};
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{u as D}from"./devops-CwVu4BME.js";import{_}from"./Input.vue_vue_type_script_setup_true_lang-Gu5Qa2C5.js";import{_ as V}from"./Button.vue_vue_type_script_setup_true_lang-C49zRtnB.js";import{d as j,s as c,o as i,c as m,h as a,a as o,q as g,t as d,i as p,e as v,w as k,k as z,K as u}from"./index-DxmLkgMg.js";const B={class:"space-y-4"},I={key:0,class:"text-xs text-muted-foreground space-y-1"},N={class:"text-foreground"},P={class:"text-foreground"},S={key:0},U={key:1,class:"text-red-400"},b={class:"grid grid-cols-2 gap-3"},A={class:"space-y-1.5"},F={class:"space-y-1.5"},O={class:"space-y-1.5"},q={class:"flex items-center gap-2"},G=j({__name:"DevopsConnectForm",setup(E){var y,x;const t=D(),n=c(((y=t.integration)==null?void 0:y.organization)??""),r=c(((x=t.integration)==null?void 0:x.project)??""),s=c(""),f=c(!1);async function w(){if(!n.value||!r.value||!s.value){u.error("All fields are required");return}f.value=!0;try{await t.saveIntegration({organization:n.value,project:r.value,pat:s.value}),s.value="",u.success("Integration saved")}catch{u.error("Failed to save integration")}finally{f.value=!1}}async function C(){if(confirm("Delete ADO integration?"))try{await t.deleteIntegration(),n.value="",r.value="",s.value="",u.success("Integration deleted")}catch{u.error("Failed to delete integration")}}return(K,e)=>(i(),m("div",B,[a(t).integration?(i(),m("div",I,[o("p",null,[e[3]||(e[3]=g(" Connected to ",-1)),o("strong",N,d(a(t).integration.organization),1),e[4]||(e[4]=g(" / ",-1)),o("strong",P,d(a(t).integration.project),1)]),a(t).integration.last_synced_at?(i(),m("p",S," Last synced: "+d(new Date(a(t).integration.last_synced_at).toLocaleString()),1)):p("",!0),a(t).integration.last_sync_error?(i(),m("p",U," Error: "+d(a(t).integration.last_sync_error),1)):p("",!0)])):p("",!0),o("div",b,[o("div",A,[e[5]||(e[5]=o("label",{class:"text-sm font-medium text-foreground"},"Organization",-1)),v(_,{modelValue:n.value,"onUpdate:modelValue":e[0]||(e[0]=l=>n.value=l),placeholder:"myorg"},null,8,["modelValue"])]),o("div",F,[e[6]||(e[6]=o("label",{class:"text-sm font-medium text-foreground"},"Project",-1)),v(_,{modelValue:r.value,"onUpdate:modelValue":e[1]||(e[1]=l=>r.value=l),placeholder:"myproject"},null,8,["modelValue"])])]),o("div",O,[e[7]||(e[7]=o("label",{class:"text-sm font-medium text-foreground"}," Personal Access Token ",-1)),v(_,{modelValue:s.value,"onUpdate:modelValue":e[2]||(e[2]=l=>s.value=l),type:"password",placeholder:"••••••••",autocomplete:"new-password"},null,8,["modelValue"])]),o("div",q,[v(V,{loading:f.value,onClick:w},{default:k(()=>[g(d(a(t).integration?"Update":"Connect"),1)]),_:1},8,["loading"]),a(t).integration?(i(),z(V,{key:0,variant:"destructive",size:"sm",onClick:C},{default:k(()=>[...e[8]||(e[8]=[g(" Disconnect ",-1)])]),_:1})):p("",!0)])]))}});export{G as _};
|
||||
import{u as D}from"./devops-roMxSiNP.js";import{_}from"./Input.vue_vue_type_script_setup_true_lang-TcALPBvs.js";import{_ as V}from"./Button.vue_vue_type_script_setup_true_lang-B00Be5tl.js";import{d as j,s as c,o as i,c as m,h as a,a as o,q as g,t as d,i as p,e as v,w as k,k as z,K as u}from"./index-DMlmI4VG.js";const B={class:"space-y-4"},I={key:0,class:"text-xs text-muted-foreground space-y-1"},N={class:"text-foreground"},P={class:"text-foreground"},S={key:0},U={key:1,class:"text-red-400"},b={class:"grid grid-cols-2 gap-3"},A={class:"space-y-1.5"},F={class:"space-y-1.5"},O={class:"space-y-1.5"},q={class:"flex items-center gap-2"},G=j({__name:"DevopsConnectForm",setup(E){var y,x;const t=D(),n=c(((y=t.integration)==null?void 0:y.organization)??""),r=c(((x=t.integration)==null?void 0:x.project)??""),s=c(""),f=c(!1);async function w(){if(!n.value||!r.value||!s.value){u.error("All fields are required");return}f.value=!0;try{await t.saveIntegration({organization:n.value,project:r.value,pat:s.value}),s.value="",u.success("Integration saved")}catch{u.error("Failed to save integration")}finally{f.value=!1}}async function C(){if(confirm("Delete ADO integration?"))try{await t.deleteIntegration(),n.value="",r.value="",s.value="",u.success("Integration deleted")}catch{u.error("Failed to delete integration")}}return(K,e)=>(i(),m("div",B,[a(t).integration?(i(),m("div",I,[o("p",null,[e[3]||(e[3]=g(" Connected to ",-1)),o("strong",N,d(a(t).integration.organization),1),e[4]||(e[4]=g(" / ",-1)),o("strong",P,d(a(t).integration.project),1)]),a(t).integration.last_synced_at?(i(),m("p",S," Last synced: "+d(new Date(a(t).integration.last_synced_at).toLocaleString()),1)):p("",!0),a(t).integration.last_sync_error?(i(),m("p",U," Error: "+d(a(t).integration.last_sync_error),1)):p("",!0)])):p("",!0),o("div",b,[o("div",A,[e[5]||(e[5]=o("label",{class:"text-sm font-medium text-foreground"},"Organization",-1)),v(_,{modelValue:n.value,"onUpdate:modelValue":e[0]||(e[0]=l=>n.value=l),placeholder:"myorg"},null,8,["modelValue"])]),o("div",F,[e[6]||(e[6]=o("label",{class:"text-sm font-medium text-foreground"},"Project",-1)),v(_,{modelValue:r.value,"onUpdate:modelValue":e[1]||(e[1]=l=>r.value=l),placeholder:"myproject"},null,8,["modelValue"])])]),o("div",O,[e[7]||(e[7]=o("label",{class:"text-sm font-medium text-foreground"}," Personal Access Token ",-1)),v(_,{modelValue:s.value,"onUpdate:modelValue":e[2]||(e[2]=l=>s.value=l),type:"password",placeholder:"••••••••",autocomplete:"new-password"},null,8,["modelValue"])]),o("div",q,[v(V,{loading:f.value,onClick:w},{default:k(()=>[g(d(a(t).integration?"Update":"Connect"),1)]),_:1},8,["loading"]),a(t).integration?(i(),z(V,{key:0,variant:"destructive",size:"sm",onClick:C},{default:k(()=>[...e[8]||(e[8]=[g(" Disconnect ",-1)])]),_:1})):p("",!0)])]))}});export{G as _};
|
||||
1
src/static/assets/DevopsView-CBi4sEOM.js
Normal file
1
src/static/assets/DevopsView-CBi4sEOM.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{d as y,y as k,H as b,k as h,I as g,e as c,T as x,w as u,o as a,c as n,a as o,p as r,t as m,i,n as w}from"./index-DxmLkgMg.js";import{_ as $}from"./Button.vue_vue_type_script_setup_true_lang-C49zRtnB.js";const C={key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4"},B=["aria-label"],j={key:0,class:"flex items-center justify-between p-6 pb-4"},z={class:"text-lg font-semibold text-foreground"},E={key:0,class:"text-sm text-muted-foreground mt-1"},L={class:"px-6 pb-4"},M={key:1,class:"flex justify-end gap-2 px-6 pb-6"},V=y({__name:"Dialog",props:{open:{type:Boolean},title:{},description:{},maxWidth:{default:"max-w-lg"}},emits:["close"],setup(e,{emit:f}){const p=e,l=f;function d(t){t.key==="Escape"&&p.open&&l("close")}return k(()=>document.addEventListener("keydown",d)),b(()=>document.removeEventListener("keydown",d)),(t,s)=>(a(),h(g,{to:"body"},[c(x,{"enter-active-class":"transition-opacity duration-200","enter-from-class":"opacity-0","enter-to-class":"opacity-100","leave-active-class":"transition-opacity duration-200","leave-from-class":"opacity-100","leave-to-class":"opacity-0"},{default:u(()=>[e.open?(a(),n("div",C,[o("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:s[0]||(s[0]=v=>l("close"))}),o("div",{class:w(["relative w-full bg-card border border-border rounded-lg shadow-xl z-10",e.maxWidth]),role:"dialog","aria-modal":!0,"aria-label":e.title},[e.title||t.$slots.header?(a(),n("div",j,[o("div",null,[r(t.$slots,"header",{},()=>[o("h2",z,m(e.title),1),e.description?(a(),n("p",E,m(e.description),1)):i("",!0)])]),c($,{variant:"ghost",size:"icon",class:"shrink-0",onClick:s[1]||(s[1]=v=>l("close"))},{default:u(()=>[...s[2]||(s[2]=[o("svg",{class:"h-4 w-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])]),_:1})])):i("",!0),o("div",L,[r(t.$slots,"default")]),t.$slots.footer?(a(),n("div",M,[r(t.$slots,"footer")])):i("",!0)],10,B)])):i("",!0)]),_:3})]))}});export{V as _};
|
||||
import{d as y,y as k,I as b,k as h,J as g,e as c,T as x,w as u,o as a,c as n,a as o,p as r,t as m,i,n as w}from"./index-DMlmI4VG.js";import{_ as $}from"./Button.vue_vue_type_script_setup_true_lang-B00Be5tl.js";const C={key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4"},B=["aria-label"],j={key:0,class:"flex items-center justify-between p-6 pb-4"},z={class:"text-lg font-semibold text-foreground"},E={key:0,class:"text-sm text-muted-foreground mt-1"},L={class:"px-6 pb-4"},M={key:1,class:"flex justify-end gap-2 px-6 pb-6"},V=y({__name:"Dialog",props:{open:{type:Boolean},title:{},description:{},maxWidth:{default:"max-w-lg"}},emits:["close"],setup(e,{emit:f}){const p=e,l=f;function d(t){t.key==="Escape"&&p.open&&l("close")}return k(()=>document.addEventListener("keydown",d)),b(()=>document.removeEventListener("keydown",d)),(t,s)=>(a(),h(g,{to:"body"},[c(x,{"enter-active-class":"transition-opacity duration-200","enter-from-class":"opacity-0","enter-to-class":"opacity-100","leave-active-class":"transition-opacity duration-200","leave-from-class":"opacity-100","leave-to-class":"opacity-0"},{default:u(()=>[e.open?(a(),n("div",C,[o("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:s[0]||(s[0]=v=>l("close"))}),o("div",{class:w(["relative w-full bg-card border border-border rounded-lg shadow-xl z-10",e.maxWidth]),role:"dialog","aria-modal":!0,"aria-label":e.title},[e.title||t.$slots.header?(a(),n("div",j,[o("div",null,[r(t.$slots,"header",{},()=>[o("h2",z,m(e.title),1),e.description?(a(),n("p",E,m(e.description),1)):i("",!0)])]),c($,{variant:"ghost",size:"icon",class:"shrink-0",onClick:s[1]||(s[1]=v=>l("close"))},{default:u(()=>[...s[2]||(s[2]=[o("svg",{class:"h-4 w-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])]),_:1})])):i("",!0),o("div",L,[r(t.$slots,"default")]),t.$slots.footer?(a(),n("div",M,[r(t.$slots,"footer")])):i("",!0)],10,B)])):i("",!0)]),_:3})]))}});export{V as _};
|
||||
|
|
@ -1 +1 @@
|
|||
import{c as i}from"./utils-7WVCegLb.js";import{d,o as s,c as u,n as m,h as r}from"./index-DxmLkgMg.js";const c=["id","name","type","value","placeholder","disabled","autocomplete","min","max","step"],g=d({__name:"Input",props:{modelValue:{},type:{},placeholder:{},disabled:{type:Boolean},class:{},id:{},name:{},autocomplete:{},min:{},max:{},step:{}},emits:["update:modelValue","change","blur","focus"],setup(e,{emit:n}){const a=e,o=n;return(f,t)=>(s(),u("input",{id:e.id,name:e.name,type:e.type??"text",value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,autocomplete:e.autocomplete,min:e.min,max:e.max,step:e.step,class:m(r(i)("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm","ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium","placeholder:text-muted-foreground","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-50",a.class)),onInput:t[0]||(t[0]=l=>o("update:modelValue",l.target.value)),onChange:t[1]||(t[1]=l=>o("change",l.target.value)),onBlur:t[2]||(t[2]=l=>o("blur",l)),onFocus:t[3]||(t[3]=l=>o("focus",l))},null,42,c))}});export{g as _};
|
||||
import{c as i}from"./utils-7WVCegLb.js";import{d,o as s,c as u,n as m,h as r}from"./index-DMlmI4VG.js";const c=["id","name","type","value","placeholder","disabled","autocomplete","min","max","step"],g=d({__name:"Input",props:{modelValue:{},type:{},placeholder:{},disabled:{type:Boolean},class:{},id:{},name:{},autocomplete:{},min:{},max:{},step:{}},emits:["update:modelValue","change","blur","focus"],setup(e,{emit:n}){const a=e,o=n;return(f,t)=>(s(),u("input",{id:e.id,name:e.name,type:e.type??"text",value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,autocomplete:e.autocomplete,min:e.min,max:e.max,step:e.step,class:m(r(i)("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm","ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium","placeholder:text-muted-foreground","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-50",a.class)),onInput:t[0]||(t[0]=l=>o("update:modelValue",l.target.value)),onChange:t[1]||(t[1]=l=>o("change",l.target.value)),onBlur:t[2]||(t[2]=l=>o("blur",l)),onFocus:t[3]||(t[3]=l=>o("focus",l))},null,42,c))}});export{g as _};
|
||||
|
|
@ -1 +1 @@
|
|||
import{a as b}from"./admin-Hqgc_gdo.js";import{_ as K,a as $}from"./CardContent.vue_vue_type_script_setup_true_lang-gLtslFdL.js";import{_ as v}from"./Button.vue_vue_type_script_setup_true_lang-C49zRtnB.js";import{_ as V}from"./Dialog.vue_vue_type_script_setup_true_lang-BtMWNArX.js";import{_ as N}from"./Input.vue_vue_type_script_setup_true_lang-Gu5Qa2C5.js";import{_ as A}from"./Spinner.vue_vue_type_script_setup_true_lang-DMwaztwt.js";import{d as B,y as L,c as l,a as t,e as r,w as n,s as i,o as a,q as p,F as P,r as F,t as u,h as k,k as I,i as j,K as y}from"./index-DxmLkgMg.js";import{a as h}from"./utils-7WVCegLb.js";const D={class:"p-6"},R={class:"flex items-center justify-between mb-6"},z={key:0,class:"flex items-center justify-center h-20"},M={key:1,class:"text-center text-muted-foreground py-8 text-sm"},T={key:2,class:"w-full"},U={class:"px-4 py-3 text-sm text-foreground"},q={class:"px-4 py-3 text-sm font-mono text-muted-foreground"},E={class:"px-4 py-3 text-xs text-muted-foreground"},H={class:"px-4 py-3 text-xs text-muted-foreground"},S={class:"px-4 py-3 text-right"},G={class:"space-y-4"},J={key:0,class:"rounded-md bg-emerald-500/10 border border-emerald-500/30 p-3"},O={class:"text-xs font-mono text-foreground break-all"},Q={key:1,class:"space-y-1.5"},re=B({__name:"KeysView",setup(W){const f=i([]),_=i(!1),c=i(!1),m=i(""),x=i(!1),d=i(null);L(()=>g());async function g(){_.value=!0;try{const o=await b.keys();f.value=o.data}finally{_.value=!1}}async function w(){if(m.value.trim()){x.value=!0;try{const o=await b.createKey({label:m.value});d.value=o.data.key,y.success("API key created"),await g(),m.value=""}catch{y.error("Failed to create key")}finally{x.value=!1}}}async function C(o){if(confirm(`Revoke key "${o.label}"? This cannot be undone.`))try{await b.revokeKey(o.id),y.success("Key revoked"),f.value=f.value.filter(e=>e.id!==o.id)}catch{y.error("Failed to revoke key")}}return(o,e)=>(a(),l("div",D,[t("div",R,[e[5]||(e[5]=t("h2",{class:"text-lg font-semibold text-foreground"},"API Keys",-1)),r(v,{size:"sm",onClick:e[0]||(e[0]=s=>{c.value=!0,d.value=null})},{default:n(()=>[...e[4]||(e[4]=[t("svg",{class:"h-4 w-4 mr-1.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),p(" New Key ",-1)])]),_:1})]),r(K,null,{default:n(()=>[r($,{class:"p-0"},{default:n(()=>[_.value?(a(),l("div",z,[r(A,{class:"text-primary"})])):f.value.length===0?(a(),l("div",M," No API keys ")):(a(),l("table",T,[e[7]||(e[7]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Label"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Prefix"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Created"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Last Used"),t("th",{class:"px-4 py-3"})])],-1)),t("tbody",null,[(a(!0),l(P,null,F(f.value,s=>(a(),l("tr",{key:s.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",U,u(s.label),1),t("td",q,u(s.prefix)+"...",1),t("td",E,u(k(h)(s.created_at)),1),t("td",H,u(s.last_used?k(h)(s.last_used):"Never"),1),t("td",S,[r(v,{variant:"ghost",size:"sm",class:"text-destructive",onClick:X=>C(s)},{default:n(()=>[...e[6]||(e[6]=[p(" Revoke ",-1)])]),_:1},8,["onClick"])])]))),128))])]))]),_:1})]),_:1}),r(V,{open:c.value,title:"Create API Key",onClose:e[3]||(e[3]=s=>c.value=!1)},{footer:n(()=>[r(v,{variant:"outline",onClick:e[2]||(e[2]=s=>c.value=!1)},{default:n(()=>[p(u(d.value?"Done":"Cancel"),1)]),_:1}),d.value?j("",!0):(a(),I(v,{key:0,loading:x.value,onClick:w},{default:n(()=>[...e[10]||(e[10]=[p(" Create ",-1)])]),_:1},8,["loading"]))]),default:n(()=>[t("div",G,[d.value?(a(),l("div",J,[e[8]||(e[8]=t("p",{class:"text-xs text-emerald-400 font-medium mb-1"},"Key created — save it now!",-1)),t("p",O,u(d.value),1)])):(a(),l("div",Q,[e[9]||(e[9]=t("label",{class:"text-sm font-medium text-foreground"},"Label",-1)),r(N,{modelValue:m.value,"onUpdate:modelValue":e[1]||(e[1]=s=>m.value=s),placeholder:"e.g. claude-collector",disabled:x.value},null,8,["modelValue","disabled"])]))])]),_:1},8,["open"])]))}});export{re as default};
|
||||
import{a as b}from"./admin-DvZ7jcBF.js";import{_ as K,a as $}from"./CardContent.vue_vue_type_script_setup_true_lang-B5oRrbOE.js";import{_ as v}from"./Button.vue_vue_type_script_setup_true_lang-B00Be5tl.js";import{_ as V}from"./Dialog.vue_vue_type_script_setup_true_lang-C0H3A6cL.js";import{_ as N}from"./Input.vue_vue_type_script_setup_true_lang-TcALPBvs.js";import{_ as A}from"./Spinner.vue_vue_type_script_setup_true_lang-CxhsEqE4.js";import{d as B,y as L,c as l,a as t,e as r,w as n,s as i,o as a,q as p,F as P,r as F,t as u,h as k,k as I,i as j,K as y}from"./index-DMlmI4VG.js";import{a as h}from"./utils-7WVCegLb.js";const D={class:"p-6"},R={class:"flex items-center justify-between mb-6"},z={key:0,class:"flex items-center justify-center h-20"},M={key:1,class:"text-center text-muted-foreground py-8 text-sm"},T={key:2,class:"w-full"},U={class:"px-4 py-3 text-sm text-foreground"},q={class:"px-4 py-3 text-sm font-mono text-muted-foreground"},E={class:"px-4 py-3 text-xs text-muted-foreground"},H={class:"px-4 py-3 text-xs text-muted-foreground"},S={class:"px-4 py-3 text-right"},G={class:"space-y-4"},J={key:0,class:"rounded-md bg-emerald-500/10 border border-emerald-500/30 p-3"},O={class:"text-xs font-mono text-foreground break-all"},Q={key:1,class:"space-y-1.5"},re=B({__name:"KeysView",setup(W){const f=i([]),_=i(!1),c=i(!1),m=i(""),x=i(!1),d=i(null);L(()=>g());async function g(){_.value=!0;try{const o=await b.keys();f.value=o.data}finally{_.value=!1}}async function w(){if(m.value.trim()){x.value=!0;try{const o=await b.createKey({label:m.value});d.value=o.data.key,y.success("API key created"),await g(),m.value=""}catch{y.error("Failed to create key")}finally{x.value=!1}}}async function C(o){if(confirm(`Revoke key "${o.label}"? This cannot be undone.`))try{await b.revokeKey(o.id),y.success("Key revoked"),f.value=f.value.filter(e=>e.id!==o.id)}catch{y.error("Failed to revoke key")}}return(o,e)=>(a(),l("div",D,[t("div",R,[e[5]||(e[5]=t("h2",{class:"text-lg font-semibold text-foreground"},"API Keys",-1)),r(v,{size:"sm",onClick:e[0]||(e[0]=s=>{c.value=!0,d.value=null})},{default:n(()=>[...e[4]||(e[4]=[t("svg",{class:"h-4 w-4 mr-1.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),p(" New Key ",-1)])]),_:1})]),r(K,null,{default:n(()=>[r($,{class:"p-0"},{default:n(()=>[_.value?(a(),l("div",z,[r(A,{class:"text-primary"})])):f.value.length===0?(a(),l("div",M," No API keys ")):(a(),l("table",T,[e[7]||(e[7]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Label"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Prefix"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Created"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Last Used"),t("th",{class:"px-4 py-3"})])],-1)),t("tbody",null,[(a(!0),l(P,null,F(f.value,s=>(a(),l("tr",{key:s.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",U,u(s.label),1),t("td",q,u(s.prefix)+"...",1),t("td",E,u(k(h)(s.created_at)),1),t("td",H,u(s.last_used?k(h)(s.last_used):"Never"),1),t("td",S,[r(v,{variant:"ghost",size:"sm",class:"text-destructive",onClick:X=>C(s)},{default:n(()=>[...e[6]||(e[6]=[p(" Revoke ",-1)])]),_:1},8,["onClick"])])]))),128))])]))]),_:1})]),_:1}),r(V,{open:c.value,title:"Create API Key",onClose:e[3]||(e[3]=s=>c.value=!1)},{footer:n(()=>[r(v,{variant:"outline",onClick:e[2]||(e[2]=s=>c.value=!1)},{default:n(()=>[p(u(d.value?"Done":"Cancel"),1)]),_:1}),d.value?j("",!0):(a(),I(v,{key:0,loading:x.value,onClick:w},{default:n(()=>[...e[10]||(e[10]=[p(" Create ",-1)])]),_:1},8,["loading"]))]),default:n(()=>[t("div",G,[d.value?(a(),l("div",J,[e[8]||(e[8]=t("p",{class:"text-xs text-emerald-400 font-medium mb-1"},"Key created — save it now!",-1)),t("p",O,u(d.value),1)])):(a(),l("div",Q,[e[9]||(e[9]=t("label",{class:"text-sm font-medium text-foreground"},"Label",-1)),r(N,{modelValue:m.value,"onUpdate:modelValue":e[1]||(e[1]=s=>m.value=s),placeholder:"e.g. claude-collector",disabled:x.value},null,8,["modelValue","disabled"])]))])]),_:1},8,["open"])]))}});export{re as default};
|
||||
|
|
@ -1 +1 @@
|
|||
import{H as T,s as g,d as J,u as O,y as V,c as f,a as o,n as b,h as l,t as v,k as $,w as x,i as k,e as C,o as c,q as w,F as B,r as F,j as z}from"./index-DxmLkgMg.js";import{_ as A,a as D}from"./CardContent.vue_vue_type_script_setup_true_lang-gLtslFdL.js";import{_ as N}from"./Button.vue_vue_type_script_setup_true_lang-C49zRtnB.js";import"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-DMwaztwt.js";function U(E){const e=g([]),i=g(!1),m=g(null);let s=null,r=null,u=!1;function p(){if(!u)try{s=new EventSource(E),s.onopen=()=>{i.value=!0,m.value=null},s.onmessage=n=>{try{const y=JSON.parse(n.data);e.value.push({type:"message",data:y}),e.value.length>200&&e.value.shift()}catch{e.value.push({type:"message",data:n.data})}},s.addEventListener("session_start",n=>{try{e.value.push({type:"session_start",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_start",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("session_end",n=>{try{e.value.push({type:"session_end",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_end",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("activity",n=>{try{e.value.push({type:"activity",data:JSON.parse(n.data)})}catch{e.value.push({type:"activity",data:n.data})}e.value.length>200&&e.value.shift()}),s.onerror=()=>{i.value=!1,m.value="Connection lost, reconnecting...",s==null||s.close(),s=null,u||(r=setTimeout(()=>p(),5e3))}}catch{m.value="Failed to connect to event stream",u||(r=setTimeout(()=>p(),5e3))}}function _(){u=!0,r&&clearTimeout(r),s==null||s.close(),s=null,i.value=!1}function h(){e.value=[]}return T(()=>{_()}),{events:e,connected:i,error:m,connect:p,disconnect:_,clearEvents:h}}const I={class:"p-6 h-full flex flex-col"},R={class:"flex items-center gap-3 mb-4"},q={class:"flex items-center gap-2"},H={class:"text-xs text-muted-foreground"},M={key:0,class:"mb-4 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2"},P={key:0,class:"flex items-center justify-center h-full text-sm text-muted-foreground"},W={key:1,class:"overflow-y-auto h-full font-mono text-xs"},G={class:"flex-1 min-w-0"},K={class:"flex items-center gap-2 flex-wrap"},Q={key:0,class:"text-muted-foreground"},X={class:"text-muted-foreground truncate mt-0.5"},ne=J({__name:"LiveView",setup(E){const e=O(),i=e.getToken(),m=`/cc-dashboard/api/events${i?`?token=${encodeURIComponent(i)}`:""}`,{events:s,connected:r,error:u,connect:p,clearEvents:_}=U(m);V(()=>{e.isAuthenticated&&i&&p()});const h=z(()=>[...s.value].reverse().slice(0,100));function n(t){return t==="session_start"?"text-emerald-400":t==="session_end"?"text-amber-400":t==="activity"?"text-blue-400":"text-muted-foreground"}function y(t){return t==="session_start"?"▶":t==="session_end"?"■":t==="activity"?"●":"○"}function j(t){if(typeof t=="string")return t;if(t&&typeof t=="object"){const a=t;return a.message||a.summary||JSON.stringify(t)}return String(t)}function S(t){if(t&&typeof t=="object"){const a=t;return a.display_name||a.project_id||""}return""}return(t,a)=>(c(),f("div",I,[o("div",R,[a[2]||(a[2]=o("h2",{class:"text-lg font-semibold text-foreground flex-1"},"Live Feed",-1)),o("div",q,[o("div",{class:b(["h-2 w-2 rounded-full",l(r)?"bg-emerald-500 animate-pulse":"bg-red-500"])},null,2),o("span",H,v(l(r)?"Connected":"Disconnected"),1)]),l(r)?k("",!0):(c(),$(N,{key:0,variant:"outline",size:"sm",onClick:l(p)},{default:x(()=>[...a[0]||(a[0]=[w(" Reconnect ",-1)])]),_:1},8,["onClick"])),C(N,{variant:"ghost",size:"sm",onClick:l(_)},{default:x(()=>[...a[1]||(a[1]=[w(" Clear ",-1)])]),_:1},8,["onClick"])]),l(u)&&!l(r)?(c(),f("div",M,v(l(u)),1)):k("",!0),C(A,{class:"flex-1 overflow-hidden"},{default:x(()=>[C(D,{class:"p-0 h-full"},{default:x(()=>[h.value.length===0?(c(),f("div",P,[...a[3]||(a[3]=[o("div",{class:"text-center"},[o("div",{class:"text-2xl mb-2"},"📡"),o("p",null,"Waiting for events..."),o("p",{class:"text-xs mt-1"},"Activity will appear here in real-time")],-1)])])):(c(),f("div",W,[(c(!0),f(B,null,F(h.value,(d,L)=>(c(),f("div",{key:L,class:"flex items-start gap-2 px-4 py-1.5 hover:bg-muted/50 border-b border-border/30"},[o("span",{class:b([n(d.type),"shrink-0 mt-0.5"])},v(y(d.type)),3),o("div",G,[o("div",K,[o("span",{class:b([n(d.type),"font-medium"])},v(d.type),3),S(d.data)?(c(),f("span",Q,v(S(d.data)),1)):k("",!0)]),o("p",X,v(j(d.data)),1)])]))),128))]))]),_:1})]),_:1})]))}});export{ne as default};
|
||||
import{I as T,s as g,d as J,u as O,y as V,c as f,a as o,n as b,h as l,t as v,k as $,w as x,i as k,e as C,o as c,q as w,F as B,r as F,j as z}from"./index-DMlmI4VG.js";import{_ as A,a as D}from"./CardContent.vue_vue_type_script_setup_true_lang-B5oRrbOE.js";import{_ as N}from"./Button.vue_vue_type_script_setup_true_lang-B00Be5tl.js";import"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-CxhsEqE4.js";function I(E){const e=g([]),i=g(!1),m=g(null);let s=null,r=null,u=!1;function p(){if(!u)try{s=new EventSource(E),s.onopen=()=>{i.value=!0,m.value=null},s.onmessage=n=>{try{const y=JSON.parse(n.data);e.value.push({type:"message",data:y}),e.value.length>200&&e.value.shift()}catch{e.value.push({type:"message",data:n.data})}},s.addEventListener("session_start",n=>{try{e.value.push({type:"session_start",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_start",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("session_end",n=>{try{e.value.push({type:"session_end",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_end",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("activity",n=>{try{e.value.push({type:"activity",data:JSON.parse(n.data)})}catch{e.value.push({type:"activity",data:n.data})}e.value.length>200&&e.value.shift()}),s.onerror=()=>{i.value=!1,m.value="Connection lost, reconnecting...",s==null||s.close(),s=null,u||(r=setTimeout(()=>p(),5e3))}}catch{m.value="Failed to connect to event stream",u||(r=setTimeout(()=>p(),5e3))}}function _(){u=!0,r&&clearTimeout(r),s==null||s.close(),s=null,i.value=!1}function h(){e.value=[]}return T(()=>{_()}),{events:e,connected:i,error:m,connect:p,disconnect:_,clearEvents:h}}const U={class:"p-6 h-full flex flex-col"},R={class:"flex items-center gap-3 mb-4"},q={class:"flex items-center gap-2"},M={class:"text-xs text-muted-foreground"},P={key:0,class:"mb-4 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2"},W={key:0,class:"flex items-center justify-center h-full text-sm text-muted-foreground"},G={key:1,class:"overflow-y-auto h-full font-mono text-xs"},H={class:"flex-1 min-w-0"},K={class:"flex items-center gap-2 flex-wrap"},Q={key:0,class:"text-muted-foreground"},X={class:"text-muted-foreground truncate mt-0.5"},ne=J({__name:"LiveView",setup(E){const e=O(),i=e.getToken(),m=`/cc-dashboard/api/events${i?`?token=${encodeURIComponent(i)}`:""}`,{events:s,connected:r,error:u,connect:p,clearEvents:_}=I(m);V(()=>{e.isAuthenticated&&i&&p()});const h=z(()=>[...s.value].reverse().slice(0,100));function n(t){return t==="session_start"?"text-emerald-400":t==="session_end"?"text-amber-400":t==="activity"?"text-blue-400":"text-muted-foreground"}function y(t){return t==="session_start"?"▶":t==="session_end"?"■":t==="activity"?"●":"○"}function j(t){if(typeof t=="string")return t;if(t&&typeof t=="object"){const a=t;return a.message||a.summary||JSON.stringify(t)}return String(t)}function S(t){if(t&&typeof t=="object"){const a=t;return a.display_name||a.project_id||""}return""}return(t,a)=>(c(),f("div",U,[o("div",R,[a[2]||(a[2]=o("h2",{class:"text-lg font-semibold text-foreground flex-1"},"Live Feed",-1)),o("div",q,[o("div",{class:b(["h-2 w-2 rounded-full",l(r)?"bg-emerald-500 animate-pulse":"bg-red-500"])},null,2),o("span",M,v(l(r)?"Connected":"Disconnected"),1)]),l(r)?k("",!0):(c(),$(N,{key:0,variant:"outline",size:"sm",onClick:l(p)},{default:x(()=>[...a[0]||(a[0]=[w(" Reconnect ",-1)])]),_:1},8,["onClick"])),C(N,{variant:"ghost",size:"sm",onClick:l(_)},{default:x(()=>[...a[1]||(a[1]=[w(" Clear ",-1)])]),_:1},8,["onClick"])]),l(u)&&!l(r)?(c(),f("div",P,v(l(u)),1)):k("",!0),C(A,{class:"flex-1 overflow-hidden"},{default:x(()=>[C(D,{class:"p-0 h-full"},{default:x(()=>[h.value.length===0?(c(),f("div",W,[...a[3]||(a[3]=[o("div",{class:"text-center"},[o("div",{class:"text-2xl mb-2"},"📡"),o("p",null,"Waiting for events..."),o("p",{class:"text-xs mt-1"},"Activity will appear here in real-time")],-1)])])):(c(),f("div",G,[(c(!0),f(B,null,F(h.value,(d,L)=>(c(),f("div",{key:L,class:"flex items-start gap-2 px-4 py-1.5 hover:bg-muted/50 border-b border-border/30"},[o("span",{class:b([n(d.type),"shrink-0 mt-0.5"])},v(y(d.type)),3),o("div",H,[o("div",K,[o("span",{class:b([n(d.type),"font-medium"])},v(d.type),3),S(d.data)?(c(),f("span",Q,v(S(d.data)),1)):k("",!0)]),o("p",X,v(j(d.data)),1)])]))),128))]))]),_:1})]),_:1})]))}});export{ne as default};
|
||||
|
|
@ -1 +1 @@
|
|||
import{d as h,u as f,c as o,a as t,b as m,e as a,w as d,o as r,f as g,g as p,h as i,t as x,i as w}from"./index-DxmLkgMg.js";import{_ as y,a as b}from"./CardContent.vue_vue_type_script_setup_true_lang-gLtslFdL.js";import"./utils-7WVCegLb.js";const v={class:"min-h-screen flex items-center justify-center bg-background p-4"},_={class:"w-full max-w-sm"},k={class:"space-y-4"},C={key:0,class:"rounded-md bg-destructive/10 border border-destructive/30 px-3 py-2 text-sm text-destructive"},B=["disabled"],V={key:0},S={key:1},M=h({__name:"LoginView",setup(F){const c=g(),l=p(),s=f();async function u(){try{await s.loginWithMicrosoft();const n=l.query.redirect;c.push(n??"/")}catch{}}return(n,e)=>(r(),o("div",v,[t("div",_,[e[2]||(e[2]=m('<div class="text-center mb-8"><div class="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary mb-3"><svg class="h-7 w-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg></div><h1 class="text-2xl font-bold text-foreground">CC Dashboard</h1><p class="text-sm text-muted-foreground mt-1">Corporate Planning Hub</p></div>',1)),a(y,null,{default:d(()=>[a(b,{class:"pt-6"},{default:d(()=>[t("div",k,[i(s).error?(r(),o("div",C,x(i(s).error),1)):w("",!0),t("button",{type:"button",disabled:i(s).loading,class:"w-full flex items-center justify-center gap-3 rounded-md border border-border bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",onClick:u},[e[0]||(e[0]=t("svg",{class:"h-5 w-5 shrink-0",viewBox:"0 0 21 21",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"1",y:"1",width:"9",height:"9",fill:"#F25022"}),t("rect",{x:"11",y:"1",width:"9",height:"9",fill:"#7FBA00"}),t("rect",{x:"1",y:"11",width:"9",height:"9",fill:"#00A4EF"}),t("rect",{x:"11",y:"11",width:"9",height:"9",fill:"#FFB900"})],-1)),i(s).loading?(r(),o("span",V,"Signing in…")):(r(),o("span",S,"Sign in with Microsoft"))],8,B),e[1]||(e[1]=t("p",{class:"text-center text-xs text-muted-foreground"}," Use your @oliver.agency account ",-1))])]),_:1})]),_:1})])]))}});export{M as default};
|
||||
import{d as h,u as f,c as o,a as t,b as m,e as a,w as d,o as r,f as g,g as p,h as i,t as x,i as w}from"./index-DMlmI4VG.js";import{_ as y,a as b}from"./CardContent.vue_vue_type_script_setup_true_lang-B5oRrbOE.js";import"./utils-7WVCegLb.js";const v={class:"min-h-screen flex items-center justify-center bg-background p-4"},_={class:"w-full max-w-sm"},k={class:"space-y-4"},C={key:0,class:"rounded-md bg-destructive/10 border border-destructive/30 px-3 py-2 text-sm text-destructive"},B=["disabled"],V={key:0},S={key:1},M=h({__name:"LoginView",setup(F){const c=g(),l=p(),s=f();async function u(){try{await s.loginWithMicrosoft();const n=l.query.redirect;c.push(n??"/")}catch{}}return(n,e)=>(r(),o("div",v,[t("div",_,[e[2]||(e[2]=m('<div class="text-center mb-8"><div class="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary mb-3"><svg class="h-7 w-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg></div><h1 class="text-2xl font-bold text-foreground">CC Dashboard</h1><p class="text-sm text-muted-foreground mt-1">Corporate Planning Hub</p></div>',1)),a(y,null,{default:d(()=>[a(b,{class:"pt-6"},{default:d(()=>[t("div",k,[i(s).error?(r(),o("div",C,x(i(s).error),1)):w("",!0),t("button",{type:"button",disabled:i(s).loading,class:"w-full flex items-center justify-center gap-3 rounded-md border border-border bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",onClick:u},[e[0]||(e[0]=t("svg",{class:"h-5 w-5 shrink-0",viewBox:"0 0 21 21",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"1",y:"1",width:"9",height:"9",fill:"#F25022"}),t("rect",{x:"11",y:"1",width:"9",height:"9",fill:"#7FBA00"}),t("rect",{x:"1",y:"11",width:"9",height:"9",fill:"#00A4EF"}),t("rect",{x:"11",y:"11",width:"9",height:"9",fill:"#FFB900"})],-1)),i(s).loading?(r(),o("span",V,"Signing in…")):(r(),o("span",S,"Sign in with Microsoft"))],8,B),e[1]||(e[1]=t("p",{class:"text-center text-xs text-muted-foreground"}," Use your @oliver.agency account ",-1))])]),_:1})]),_:1})])]))}});export{M as default};
|
||||
1
src/static/assets/OmgView-BUhFe1Nr.js
Normal file
1
src/static/assets/OmgView-BUhFe1Nr.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{c as r}from"./utils-7WVCegLb.js";import{d as s,o as n,c as t,n as l,h as c,a as d,B as u}from"./index-DxmLkgMg.js";const h=s({__name:"Progress",props:{value:{},max:{default:100},class:{},color:{default:"default"}},setup(a){const e=a,o=()=>Math.min(100,Math.max(0,e.value/e.max*100));return(i,m)=>(n(),t("div",{class:l(c(r)("relative h-2 w-full overflow-hidden rounded-full bg-secondary",e.class))},[d("div",{class:l(["h-full rounded-full transition-all duration-300",{"bg-primary":a.color==="default","bg-emerald-500":a.color==="success","bg-amber-500":a.color==="warning","bg-red-500":a.color==="danger"}]),style:u({width:`${o()}%`})},null,6)],2))}});export{h as _};
|
||||
import{c as r}from"./utils-7WVCegLb.js";import{d as s,o as n,c as t,n as l,h as c,a as d,B as u}from"./index-DMlmI4VG.js";const h=s({__name:"Progress",props:{value:{},max:{default:100},class:{},color:{default:"default"}},setup(a){const e=a,o=()=>Math.min(100,Math.max(0,e.value/e.max*100));return(i,m)=>(n(),t("div",{class:l(c(r)("relative h-2 w-full overflow-hidden rounded-full bg-secondary",e.class))},[d("div",{class:l(["h-full rounded-full transition-all duration-300",{"bg-primary":a.color==="default","bg-emerald-500":a.color==="success","bg-amber-500":a.color==="warning","bg-red-500":a.color==="danger"}]),style:u({width:`${o()}%`})},null,6)],2))}});export{h as _};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,4 @@
|
|||
var Ce=Object.defineProperty;var ce=a=>{throw TypeError(a)};var Ee=(a,t,e)=>t in a?Ce(a,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):a[t]=e;var x=(a,t,e)=>Ee(a,typeof t!="symbol"?t+"":t,e),Le=(a,t,e)=>t.has(a)||ce("Cannot "+e);var pe=(a,t,e)=>t.has(a)?ce("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(a):t.set(a,e);var P=(a,t,e)=>(Le(a,t,"access private method"),e);import{E as V,d as Be,u as qe,y as Ze,c as R,a as b,n as G,e as M,w as A,F as Pe,r as Me,s as C,o as T,q as W,k as ue,t as X,h as De,i as he,K as D}from"./index-DxmLkgMg.js";import{a as Qe,_ as je}from"./CardContent.vue_vue_type_script_setup_true_lang-gLtslFdL.js";import{_ as fe}from"./Badge.vue_vue_type_script_setup_true_lang-CVzGyv6L.js";import{_ as Oe}from"./Button.vue_vue_type_script_setup_true_lang-C49zRtnB.js";import{_ as Ne}from"./Spinner.vue_vue_type_script_setup_true_lang-DMwaztwt.js";import{a as He,i as Ue}from"./utils-7WVCegLb.js";import{_ as Fe}from"./_plugin-vue_export-helper-DlAUqK2U.js";const de={list:()=>V.get("/api/reports"),get:a=>V.get(`/api/reports/${a}`),generate:a=>V.post("/api/reports/generate",a)};function Y(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}let I=Y();function we(a){I=a}const ye=/[&<>"']/,Ve=new RegExp(ye.source,"g"),$e=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,Ge=new RegExp($e.source,"g"),We={"&":"&","<":"<",">":">",'"':""","'":"'"},ge=a=>We[a];function w(a,t){if(t){if(ye.test(a))return a.replace(Ve,ge)}else if($e.test(a))return a.replace(Ge,ge);return a}const Xe=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig;function Ke(a){return a.replace(Xe,(t,e)=>(e=e.toLowerCase(),e==="colon"?":":e.charAt(0)==="#"?e.charAt(1)==="x"?String.fromCharCode(parseInt(e.substring(2),16)):String.fromCharCode(+e.substring(1)):""))}const Je=/(^|[^\[])\^/g;function g(a,t){let e=typeof a=="string"?a:a.source;t=t||"";const n={replace:(i,r)=>{let s=typeof r=="string"?r:r.source;return s=s.replace(Je,"$1"),e=e.replace(i,s),n},getRegex:()=>new RegExp(e,t)};return n}function ke(a){try{a=encodeURI(a).replace(/%25/g,"%")}catch{return null}return a}const L={exec:()=>null};function xe(a,t){const e=a.replace(/\|/g,(r,s,o)=>{let l=!1,u=s;for(;--u>=0&&o[u]==="\\";)l=!l;return l?"|":" |"}),n=e.split(/ \|/);let i=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length<t;)n.push("");for(;i<n.length;i++)n[i]=n[i].trim().replace(/\\\|/g,"|");return n}function Q(a,t,e){const n=a.length;if(n===0)return"";let i=0;for(;i<n&&a.charAt(n-i-1)===t;)i++;return a.slice(0,n-i)}function Ye(a,t){if(a.indexOf(t[1])===-1)return-1;let e=0;for(let n=0;n<a.length;n++)if(a[n]==="\\")n++;else if(a[n]===t[0])e++;else if(a[n]===t[1]&&(e--,e<0))return n;return-1}function me(a,t,e,n){const i=t.href,r=t.title?w(t.title):null,s=a[1].replace(/\\([\[\]])/g,"$1");if(a[0].charAt(0)!=="!"){n.state.inLink=!0;const o={type:"link",raw:e,href:i,title:r,text:s,tokens:n.inlineTokens(s)};return n.state.inLink=!1,o}return{type:"image",raw:e,href:i,title:r,text:w(s)}}function et(a,t){const e=a.match(/^(\s+)(?:```)/);if(e===null)return t;const n=e[1];return t.split(`
|
||||
var Ce=Object.defineProperty;var ce=a=>{throw TypeError(a)};var Ee=(a,t,e)=>t in a?Ce(a,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):a[t]=e;var x=(a,t,e)=>Ee(a,typeof t!="symbol"?t+"":t,e),Le=(a,t,e)=>t.has(a)||ce("Cannot "+e);var pe=(a,t,e)=>t.has(a)?ce("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(a):t.set(a,e);var P=(a,t,e)=>(Le(a,t,"access private method"),e);import{E as V,d as Be,u as qe,y as Ze,c as R,a as b,n as G,e as M,w as A,F as Pe,r as Me,s as C,o as T,q as W,k as ue,t as X,h as De,i as he,K as D}from"./index-DMlmI4VG.js";import{a as Qe,_ as je}from"./CardContent.vue_vue_type_script_setup_true_lang-B5oRrbOE.js";import{_ as fe}from"./Badge.vue_vue_type_script_setup_true_lang-B0XCy3Qk.js";import{_ as Oe}from"./Button.vue_vue_type_script_setup_true_lang-B00Be5tl.js";import{_ as Ne}from"./Spinner.vue_vue_type_script_setup_true_lang-CxhsEqE4.js";import{a as He,i as Ue}from"./utils-7WVCegLb.js";import{_ as Fe}from"./_plugin-vue_export-helper-DlAUqK2U.js";const de={list:()=>V.get("/api/reports"),get:a=>V.get(`/api/reports/${a}`),generate:a=>V.post("/api/reports/generate",a)};function Y(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}let I=Y();function we(a){I=a}const ye=/[&<>"']/,Ve=new RegExp(ye.source,"g"),$e=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,Ge=new RegExp($e.source,"g"),We={"&":"&","<":"<",">":">",'"':""","'":"'"},ge=a=>We[a];function w(a,t){if(t){if(ye.test(a))return a.replace(Ve,ge)}else if($e.test(a))return a.replace(Ge,ge);return a}const Xe=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig;function Ke(a){return a.replace(Xe,(t,e)=>(e=e.toLowerCase(),e==="colon"?":":e.charAt(0)==="#"?e.charAt(1)==="x"?String.fromCharCode(parseInt(e.substring(2),16)):String.fromCharCode(+e.substring(1)):""))}const Je=/(^|[^\[])\^/g;function g(a,t){let e=typeof a=="string"?a:a.source;t=t||"";const n={replace:(i,r)=>{let s=typeof r=="string"?r:r.source;return s=s.replace(Je,"$1"),e=e.replace(i,s),n},getRegex:()=>new RegExp(e,t)};return n}function ke(a){try{a=encodeURI(a).replace(/%25/g,"%")}catch{return null}return a}const L={exec:()=>null};function xe(a,t){const e=a.replace(/\|/g,(r,s,o)=>{let l=!1,u=s;for(;--u>=0&&o[u]==="\\";)l=!l;return l?"|":" |"}),n=e.split(/ \|/);let i=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length<t;)n.push("");for(;i<n.length;i++)n[i]=n[i].trim().replace(/\\\|/g,"|");return n}function Q(a,t,e){const n=a.length;if(n===0)return"";let i=0;for(;i<n&&a.charAt(n-i-1)===t;)i++;return a.slice(0,n-i)}function Ye(a,t){if(a.indexOf(t[1])===-1)return-1;let e=0;for(let n=0;n<a.length;n++)if(a[n]==="\\")n++;else if(a[n]===t[0])e++;else if(a[n]===t[1]&&(e--,e<0))return n;return-1}function me(a,t,e,n){const i=t.href,r=t.title?w(t.title):null,s=a[1].replace(/\\([\[\]])/g,"$1");if(a[0].charAt(0)!=="!"){n.state.inLink=!0;const o={type:"link",raw:e,href:i,title:r,text:s,tokens:n.inlineTokens(s)};return n.state.inLink=!1,o}return{type:"image",raw:e,href:i,title:r,text:w(s)}}function et(a,t){const e=a.match(/^(\s+)(?:```)/);if(e===null)return t;const n=e[1];return t.split(`
|
||||
`).map(i=>{const r=i.match(/^\s+/);if(r===null)return i;const[s]=r;return s.length>=n.length?i.slice(n.length):i}).join(`
|
||||
`)}class O{constructor(t){x(this,"options");x(this,"rules");x(this,"lexer");this.options=t||I}space(t){const e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){const e=this.rules.block.code.exec(t);if(e){const n=e[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?n:Q(n,`
|
||||
`)}}}fences(t){const e=this.rules.block.fences.exec(t);if(e){const n=e[0],i=et(n,e[3]||"");return{type:"code",raw:n,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:i}}}heading(t){const e=this.rules.block.heading.exec(t);if(e){let n=e[2].trim();if(/#$/.test(n)){const i=Q(n,"#");(this.options.pedantic||!i||/ $/.test(i))&&(n=i.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(t){const e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:e[0]}}blockquote(t){const e=this.rules.block.blockquote.exec(t);if(e){let n=e[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,`
|
||||
|
|
@ -1 +1 @@
|
|||
import{d as N,u as E,y as P,c as U,a,e as t,w as s,s as f,o as k,q as u,h as c,k as z,i as B,E as I,K as x}from"./index-DxmLkgMg.js";import{u as F}from"./devops-CwVu4BME.js";import{_ as w,a as V}from"./CardContent.vue_vue_type_script_setup_true_lang-gLtslFdL.js";import{_ as $,a as S}from"./CardTitle.vue_vue_type_script_setup_true_lang-DiCmBSdj.js";import{_ as y}from"./Input.vue_vue_type_script_setup_true_lang-Gu5Qa2C5.js";import{_}from"./Button.vue_vue_type_script_setup_true_lang-C49zRtnB.js";import{_ as O}from"./DevopsConnectForm.vue_vue_type_script_setup_true_lang-BRLYQ8KS.js";import{i as C}from"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-DMwaztwt.js";function T(i,l){const n=`/cc-dashboard/api/export/timesheet.csv?from=${i}&to=${l}`,o=document.createElement("a");o.href=n,o.download=`timesheet-${i}-${l}.csv`,o.click()}function A(i,l){const n=`/cc-dashboard/api/export/timesheet.ics?from=${i}&to=${l}`,o=document.createElement("a");o.href=n,o.download=`timesheet-${i}-${l}.ics`,o.click()}const H={class:"p-6 space-y-6 max-w-2xl"},K={class:"space-y-1.5"},M={class:"space-y-1.5"},j={class:"flex items-center justify-between"},q={class:"flex items-center gap-3 flex-wrap"},h={class:"space-y-1.5"},G={class:"space-y-1.5"},J={class:"flex items-center gap-2"},se=N({__name:"SettingsView",setup(i){const l=E(),n=F(),o=f(""),p=f(0),g=f(!1),d=f(""),m=f("");P(()=>{l.user&&(o.value=l.user.username,p.value=l.user.daily_overhead_hours??0),n.fetchIntegration();const v=new Date;m.value=C(v);const e=new Date(v);e.setDate(v.getDate()-30),d.value=C(e)});async function D(){g.value=!0;try{await I.patch("/api/auth/me",{username:o.value,daily_overhead_hours:p.value}),await l.fetchMe(),x.success("Profile saved")}catch{x.error("Failed to save profile")}finally{g.value=!1}}async function b(){try{await n.sync(),x.success("Sync complete")}catch{x.error(n.error??"Sync failed")}}return(v,e)=>(k(),U("div",H,[e[18]||(e[18]=a("h2",{class:"text-lg font-semibold text-foreground"},"Settings",-1)),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[t(S,{class:"text-sm"},{default:s(()=>[...e[6]||(e[6]=[u("Profile",-1)])]),_:1})]),_:1}),t(V,{class:"space-y-4"},{default:s(()=>[a("div",K,[e[7]||(e[7]=a("label",{class:"text-sm font-medium text-foreground"},"Username",-1)),t(y,{modelValue:o.value,"onUpdate:modelValue":e[0]||(e[0]=r=>o.value=r),placeholder:"username"},null,8,["modelValue"])]),a("div",M,[e[8]||(e[8]=a("label",{class:"text-sm font-medium text-foreground"},"Daily Overhead Hours",-1)),t(y,{modelValue:p.value,"onUpdate:modelValue":e[1]||(e[1]=r=>p.value=r),type:"number",min:"0",max:"8",step:"0.25",class:"w-32"},null,8,["modelValue"]),e[9]||(e[9]=a("p",{class:"text-xs text-muted-foreground"}," Hours per day to add for overhead / meetings ",-1))]),t(_,{loading:g.value,onClick:D},{default:s(()=>[...e[10]||(e[10]=[u("Save Profile",-1)])]),_:1},8,["loading"])]),_:1})]),_:1}),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[a("div",j,[t(S,{class:"text-sm"},{default:s(()=>[...e[11]||(e[11]=[u("Azure DevOps Integration",-1)])]),_:1}),c(n).integration?(k(),z(_,{key:0,variant:"outline",size:"sm",loading:c(n).syncing,onClick:b},{default:s(()=>[...e[12]||(e[12]=[u(" Sync Now ",-1)])]),_:1},8,["loading"])):B("",!0)])]),_:1}),t(V,null,{default:s(()=>[t(O)]),_:1})]),_:1}),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[t(S,{class:"text-sm"},{default:s(()=>[...e[13]||(e[13]=[u("Export",-1)])]),_:1})]),_:1}),t(V,{class:"space-y-4"},{default:s(()=>[a("div",q,[a("div",h,[e[14]||(e[14]=a("label",{class:"text-xs text-muted-foreground"},"From",-1)),t(y,{modelValue:d.value,"onUpdate:modelValue":e[2]||(e[2]=r=>d.value=r),type:"date",class:"h-8 text-xs"},null,8,["modelValue"])]),a("div",G,[e[15]||(e[15]=a("label",{class:"text-xs text-muted-foreground"},"To",-1)),t(y,{modelValue:m.value,"onUpdate:modelValue":e[3]||(e[3]=r=>m.value=r),type:"date",class:"h-8 text-xs"},null,8,["modelValue"])])]),a("div",J,[t(_,{variant:"outline",size:"sm",onClick:e[4]||(e[4]=r=>c(T)(d.value,m.value))},{default:s(()=>[...e[16]||(e[16]=[u(" Download CSV ",-1)])]),_:1}),t(_,{variant:"outline",size:"sm",onClick:e[5]||(e[5]=r=>c(A)(d.value,m.value))},{default:s(()=>[...e[17]||(e[17]=[u(" Download ICS ",-1)])]),_:1})])]),_:1})]),_:1})]))}});export{se as default};
|
||||
import{d as N,u as E,y as P,c as U,a,e as t,w as s,s as f,o as k,q as u,h as c,k as z,i as B,E as I,K as x}from"./index-DMlmI4VG.js";import{u as F}from"./devops-roMxSiNP.js";import{_ as w,a as V}from"./CardContent.vue_vue_type_script_setup_true_lang-B5oRrbOE.js";import{_ as $,a as S}from"./CardTitle.vue_vue_type_script_setup_true_lang-DTHgMm4V.js";import{_ as y}from"./Input.vue_vue_type_script_setup_true_lang-TcALPBvs.js";import{_}from"./Button.vue_vue_type_script_setup_true_lang-B00Be5tl.js";import{_ as O}from"./DevopsConnectForm.vue_vue_type_script_setup_true_lang-BvWJzMSh.js";import{i as C}from"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-CxhsEqE4.js";function T(i,l){const n=`/cc-dashboard/api/export/timesheet.csv?from=${i}&to=${l}`,o=document.createElement("a");o.href=n,o.download=`timesheet-${i}-${l}.csv`,o.click()}function A(i,l){const n=`/cc-dashboard/api/export/timesheet.ics?from=${i}&to=${l}`,o=document.createElement("a");o.href=n,o.download=`timesheet-${i}-${l}.ics`,o.click()}const H={class:"p-6 space-y-6 max-w-2xl"},K={class:"space-y-1.5"},M={class:"space-y-1.5"},j={class:"flex items-center justify-between"},q={class:"flex items-center gap-3 flex-wrap"},h={class:"space-y-1.5"},G={class:"space-y-1.5"},J={class:"flex items-center gap-2"},se=N({__name:"SettingsView",setup(i){const l=E(),n=F(),o=f(""),p=f(0),g=f(!1),d=f(""),m=f("");P(()=>{l.user&&(o.value=l.user.username,p.value=l.user.daily_overhead_hours??0),n.fetchIntegration();const v=new Date;m.value=C(v);const e=new Date(v);e.setDate(v.getDate()-30),d.value=C(e)});async function D(){g.value=!0;try{await I.patch("/api/auth/me",{username:o.value,daily_overhead_hours:p.value}),await l.fetchMe(),x.success("Profile saved")}catch{x.error("Failed to save profile")}finally{g.value=!1}}async function b(){try{await n.sync(),x.success("Sync complete")}catch{x.error(n.error??"Sync failed")}}return(v,e)=>(k(),U("div",H,[e[18]||(e[18]=a("h2",{class:"text-lg font-semibold text-foreground"},"Settings",-1)),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[t(S,{class:"text-sm"},{default:s(()=>[...e[6]||(e[6]=[u("Profile",-1)])]),_:1})]),_:1}),t(V,{class:"space-y-4"},{default:s(()=>[a("div",K,[e[7]||(e[7]=a("label",{class:"text-sm font-medium text-foreground"},"Username",-1)),t(y,{modelValue:o.value,"onUpdate:modelValue":e[0]||(e[0]=r=>o.value=r),placeholder:"username"},null,8,["modelValue"])]),a("div",M,[e[8]||(e[8]=a("label",{class:"text-sm font-medium text-foreground"},"Daily Overhead Hours",-1)),t(y,{modelValue:p.value,"onUpdate:modelValue":e[1]||(e[1]=r=>p.value=r),type:"number",min:"0",max:"8",step:"0.25",class:"w-32"},null,8,["modelValue"]),e[9]||(e[9]=a("p",{class:"text-xs text-muted-foreground"}," Hours per day to add for overhead / meetings ",-1))]),t(_,{loading:g.value,onClick:D},{default:s(()=>[...e[10]||(e[10]=[u("Save Profile",-1)])]),_:1},8,["loading"])]),_:1})]),_:1}),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[a("div",j,[t(S,{class:"text-sm"},{default:s(()=>[...e[11]||(e[11]=[u("Azure DevOps Integration",-1)])]),_:1}),c(n).integration?(k(),z(_,{key:0,variant:"outline",size:"sm",loading:c(n).syncing,onClick:b},{default:s(()=>[...e[12]||(e[12]=[u(" Sync Now ",-1)])]),_:1},8,["loading"])):B("",!0)])]),_:1}),t(V,null,{default:s(()=>[t(O)]),_:1})]),_:1}),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[t(S,{class:"text-sm"},{default:s(()=>[...e[13]||(e[13]=[u("Export",-1)])]),_:1})]),_:1}),t(V,{class:"space-y-4"},{default:s(()=>[a("div",q,[a("div",h,[e[14]||(e[14]=a("label",{class:"text-xs text-muted-foreground"},"From",-1)),t(y,{modelValue:d.value,"onUpdate:modelValue":e[2]||(e[2]=r=>d.value=r),type:"date",class:"h-8 text-xs"},null,8,["modelValue"])]),a("div",G,[e[15]||(e[15]=a("label",{class:"text-xs text-muted-foreground"},"To",-1)),t(y,{modelValue:m.value,"onUpdate:modelValue":e[3]||(e[3]=r=>m.value=r),type:"date",class:"h-8 text-xs"},null,8,["modelValue"])])]),a("div",J,[t(_,{variant:"outline",size:"sm",onClick:e[4]||(e[4]=r=>c(T)(d.value,m.value))},{default:s(()=>[...e[16]||(e[16]=[u(" Download CSV ",-1)])]),_:1}),t(_,{variant:"outline",size:"sm",onClick:e[5]||(e[5]=r=>c(A)(d.value,m.value))},{default:s(()=>[...e[17]||(e[17]=[u(" Download ICS ",-1)])]),_:1})])]),_:1})]),_:1})]))}});export{se as default};
|
||||
|
|
@ -1 +1 @@
|
|||
import{d as l,o as n,c as o,n as t,a as r}from"./index-DxmLkgMg.js";const i=l({__name:"Spinner",props:{size:{},class:{}},setup(s){return(a,e)=>(n(),o("svg",{class:t(["animate-spin text-current",s.size==="sm"?"h-3 w-3":s.size==="lg"?"h-6 w-6":"h-4 w-4",a.$props.class]),xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},[...e[0]||(e[0]=[r("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),r("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"},null,-1)])],2))}});export{i as _};
|
||||
import{d as l,o as n,c as o,n as t,a as r}from"./index-DMlmI4VG.js";const i=l({__name:"Spinner",props:{size:{},class:{}},setup(s){return(a,e)=>(n(),o("svg",{class:t(["animate-spin text-current",s.size==="sm"?"h-3 w-3":s.size==="lg"?"h-6 w-6":"h-4 w-4",a.$props.class]),xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},[...e[0]||(e[0]=[r("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),r("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"},null,-1)])],2))}});export{i as _};
|
||||
File diff suppressed because one or more lines are too long
1
src/static/assets/TasksView-BcEx816y.js
Normal file
1
src/static/assets/TasksView-BcEx816y.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
import{c as r}from"./utils-7WVCegLb.js";import{d as t,o as n,c as i,n as u,h as c}from"./index-DMlmI4VG.js";const m=["id","value","placeholder","disabled","rows"],g=t({__name:"Textarea",props:{modelValue:{},placeholder:{},disabled:{type:Boolean},rows:{},class:{},id:{}},emits:["update:modelValue"],setup(e,{emit:l}){const a=e,s=l;return(f,o)=>(n(),i("textarea",{id:e.id,value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,rows:e.rows??3,class:u(c(r)("flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm","ring-offset-background placeholder:text-muted-foreground","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-50 resize-none",a.class)),onInput:o[0]||(o[0]=d=>s("update:modelValue",d.target.value))},null,42,m))}});export{g as _};
|
||||
|
|
@ -1 +1 @@
|
|||
import{E as e}from"./index-DxmLkgMg.js";const i={users:()=>e.get("/api/admin/users"),keys:()=>e.get("/api/keys"),createKey:s=>e.post("/api/keys",s),revokeKey:s=>e.delete(`/api/keys/${s}`)};export{i as a};
|
||||
import{E as e}from"./index-DMlmI4VG.js";const i={users:()=>e.get("/api/admin/users"),keys:()=>e.get("/api/keys"),createKey:s=>e.post("/api/keys",s),revokeKey:s=>e.delete(`/api/keys/${s}`)};export{i as a};
|
||||
|
|
@ -1 +1 @@
|
|||
import{E as t}from"./index-DxmLkgMg.js";const e={summary:a=>t.get("/api/dashboard/summary",{params:a}),projects:a=>t.get("/api/dashboard/projects",{params:a}),timeline:a=>t.get("/api/dashboard/timeline",{params:a}),monthly:a=>t.get("/api/dashboard/monthly",{params:a}),dow:a=>t.get("/api/dashboard/dow",{params:a}),tools:a=>t.get("/api/dashboard/tools",{params:a}),activity:a=>t.get("/api/dashboard/activity",{params:a}),calendar:a=>t.get("/api/dashboard/calendar",{params:a}),project:(a,o)=>t.get("/api/dashboard/project/"+a,{params:o})};export{e as d};
|
||||
import{E as t}from"./index-DMlmI4VG.js";const e={summary:a=>t.get("/api/dashboard/summary",{params:a}),projects:a=>t.get("/api/dashboard/projects",{params:a}),timeline:a=>t.get("/api/dashboard/timeline",{params:a}),monthly:a=>t.get("/api/dashboard/monthly",{params:a}),dow:a=>t.get("/api/dashboard/dow",{params:a}),tools:a=>t.get("/api/dashboard/tools",{params:a}),activity:a=>t.get("/api/dashboard/activity",{params:a}),calendar:a=>t.get("/api/dashboard/calendar",{params:a}),project:(a,o)=>t.get("/api/dashboard/project/"+a,{params:o})};export{e as d};
|
||||
|
|
@ -1 +0,0 @@
|
|||
import{E as s,C as I,s as o}from"./index-DxmLkgMg.js";const i={getIntegration:()=>s.get("/api/devops/integration"),saveIntegration:e=>s.put("/api/devops/integration",e),deleteIntegration:()=>s.delete("/api/devops/integration"),sync:()=>s.post("/api/devops/sync"),workItems:e=>s.get("/api/devops/work-items",{params:e?{state:e}:void 0})},m=I("devops",()=>{const e=o(null),l=o([]),r=o(!1),n=o(!1),c=o(null);async function u(){n.value=!0;try{const t=await i.getIntegration();e.value=t.data}catch{e.value=null}finally{n.value=!1}}async function d(t){const a=await i.saveIntegration(t);e.value=a.data}async function g(){await i.deleteIntegration(),e.value=null}async function f(){var t,a;r.value=!0,c.value=null;try{await i.sync(),await u()}catch(v){const p=v;throw c.value=((a=(t=p.response)==null?void 0:t.data)==null?void 0:a.detail)??p.message??"Sync failed",v}finally{r.value=!1}}async function y(t){n.value=!0;try{const a=await i.workItems(t);l.value=a.data}catch{l.value=[]}finally{n.value=!1}}return{integration:e,workItems:l,syncing:r,loading:n,error:c,fetchIntegration:u,saveIntegration:d,deleteIntegration:g,sync:f,fetchWorkItems:y}});export{m as u};
|
||||
1
src/static/assets/devops-roMxSiNP.js
Normal file
1
src/static/assets/devops-roMxSiNP.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{E as n,C as I,s as o}from"./index-DMlmI4VG.js";const i={getIntegration:()=>n.get("/api/devops/integration"),saveIntegration:e=>n.put("/api/devops/integration",e),deleteIntegration:()=>n.delete("/api/devops/integration"),sync:()=>n.post("/api/devops/sync"),workItems:e=>n.get("/api/devops/work-items",{params:e?{state:e}:void 0}),cloneWorkItem:e=>n.post(`/api/devops/work-items/${e}/clone`)},m=I("devops",()=>{const e=o(null),l=o([]),r=o(!1),s=o(!1),c=o(null);async function u(){s.value=!0;try{const t=await i.getIntegration();e.value=t.data}catch{e.value=null}finally{s.value=!1}}async function d(t){const a=await i.saveIntegration(t);e.value=a.data}async function g(){await i.deleteIntegration(),e.value=null}async function f(){var t,a;r.value=!0,c.value=null;try{await i.sync(),await u()}catch(v){const p=v;throw c.value=((a=(t=p.response)==null?void 0:t.data)==null?void 0:a.detail)??p.message??"Sync failed",v}finally{r.value=!1}}async function y(t){s.value=!0;try{const a=await i.workItems(t);l.value=a.data}catch{l.value=[]}finally{s.value=!1}}return{integration:e,workItems:l,syncing:r,loading:s,error:c,fetchIntegration:u,saveIntegration:d,deleteIntegration:g,sync:f,fetchWorkItems:y}});export{i as d,m as u};
|
||||
1
src/static/assets/index-CukCWBzu.css
Normal file
1
src/static/assets/index-CukCWBzu.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
import{E as l,C as w,s as i}from"./index-DxmLkgMg.js";const o={list:a=>l.get("/api/tasks",{params:a}),get:a=>l.get(`/api/tasks/${a}`),create:a=>l.post("/api/tasks",a),update:(a,s)=>l.patch(`/api/tasks/${a}`,s),remove:a=>l.delete(`/api/tasks/${a}`),complete:a=>l.post(`/api/tasks/${a}/complete`),blocks:a=>l.get(`/api/tasks/${a}/blocks`),createBlock:(a,s)=>l.post(`/api/tasks/${a}/blocks`,s),updateBlock:(a,s)=>l.patch(`/api/tasks/blocks/${a}`,s),deleteBlock:a=>l.delete(`/api/tasks/blocks/${a}`)},b=Object.freeze(Object.defineProperty({__proto__:null,tasksApi:o},Symbol.toStringTag,{value:"Module"})),$=w("tasks",()=>{const a=i([]),s=i(!1),n=i(null);async function u(t){s.value=!0,n.value=null;try{const e=await o.list({date:t});a.value=e.data}catch(e){const c=e;n.value=c.message??"Failed to fetch tasks"}finally{s.value=!1}}async function d(t){s.value=!0,n.value=null;try{const e=await o.list(t?{project_id:t}:void 0);a.value=e.data}catch(e){const c=e;n.value=c.message??"Failed to fetch tasks"}finally{s.value=!1}}async function p(t){const e=await o.create(t);return a.value.push(e.data),e.data}async function k(t,e){const c=await o.update(t,e),r=a.value.findIndex(h=>h.id===t);return r!==-1&&(a.value[r]=c.data),c.data}async function f(t){await o.remove(t),a.value=a.value.filter(e=>e.id!==t)}async function v(t){const e=await o.complete(t),c=a.value.findIndex(r=>r.id===t);return c!==-1&&(a.value[c]=e.data),e.data}async function y(t,e){return(await o.createBlock(t,e)).data}async function m(t,e){return(await o.updateBlock(t,e)).data}async function g(t){await o.deleteBlock(t)}return{tasks:a,loading:s,error:n,fetchForDate:u,fetchAll:d,create:p,update:k,remove:f,complete:v,createBlock:y,updateBlock:m,deleteBlock:g}});export{b as t,$ as u};
|
||||
import{E as l,C as w,s as i}from"./index-DMlmI4VG.js";const o={list:a=>l.get("/api/tasks",{params:a}),get:a=>l.get(`/api/tasks/${a}`),create:a=>l.post("/api/tasks",a),update:(a,s)=>l.patch(`/api/tasks/${a}`,s),remove:a=>l.delete(`/api/tasks/${a}`),complete:a=>l.post(`/api/tasks/${a}/complete`),blocks:a=>l.get(`/api/tasks/${a}/blocks`),createBlock:(a,s)=>l.post(`/api/tasks/${a}/blocks`,s),updateBlock:(a,s)=>l.patch(`/api/tasks/blocks/${a}`,s),deleteBlock:a=>l.delete(`/api/tasks/blocks/${a}`)},b=Object.freeze(Object.defineProperty({__proto__:null,tasksApi:o},Symbol.toStringTag,{value:"Module"})),$=w("tasks",()=>{const a=i([]),s=i(!1),n=i(null);async function u(t){s.value=!0,n.value=null;try{const e=await o.list({date:t});a.value=e.data}catch(e){const c=e;n.value=c.message??"Failed to fetch tasks"}finally{s.value=!1}}async function d(t){s.value=!0,n.value=null;try{const e=await o.list(t?{project_id:t}:void 0);a.value=e.data}catch(e){const c=e;n.value=c.message??"Failed to fetch tasks"}finally{s.value=!1}}async function p(t){const e=await o.create(t);return a.value.push(e.data),e.data}async function k(t,e){const c=await o.update(t,e),r=a.value.findIndex(h=>h.id===t);return r!==-1&&(a.value[r]=c.data),c.data}async function f(t){await o.remove(t),a.value=a.value.filter(e=>e.id!==t)}async function v(t){const e=await o.complete(t),c=a.value.findIndex(r=>r.id===t);return c!==-1&&(a.value[c]=e.data),e.data}async function y(t,e){return(await o.createBlock(t,e)).data}async function m(t,e){return(await o.updateBlock(t,e)).data}async function g(t){await o.deleteBlock(t)}return{tasks:a,loading:s,error:n,fetchForDate:u,fetchAll:d,create:p,update:k,remove:f,complete:v,createBlock:y,updateBlock:m,deleteBlock:g}});export{b as t,$ as u};
|
||||
|
|
@ -14,8 +14,8 @@
|
|||
else { document.documentElement.classList.remove('dark'); }
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/cc-dashboard/static/assets/index-DxmLkgMg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/cc-dashboard/static/assets/index-DXTScSCX.css">
|
||||
<script type="module" crossorigin src="/cc-dashboard/static/assets/index-DMlmI4VG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/cc-dashboard/static/assets/index-CukCWBzu.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -24,4 +24,7 @@ export const devopsApi = {
|
|||
apiClient.get<AzureWorkItem[]>('/api/devops/work-items', {
|
||||
params: state ? { state } : undefined,
|
||||
}),
|
||||
|
||||
cloneWorkItem: (id: string) =>
|
||||
apiClient.post(`/api/devops/work-items/${id}/clone`),
|
||||
}
|
||||
|
|
|
|||
23
web/src/api/endpoints/omg.ts
Normal file
23
web/src/api/endpoints/omg.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import apiClient from '@/api/client'
|
||||
import type { OmgEntry } from '@/types'
|
||||
|
||||
export interface OmgEntryPayload {
|
||||
name: string
|
||||
client?: string
|
||||
job_number?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export const omgApi = {
|
||||
list: () =>
|
||||
apiClient.get<OmgEntry[]>('/api/omg'),
|
||||
|
||||
create: (payload: OmgEntryPayload) =>
|
||||
apiClient.post<OmgEntry>('/api/omg', payload),
|
||||
|
||||
update: (id: string, payload: Partial<OmgEntryPayload>) =>
|
||||
apiClient.patch<OmgEntry>(`/api/omg/${id}`, payload),
|
||||
|
||||
remove: (id: string) =>
|
||||
apiClient.delete(`/api/omg/${id}`),
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ const statusVariant = (status: Task['status']) => {
|
|||
const map: Record<Task['status'], 'default' | 'secondary' | 'success' | 'warning' | 'outline'> = {
|
||||
todo: 'outline',
|
||||
doing: 'default',
|
||||
testing: 'warning',
|
||||
done: 'success',
|
||||
cancelled: 'secondary',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ const pageTitle = computed(() => {
|
|||
live: 'Live Feed',
|
||||
reports: 'AI Reports',
|
||||
keys: 'API Keys',
|
||||
tasks: 'Tasks',
|
||||
omg: 'OMG',
|
||||
devops: 'Azure DevOps',
|
||||
settings: 'Settings',
|
||||
admin: 'Admin',
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ interface NavItem {
|
|||
const navItems: NavItem[] = [
|
||||
{ name: 'Dashboard', path: '/', icon: 'grid' },
|
||||
{ name: 'Calendar', path: '/calendar', icon: 'calendar' },
|
||||
{ name: 'Tasks', path: '/board/', icon: 'check-square', external: true },
|
||||
{ name: 'Tasks', path: '/tasks', icon: 'check-square' },
|
||||
{ name: 'OMG', path: '/omg', icon: 'omg' },
|
||||
{ name: 'Projects', path: '/projects', icon: 'folder' },
|
||||
{ name: 'Live Feed', path: '/live', icon: 'activity' },
|
||||
{ name: 'Reports', path: '/reports', icon: 'file-text' },
|
||||
|
|
@ -98,6 +99,9 @@ const userInitials = computed(() => {
|
|||
<svg v-else-if="item.icon === 'check-square'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
<svg v-else-if="item.icon === 'omg'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else-if="item.icon === 'folder'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
||||
</svg>
|
||||
|
|
|
|||
72
web/src/components/tasks/KanbanCard.vue
Normal file
72
web/src/components/tasks/KanbanCard.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<script setup lang="ts">
|
||||
import type { Task } from '@/types'
|
||||
|
||||
const props = defineProps<{ task: Task; dragging: boolean }>()
|
||||
const emit = defineEmits<{ edit: [task: Task] }>()
|
||||
|
||||
const priorityDot: Record<number, string> = {
|
||||
1: 'bg-red-500',
|
||||
2: 'bg-orange-400',
|
||||
3: 'bg-slate-300',
|
||||
4: 'bg-slate-300',
|
||||
5: 'bg-slate-300',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
todo: '',
|
||||
doing: 'in progress',
|
||||
testing: 'testing',
|
||||
done: 'done',
|
||||
cancelled: 'cancelled',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'group relative bg-white rounded-xl border border-slate-200/80 px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm hover:shadow-md hover:border-orange-200 transition-all duration-150 select-none',
|
||||
dragging ? 'opacity-40 scale-95' : 'opacity-100',
|
||||
]"
|
||||
draggable="true"
|
||||
@click="emit('edit', task)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<!-- Priority dot -->
|
||||
<span
|
||||
:class="['mt-1.5 h-2 w-2 rounded-full shrink-0', priorityDot[task.priority] ?? 'bg-slate-300']"
|
||||
:title="`Priority ${task.priority}`"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-slate-800 leading-snug line-clamp-2">{{ task.title }}</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
<!-- Project chip -->
|
||||
<span
|
||||
v-if="task.project_name"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-500 font-medium truncate max-w-[100px]"
|
||||
:title="task.project_name"
|
||||
>
|
||||
{{ task.project_name }}
|
||||
</span>
|
||||
|
||||
<!-- ADO badge -->
|
||||
<span
|
||||
v-if="task.azure_work_item_id"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-md bg-blue-50 text-blue-500 font-medium shrink-0"
|
||||
>
|
||||
ADO
|
||||
</span>
|
||||
|
||||
<!-- Planned date -->
|
||||
<span
|
||||
v-if="task.planned_date"
|
||||
class="text-[10px] text-slate-400 shrink-0"
|
||||
>
|
||||
{{ task.planned_date }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
84
web/src/components/tasks/KanbanColumn.vue
Normal file
84
web/src/components/tasks/KanbanColumn.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script setup lang="ts">
|
||||
import KanbanCard from './KanbanCard.vue'
|
||||
import type { Task } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
status: string
|
||||
title: string
|
||||
tasks: Task[]
|
||||
draggingId: string | null
|
||||
isDragOver: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dragStart: [task: Task, e: DragEvent]
|
||||
dragEnd: []
|
||||
dragOver: [status: string, e: DragEvent]
|
||||
dragLeave: []
|
||||
drop: [status: string, index: number, e: DragEvent]
|
||||
editTask: [task: Task]
|
||||
addTask: [status: string]
|
||||
}>()
|
||||
|
||||
const columnStyle: Record<string, string> = {
|
||||
todo: 'border-t-slate-300',
|
||||
doing: 'border-t-orange-400',
|
||||
testing: 'border-t-blue-400',
|
||||
done: 'border-t-emerald-400',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-0 w-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-1 mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-slate-500">{{ title }}</h3>
|
||||
<span class="text-[10px] font-semibold bg-slate-100 text-slate-400 px-1.5 py-0.5 rounded-full leading-none">
|
||||
{{ tasks.length }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="h-5 w-5 rounded flex items-center justify-center text-slate-400 hover:text-orange-500 hover:bg-orange-50 transition-colors"
|
||||
title="Add task"
|
||||
@click="emit('addTask', status)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
:class="[
|
||||
'flex-1 rounded-xl border-2 border-dashed transition-all duration-150 p-2 space-y-2 min-h-[120px]',
|
||||
`border-t-4 ${columnStyle[status] ?? 'border-t-slate-300'}`,
|
||||
isDragOver
|
||||
? 'border-orange-300 bg-orange-50/60'
|
||||
: 'border-transparent bg-slate-50/60',
|
||||
]"
|
||||
@dragover="emit('dragOver', status, $event)"
|
||||
@dragleave="emit('dragLeave')"
|
||||
@drop="emit('drop', status, tasks.length, $event)"
|
||||
>
|
||||
<KanbanCard
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:dragging="draggingId === task.id"
|
||||
@dragstart="emit('dragStart', task, $event)"
|
||||
@dragend="emit('dragEnd')"
|
||||
@edit="emit('editTask', task)"
|
||||
/>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="tasks.length === 0 && !isDragOver"
|
||||
class="flex items-center justify-center h-16 text-[11px] text-slate-300 select-none"
|
||||
>
|
||||
Drop here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -174,6 +174,7 @@ async function handleSave() {
|
|||
<Select v-model="form.status" :disabled="saving">
|
||||
<option value="todo">Todo</option>
|
||||
<option value="doing">Doing</option>
|
||||
<option value="testing">Testing</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</Select>
|
||||
|
|
|
|||
41
web/src/composables/useKanbanDnD.ts
Normal file
41
web/src/composables/useKanbanDnD.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { ref } from 'vue'
|
||||
import { useTasksStore } from '@/stores/tasks'
|
||||
import type { Task } from '@/types'
|
||||
|
||||
export function useKanbanDnD() {
|
||||
const tasksStore = useTasksStore()
|
||||
const draggingId = ref<string | null>(null)
|
||||
const dragOverColumn = ref<string | null>(null)
|
||||
|
||||
function onDragStart(task: Task, e: DragEvent) {
|
||||
draggingId.value = task.id
|
||||
e.dataTransfer?.setData('task_id', task.id)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null
|
||||
dragOverColumn.value = null
|
||||
}
|
||||
|
||||
function onDragOver(status: string, e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragOverColumn.value = status
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverColumn.value = null
|
||||
}
|
||||
|
||||
async function onDrop(targetStatus: string, targetIndex: number, e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragOverColumn.value = null
|
||||
const id = e.dataTransfer?.getData('task_id')
|
||||
if (!id) return
|
||||
draggingId.value = null
|
||||
await tasksStore.update(id, { status: targetStatus as Task['status'], sort_index: targetIndex })
|
||||
}
|
||||
|
||||
return { draggingId, dragOverColumn, onDragStart, onDragEnd, onDragOver, onDragLeave, onDrop }
|
||||
}
|
||||
|
|
@ -22,6 +22,16 @@ const routes = [
|
|||
name: 'calendar',
|
||||
component: () => import('@/views/CalendarView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'tasks',
|
||||
component: () => import('@/views/TasksView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'omg',
|
||||
name: 'omg',
|
||||
component: () => import('@/views/OmgView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
name: 'projects',
|
||||
|
|
|
|||
|
|
@ -27,13 +27,17 @@ export interface Task {
|
|||
planned_date: string
|
||||
estimate_hours: number
|
||||
actual_hours: number
|
||||
status: 'todo' | 'doing' | 'done' | 'cancelled'
|
||||
status: 'todo' | 'doing' | 'testing' | 'done' | 'cancelled'
|
||||
priority: 1 | 2 | 3 | 4 | 5
|
||||
sort_index: number
|
||||
project_id: string | null
|
||||
azure_work_item_id: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
tags?: TagBrief[]
|
||||
project_name?: string
|
||||
job_number?: string
|
||||
work_item_title?: string
|
||||
}
|
||||
|
||||
export interface TaskBlock {
|
||||
|
|
@ -227,3 +231,13 @@ export interface AzureWorkItem {
|
|||
priority?: number
|
||||
created_date?: string
|
||||
}
|
||||
|
||||
export interface OmgEntry {
|
||||
id: string
|
||||
name: string
|
||||
client: string
|
||||
job_number: string
|
||||
notes: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ const DOW_LABELS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
|||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-xs font-semibold text-muted-foreground uppercase tracking-widest">Tasks Today</CardTitle>
|
||||
<RouterLink to="/planner" class="text-xs text-primary hover:underline">View all →</RouterLink>
|
||||
<RouterLink to="/tasks" class="text-xs text-primary hover:underline">View all →</RouterLink>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDevopsStore } from '@/stores/devops'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { devopsApi } from '@/api/endpoints/devops'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
|
|
@ -14,7 +14,6 @@ import DevopsConnectForm from '@/components/devops/DevopsConnectForm.vue'
|
|||
import { toast } from 'vue-sonner'
|
||||
|
||||
const devopsStore = useDevopsStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
type StateFilter = 'All' | 'Active' | 'Resolved' | 'Closed'
|
||||
const stateFilter = ref<StateFilter>('All')
|
||||
|
|
@ -59,11 +58,7 @@ async function cloneToTasks(wiId: string, e: Event) {
|
|||
if (cloningId.value) return
|
||||
cloningId.value = wiId
|
||||
try {
|
||||
const res = await fetch(`/cc-dashboard/api/devops/work-items/${wiId}/clone`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await devopsApi.cloneWorkItem(wiId)
|
||||
toast.success('Cloned to Tasks')
|
||||
} catch {
|
||||
toast.error('Failed to clone')
|
||||
|
|
|
|||
283
web/src/views/OmgView.vue
Normal file
283
web/src/views/OmgView.vue
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { omgApi, type OmgEntryPayload } from '@/api/endpoints/omg'
|
||||
import Dialog from '@/components/ui/Dialog.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Textarea from '@/components/ui/Textarea.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Spinner from '@/components/ui/Spinner.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { OmgEntry } from '@/types'
|
||||
|
||||
const entries = ref<OmgEntry[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// Dialog state
|
||||
const dialogOpen = ref(false)
|
||||
const editingEntry = ref<OmgEntry | null>(null)
|
||||
const saving = ref(false)
|
||||
const form = ref({ name: '', client: '', job_number: '', notes: '' })
|
||||
|
||||
// Inline edit state
|
||||
const inlineEdit = ref<{ id: string; field: 'name' | 'client' | 'job_number' } | null>(null)
|
||||
const inlineValue = ref('')
|
||||
|
||||
onMounted(loadEntries)
|
||||
|
||||
async function loadEntries() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await omgApi.list()
|
||||
entries.value = res.data
|
||||
} catch {
|
||||
toast.error('Failed to load entries')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
editingEntry.value = null
|
||||
form.value = { name: '', client: '', job_number: '', notes: '' }
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(entry: OmgEntry) {
|
||||
editingEntry.value = entry
|
||||
form.value = { name: entry.name, client: entry.client, job_number: entry.job_number, notes: entry.notes }
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogOpen.value = false
|
||||
editingEntry.value = null
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.name.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: OmgEntryPayload = {
|
||||
name: form.value.name.trim(),
|
||||
client: form.value.client.trim(),
|
||||
job_number: form.value.job_number.trim(),
|
||||
notes: form.value.notes.trim(),
|
||||
}
|
||||
if (editingEntry.value) {
|
||||
const res = await omgApi.update(editingEntry.value.id, payload)
|
||||
const idx = entries.value.findIndex(e => e.id === editingEntry.value!.id)
|
||||
if (idx !== -1) entries.value[idx] = res.data
|
||||
toast.success('Entry updated')
|
||||
} else {
|
||||
const res = await omgApi.create(payload)
|
||||
entries.value.push(res.data)
|
||||
entries.value.sort((a, b) => a.name.localeCompare(b.name))
|
||||
toast.success('Entry created')
|
||||
}
|
||||
closeDialog()
|
||||
} catch {
|
||||
toast.error('Failed to save entry')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(entry: OmgEntry) {
|
||||
try {
|
||||
await omgApi.remove(entry.id)
|
||||
entries.value = entries.value.filter(e => e.id !== entry.id)
|
||||
toast.success('Entry deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete entry')
|
||||
}
|
||||
}
|
||||
|
||||
// Inline editing
|
||||
function startInline(entry: OmgEntry, field: 'name' | 'client' | 'job_number') {
|
||||
inlineEdit.value = { id: entry.id, field }
|
||||
inlineValue.value = entry[field]
|
||||
}
|
||||
|
||||
async function commitInline(entry: OmgEntry) {
|
||||
if (!inlineEdit.value) return
|
||||
const field = inlineEdit.value.field
|
||||
const val = inlineValue.value.trim()
|
||||
if (val === entry[field]) {
|
||||
inlineEdit.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await omgApi.update(entry.id, { [field]: val })
|
||||
const idx = entries.value.findIndex(e => e.id === entry.id)
|
||||
if (idx !== -1) entries.value[idx] = res.data
|
||||
} catch {
|
||||
toast.error('Failed to update')
|
||||
}
|
||||
inlineEdit.value = null
|
||||
}
|
||||
|
||||
function cancelInline() {
|
||||
inlineEdit.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<h2 class="text-lg font-semibold text-foreground flex-1">OMG</h2>
|
||||
<Button size="sm" @click="openNew">
|
||||
<svg class="h-3.5 w-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add entry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-40">
|
||||
<Spinner size="lg" class="text-primary" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="entries.length === 0" class="text-center text-muted-foreground py-16">
|
||||
No entries yet. Click "Add entry" to create one.
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="border border-border rounded-xl overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="grid grid-cols-[1fr_1fr_140px_80px] gap-4 px-4 py-2.5 bg-muted/30 border-b border-border text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span>Project name</span>
|
||||
<span>Client</span>
|
||||
<span>Job #</span>
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
<!-- Rows -->
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="grid grid-cols-[1fr_1fr_140px_80px] gap-4 px-4 py-3 border-b border-border last:border-0 items-center hover:bg-muted/10 transition-colors"
|
||||
>
|
||||
<!-- Name (inline edit on dblclick) -->
|
||||
<div>
|
||||
<input
|
||||
v-if="inlineEdit?.id === entry.id && inlineEdit.field === 'name'"
|
||||
:value="inlineValue"
|
||||
class="w-full text-sm bg-transparent border-b border-primary outline-none py-0.5"
|
||||
autofocus
|
||||
@input="inlineValue = ($event.target as HTMLInputElement).value"
|
||||
@blur="commitInline(entry)"
|
||||
@keydown.enter="commitInline(entry)"
|
||||
@keydown.escape="cancelInline"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm font-medium text-foreground cursor-pointer hover:text-primary transition-colors"
|
||||
title="Double-click to edit"
|
||||
@dblclick="startInline(entry, 'name')"
|
||||
>{{ entry.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Client -->
|
||||
<div>
|
||||
<input
|
||||
v-if="inlineEdit?.id === entry.id && inlineEdit.field === 'client'"
|
||||
:value="inlineValue"
|
||||
class="w-full text-sm bg-transparent border-b border-primary outline-none py-0.5"
|
||||
autofocus
|
||||
@input="inlineValue = ($event.target as HTMLInputElement).value"
|
||||
@blur="commitInline(entry)"
|
||||
@keydown.enter="commitInline(entry)"
|
||||
@keydown.escape="cancelInline"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
:class="entry.client ? '' : 'italic opacity-40'"
|
||||
title="Double-click to edit"
|
||||
@dblclick="startInline(entry, 'client')"
|
||||
>{{ entry.client || 'No client' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Job # -->
|
||||
<div>
|
||||
<input
|
||||
v-if="inlineEdit?.id === entry.id && inlineEdit.field === 'job_number'"
|
||||
:value="inlineValue"
|
||||
class="w-full text-sm bg-transparent border-b border-primary outline-none py-0.5"
|
||||
autofocus
|
||||
@input="inlineValue = ($event.target as HTMLInputElement).value"
|
||||
@blur="commitInline(entry)"
|
||||
@keydown.enter="commitInline(entry)"
|
||||
@keydown.escape="cancelInline"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm tabular-nums cursor-pointer hover:text-foreground transition-colors"
|
||||
:class="entry.job_number ? 'text-foreground' : 'text-muted-foreground/40 italic'"
|
||||
title="Double-click to edit"
|
||||
@dblclick="startInline(entry, 'job_number')"
|
||||
>{{ entry.job_number || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
class="h-7 w-7 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Edit"
|
||||
@click="openEdit(entry)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="h-7 w-7 rounded flex items-center justify-center text-muted-foreground hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
title="Delete"
|
||||
@click="handleDelete(entry)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add / Edit dialog -->
|
||||
<Dialog
|
||||
:open="dialogOpen"
|
||||
:title="editingEntry ? 'Edit Entry' : 'New Entry'"
|
||||
max-width="max-w-md"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<form class="space-y-4" @submit.prevent="handleSave">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-foreground">Project name *</label>
|
||||
<Input v-model="form.name" placeholder="Project name..." :disabled="saving" autofocus />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-foreground">Client</label>
|
||||
<Input v-model="form.client" placeholder="Client..." :disabled="saving" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-foreground">Job #</label>
|
||||
<Input v-model="form.job_number" placeholder="J-001" :disabled="saving" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-foreground">Notes</label>
|
||||
<Textarea v-model="form.notes" placeholder="Additional notes..." :disabled="saving" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" :disabled="saving" @click="closeDialog">Cancel</Button>
|
||||
<Button :loading="saving" @click="handleSave">
|
||||
{{ editingEntry ? 'Update' : 'Create' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
172
web/src/views/TasksView.vue
Normal file
172
web/src/views/TasksView.vue
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTasksStore } from '@/stores/tasks'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { useKanbanDnD } from '@/composables/useKanbanDnD'
|
||||
import KanbanColumn from '@/components/tasks/KanbanColumn.vue'
|
||||
import TaskForm from '@/components/tasks/TaskForm.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Select from '@/components/ui/Select.vue'
|
||||
import Spinner from '@/components/ui/Spinner.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { Task } from '@/types'
|
||||
import type { TaskCreatePayload, TaskUpdatePayload } from '@/api/endpoints/tasks'
|
||||
|
||||
const tasksStore = useTasksStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
const dnd = useKanbanDnD()
|
||||
|
||||
const filterProject = ref<string>('')
|
||||
const dialogOpen = ref(false)
|
||||
const editingTask = ref<Task | null>(null)
|
||||
const newTaskStatus = ref<string>('todo')
|
||||
|
||||
const COLUMNS = [
|
||||
{ status: 'todo', title: 'To Do' },
|
||||
{ status: 'doing', title: 'Doing' },
|
||||
{ status: 'testing', title: 'Testing' },
|
||||
{ status: 'done', title: 'Done' },
|
||||
]
|
||||
|
||||
const DONE_LIMIT = 30
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
tasksStore.fetchAll(),
|
||||
projectsStore.fetchProjects(),
|
||||
])
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const tasks = tasksStore.tasks.filter(t => t.status !== 'cancelled')
|
||||
if (!filterProject.value) return tasks
|
||||
return tasks.filter(t => t.project_id === filterProject.value)
|
||||
})
|
||||
|
||||
function tasksForColumn(status: string): Task[] {
|
||||
let col = filteredTasks.value.filter(t => t.status === status)
|
||||
col = [...col].sort((a, b) => a.sort_index - b.sort_index || a.created_at.localeCompare(b.created_at))
|
||||
if (status === 'done') col = col.slice(-DONE_LIMIT)
|
||||
return col
|
||||
}
|
||||
|
||||
function openNew(status: string) {
|
||||
newTaskStatus.value = status
|
||||
editingTask.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(task: Task) {
|
||||
editingTask.value = task
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogOpen.value = false
|
||||
editingTask.value = null
|
||||
}
|
||||
|
||||
async function handleSave(
|
||||
payload: TaskCreatePayload | TaskUpdatePayload,
|
||||
block?: { start_at: string; end_at: string },
|
||||
) {
|
||||
try {
|
||||
if (editingTask.value) {
|
||||
await tasksStore.update(editingTask.value.id, payload as TaskUpdatePayload)
|
||||
if (block) {
|
||||
await tasksStore.createBlock(editingTask.value.id, block)
|
||||
}
|
||||
toast.success('Task updated')
|
||||
} else {
|
||||
const createPayload = {
|
||||
...(payload as TaskCreatePayload),
|
||||
status: (payload as TaskCreatePayload).status || newTaskStatus.value,
|
||||
} as TaskCreatePayload
|
||||
const created = await tasksStore.create(createPayload)
|
||||
if (block) {
|
||||
await tasksStore.createBlock(created.id, block)
|
||||
}
|
||||
toast.success('Task created')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save task')
|
||||
}
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-3 px-6 py-4 border-b border-slate-100 bg-white/60 shrink-0">
|
||||
<h2 class="text-sm font-semibold text-slate-800 mr-2">Kanban</h2>
|
||||
|
||||
<!-- Project filter -->
|
||||
<Select
|
||||
v-model="filterProject"
|
||||
class="w-44 text-xs"
|
||||
>
|
||||
<option value="">All projects</option>
|
||||
<option v-for="proj in projectsStore.projects" :key="proj.id" :value="proj.id">
|
||||
{{ proj.display_name }}
|
||||
</option>
|
||||
</Select>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<Button size="sm" @click="openNew('todo')">
|
||||
<svg class="h-3.5 w-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Board -->
|
||||
<div
|
||||
v-if="tasksStore.loading"
|
||||
class="flex items-center justify-center flex-1"
|
||||
>
|
||||
<Spinner size="lg" class="text-primary" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-4 p-6 h-full min-w-[700px]">
|
||||
<div
|
||||
v-for="col in COLUMNS"
|
||||
:key="col.status"
|
||||
class="flex flex-col min-h-0 overflow-y-auto"
|
||||
>
|
||||
<KanbanColumn
|
||||
:status="col.status"
|
||||
:title="col.title"
|
||||
:tasks="tasksForColumn(col.status)"
|
||||
:dragging-id="dnd.draggingId.value"
|
||||
:is-drag-over="dnd.dragOverColumn.value === col.status"
|
||||
@drag-start="dnd.onDragStart"
|
||||
@drag-end="dnd.onDragEnd"
|
||||
@drag-over="dnd.onDragOver"
|
||||
@drag-leave="dnd.onDragLeave"
|
||||
@drop="dnd.onDrop"
|
||||
@edit-task="openEdit"
|
||||
@add-task="openNew"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task form dialog -->
|
||||
<TaskForm
|
||||
:open="dialogOpen"
|
||||
:task="editingTask"
|
||||
:default-date="today"
|
||||
@close="closeDialog"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Add table
Reference in a new issue