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:
Vadym Samoilenko 2026-05-07 14:09:36 +01:00
parent 7c15f884c1
commit 36118cb759
63 changed files with 1036 additions and 114 deletions

View file

@ -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

View 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")

View file

@ -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>

View file

@ -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:

View file

@ -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)

View file

@ -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"

View file

@ -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
View 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()

View file

@ -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,

View file

@ -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}

View 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"&nbsp;", " ", text)
text = re.sub(r"&lt;", "<", text)
text = re.sub(r"&gt;", ">", text)
text = re.sub(r"&amp;", "&", 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)

View file

@ -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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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 _};

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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};

View file

@ -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};

View file

@ -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 _};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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 _};

View file

@ -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 _};

View file

@ -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};

View file

@ -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};

View file

@ -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};

File diff suppressed because one or more lines are too long

View file

@ -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 _};

View file

@ -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={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},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={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},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,`

View file

@ -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};

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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 _};

View file

@ -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};

View file

@ -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};

View file

@ -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};

View 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};

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

View file

@ -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};

View file

@ -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>

View file

@ -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`),
}

View 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}`),
}

View file

@ -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',
}

View file

@ -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',

View file

@ -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>

View 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>

View 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>

View file

@ -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>

View 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 }
}

View file

@ -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',

View file

@ -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
}

View file

@ -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>

View file

@ -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
View 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
View 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>