fix: OMG auto-sync, Projects OMG# column, ADO OMG Deliverable Number, session persistence

- Auto-create/update OmgEntry when Project.job_number changes (PATCH /api/projects);
  delete stale entry on clear; sync name/client when those fields change too
- Backfill script: scripts/backfill_omg_from_projects.py
- Projects List-view: add OMG # column with link to /omg?highlight=<job_number>;
  Grid-view badge also made clickable; OmgView supports ?highlight= deep-link with scroll+highlight
- AzureWorkItem: add omg_number column (migration 0009), extracted from
  fields_json[Custom.OMGDeliverableNumber] on sync; DevOps table shows OMG # column
  with CC-project link when matched; toolbar badge shows count of items without OMG #
- Session no longer lost on F5: refresh_token moved to HttpOnly+SameSite=Lax cookie;
  authStore.init() restores session on app start; axios interceptor retries on 401
  via cookie refresh before logging out; POST /api/auth/logout clears cookie

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-13 12:30:40 +01:00
parent b7a1dc9fdf
commit 26127061ec
67 changed files with 418 additions and 123 deletions

View file

@ -0,0 +1,26 @@
"""Add omg_number column to azure_work_items.
Revision ID: 0009
Revises: 0008
Create Date: 2026-05-13
"""
import sqlalchemy as sa
from alembic import op
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"azure_work_items",
sa.Column("omg_number", sa.String(100), nullable=False, server_default=""),
)
op.create_index("ix_azure_work_items_omg_number", "azure_work_items", ["omg_number"])
def downgrade():
op.drop_index("ix_azure_work_items_omg_number", "azure_work_items")
op.drop_column("azure_work_items", "omg_number")

View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Backfill omg_entries from projects that have a job_number but no matching entry.
docker compose exec app python scripts/backfill_omg_from_projects.py
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from sqlalchemy import select
from src.database import AsyncSessionLocal
from src.models import OmgEntry, Project
async def main():
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Project).where(Project.job_number != "")
)
projects = result.scalars().all()
created = 0
for proj in projects:
existing = await db.execute(
select(OmgEntry).where(
OmgEntry.user_id == proj.user_id,
OmgEntry.job_number == proj.job_number,
)
)
if existing.scalar_one_or_none():
continue
db.add(OmgEntry(
user_id=proj.user_id,
name=proj.display_name,
client=proj.client,
job_number=proj.job_number,
))
created += 1
await db.commit()
print(f"Backfilled {created} OMG entries from {len(projects)} projects with job_number.")
asyncio.run(main())

View file

@ -246,6 +246,7 @@ class AzureWorkItem(Base):
area_path: Mapped[str] = mapped_column(String(500), default="")
url: Mapped[str] = mapped_column(String(1000), default="")
fields_json: Mapped[dict] = mapped_column(JSONB, default=dict)
omg_number: Mapped[str] = mapped_column(String(100), default="", index=True)
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@property

View file

@ -1,17 +1,36 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import (
CurrentUser, create_access_token, create_refresh_token,
decode_token, hash_password,
decode_token,
)
from src.config import settings
from src.database import get_db
from src.models import User
from src.schemas import MicrosoftLoginRequest, RefreshRequest, TokenResponse, UserOut
from src.schemas import MicrosoftLoginRequest, TokenResponse, UserOut
from src.sso import validate_microsoft_id_token
_REFRESH_COOKIE = "refresh_token"
_REFRESH_MAX_AGE = settings.REFRESH_TOKEN_EXPIRE_DAYS * 86400
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_REFRESH_COOKIE,
value=token,
httponly=True,
secure=not settings.DEBUG,
samesite="lax",
max_age=_REFRESH_MAX_AGE,
path="/api/auth",
)
def _clear_refresh_cookie(response: Response) -> None:
response.delete_cookie(key=_REFRESH_COOKIE, path="/api/auth")
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -20,7 +39,7 @@ def _admin_set() -> set[str]:
@router.post("/microsoft", response_model=TokenResponse)
async def microsoft_sso(body: MicrosoftLoginRequest, db: AsyncSession = Depends(get_db)):
async def microsoft_sso(body: MicrosoftLoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
claims = validate_microsoft_id_token(body.id_token)
raw_email = claims.get("preferred_username") or claims.get("email") or ""
@ -68,24 +87,30 @@ async def microsoft_sso(body: MicrosoftLoginRequest, db: AsyncSession = Depends(
await db.commit()
await db.refresh(user)
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
refresh_token = create_refresh_token(user.id)
_set_refresh_cookie(response, refresh_token)
return TokenResponse(access_token=create_access_token(user.id, user.role))
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
payload = decode_token(body.refresh_token)
async def refresh(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
raw = request.cookies.get(_REFRESH_COOKIE)
if not raw:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
payload = decode_token(raw)
if payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user = await db.get(User, payload["sub"])
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
new_refresh = create_refresh_token(user.id)
_set_refresh_cookie(response, new_refresh)
return TokenResponse(access_token=create_access_token(user.id, user.role))
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(response: Response):
_clear_refresh_cookie(response)
@router.get("/me", response_model=UserOut)

View file

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import CurrentUser
from src.database import get_db
from src.models import Project
from src.models import OmgEntry, Project
from src.schemas import ProjectOut
router = APIRouter(prefix="/api/projects", tags=["projects"])
@ -28,6 +28,9 @@ async def update_project(
project = await db.get(Project, project_id)
if not project or project.user_id != user.id:
raise HTTPException(status_code=404, detail="Project not found")
old_job_number = project.job_number
if "display_name" in body:
project.display_name = str(body["display_name"])[:255]
if "client" in body:
@ -36,6 +39,61 @@ async def update_project(
project.job_number = str(body["job_number"])[:100]
if "repo_url" in body:
project.repo_url = str(body["repo_url"])[:500]
await _sync_omg_entry(user.id, project, old_job_number, body, db)
await db.commit()
await db.refresh(project)
return project
async def _sync_omg_entry(
user_id: str,
project: Project,
old_job_number: str,
changed_fields: dict,
db: AsyncSession,
) -> None:
"""Keep omg_entries in sync when project job_number / name / client changes."""
job_changed = "job_number" in changed_fields
meta_changed = "display_name" in changed_fields or "client" in changed_fields
if job_changed and old_job_number != project.job_number:
# Remove stale entry for the old number
if old_job_number:
res = await db.execute(
select(OmgEntry).where(
OmgEntry.user_id == user_id,
OmgEntry.job_number == old_job_number,
)
)
old_entry = res.scalar_one_or_none()
if old_entry:
await db.delete(old_entry)
if project.job_number:
await _upsert_omg(user_id, project, db)
elif meta_changed and project.job_number:
# Name or client changed but job_number stayed — update existing entry if any
await _upsert_omg(user_id, project, db)
async def _upsert_omg(user_id: str, project: Project, db: AsyncSession) -> None:
res = await db.execute(
select(OmgEntry).where(
OmgEntry.user_id == user_id,
OmgEntry.job_number == project.job_number,
)
)
entry = res.scalar_one_or_none()
if entry:
entry.name = project.display_name
entry.client = project.client
else:
db.add(OmgEntry(
user_id=user_id,
name=project.display_name,
client=project.client,
job_number=project.job_number,
))

View file

@ -12,7 +12,6 @@ class MicrosoftLoginRequest(BaseModel):
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
@ -382,6 +381,7 @@ class AzureWorkItemOut(BaseModel):
team_project: str = ""
priority: int = 3
created_date: str = ""
omg_number: str = ""
fields_json: dict = {}
model_config = {"from_attributes": True}

View file

@ -33,6 +33,10 @@ FIELDS = [
"Microsoft.VSTS.Common.Priority",
]
# ADO custom field for OMG deliverable number — verify exact key after first sync
# by inspecting fields_json in the DevOps UI or /api/devops/work-items endpoint
OMG_DELIVERABLE_FIELD = "Custom.OMGDeliverableNumber"
async def sync_user_work_items(user: User, db: AsyncSession) -> int:
"""Sync ADO work items for a single user. Returns count of upserted items."""
@ -88,6 +92,7 @@ async def sync_user_work_items(user: User, db: AsyncSession) -> int:
wi.area_path = f.get("System.AreaPath", "")
wi.url = f"https://dev.azure.com/{integ.organization}/_workitems/edit/{ado_id}"
wi.fields_json = f
wi.omg_number = str(f.get(OMG_DELIVERABLE_FIELD) or "").strip()
wi.synced_at = datetime.now(timezone.utc)
# Soft-archive items no longer in ADO result set

View file

@ -1 +1 @@
import{d as _,u as y,A as h,c as r,a as t,e as n,k as v,w as d,f as g,s as m,o as s,F as b,r as k,t as a,q as u,h as A}from"./index-DJpSDPva.js";import{a as w}from"./admin-BXEwmj8v.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-C9faiyKN.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-BPDqRYvu.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-DiLd-UCb.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6 space-y-8"},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=_({__name:"AdminView",setup(I){const x=y(),p=g(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){p.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"},"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(b,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 _,u as y,A as h,c as r,a as t,e as n,k as v,w as d,f as g,s as m,o as s,F as b,r as k,t as a,q as u,h as A}from"./index-DXo25S1G.js";import{a as w}from"./admin-CpfPkRBL.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-CtMCMZF8.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-fQCaDGBh.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-DlENUZyI.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6 space-y-8"},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=_({__name:"AdminView",setup(I){const x=y(),p=g(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){p.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"},"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(b,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

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-DJpSDPva.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-DXo25S1G.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-DiLd-UCb.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-DJpSDPva.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-10 w-10 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-DlENUZyI.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-DXo25S1G.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-10 w-10 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-DJpSDPva.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-DXo25S1G.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-DJpSDPva.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-DXo25S1G.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,6 +1,6 @@
import{d as b,k,w as a,h as o,V as y,o as u,e as t,X as g,Y as h,a as i,Z as C,q as s,t as c,$ as T,c as V,i as w,a0 as B,a1 as L,a2 as N,s as z}from"./index-DJpSDPva.js";import{_ as m}from"./Button.vue_vue_type_script_setup_true_lang-CXC2vi8v.js";import{_}from"./Input.vue_vue_type_script_setup_true_lang-BB-oIhte.js";import{c as A}from"./createLucideIcon-D1lNol2c.js";/**
import{d as b,k,w as a,h as o,X as y,o as u,e as t,Y as g,Z as h,a as i,$ as C,q as s,t as c,a0 as T,c as w,i as V,a1 as B,a2 as L,a3 as N,s as z}from"./index-DXo25S1G.js";import{_ as m}from"./Button.vue_vue_type_script_setup_true_lang-CFigHNuj.js";import{_}from"./Input.vue_vue_type_script_setup_true_lang-hvAdVdJA.js";import{c as A}from"./createLucideIcon-DiGgSOrB.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const $=A("CircleAlertIcon",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]]),q={class:"mb-4 flex flex-col items-center gap-3 text-center"},D={class:"flex h-10 w-10 items-center justify-center rounded-full border border-border"},I={key:0,class:"mb-4"},U={class:"mb-1.5 block text-sm text-muted-foreground"},j={class:"font-mono text-foreground"},E={class:"flex gap-2"},Y=b({__name:"ConfirmDialog",props:{open:{type:Boolean},title:{},description:{},confirmLabel:{default:"Confirm"},cancelLabel:{default:"Cancel"},variant:{default:"destructive"},confirmText:{}},emits:["update:open","confirm","cancel"],setup(e,{emit:x}){const f=e,d=x,n=z("");function v(){f.confirmText&&n.value!==f.confirmText||(d("confirm"),d("update:open",!1),n.value="")}function p(){d("cancel"),d("update:open",!1),n.value=""}return(F,l)=>(u(),k(o(y),{open:e.open,"onUpdate:open":l[1]||(l[1]=r=>d("update:open",r))},{default:a(()=>[t(o(N),null,{default:a(()=>[t(o(g),{class:"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"}),t(o(h),{class:"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-background p-6 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"},{default:a(()=>[i("div",q,[i("div",D,[t(o($),{class:"h-5 w-5 text-muted-foreground"})]),t(o(C),{class:"text-lg font-semibold tracking-tight"},{default:a(()=>[s(c(e.title),1)]),_:1}),t(o(T),{class:"text-sm text-muted-foreground"},{default:a(()=>[s(c(e.description),1)]),_:1})]),e.confirmText?(u(),V("div",I,[i("label",U,[l[2]||(l[2]=s(" Type ",-1)),i("span",j,c(e.confirmText),1),l[3]||(l[3]=s(" to confirm ",-1))]),t(_,{modelValue:n.value,"onUpdate:modelValue":l[0]||(l[0]=r=>n.value=r),placeholder:e.confirmText,class:"w-full"},null,8,["modelValue","placeholder"])])):w("",!0),i("div",E,[t(o(B),{"as-child":""},{default:a(()=>[t(m,{variant:"outline",class:"flex-1",onClick:p},{default:a(()=>[s(c(e.cancelLabel),1)]),_:1})]),_:1}),t(o(L),{"as-child":""},{default:a(()=>[t(m,{variant:e.variant==="destructive"?"destructive":"default",class:"flex-1",disabled:e.confirmText?n.value!==e.confirmText:!1,onClick:v},{default:a(()=>[s(c(e.confirmLabel),1)]),_:1},8,["variant","disabled"])]),_:1})])]),_:1})]),_:1})]),_:1},8,["open"]))}});export{Y as _};
*/const $=A("CircleAlertIcon",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]]),q={class:"mb-4 flex flex-col items-center gap-3 text-center"},D={class:"flex h-10 w-10 items-center justify-center rounded-full border border-border"},I={key:0,class:"mb-4"},U={class:"mb-1.5 block text-sm text-muted-foreground"},j={class:"font-mono text-foreground"},E={class:"flex gap-2"},Y=b({__name:"ConfirmDialog",props:{open:{type:Boolean},title:{},description:{},confirmLabel:{default:"Confirm"},cancelLabel:{default:"Cancel"},variant:{default:"destructive"},confirmText:{}},emits:["update:open","confirm","cancel"],setup(e,{emit:x}){const f=e,d=x,n=z("");function v(){f.confirmText&&n.value!==f.confirmText||(d("confirm"),d("update:open",!1),n.value="")}function p(){d("cancel"),d("update:open",!1),n.value=""}return(F,l)=>(u(),k(o(y),{open:e.open,"onUpdate:open":l[1]||(l[1]=r=>d("update:open",r))},{default:a(()=>[t(o(N),null,{default:a(()=>[t(o(g),{class:"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"}),t(o(h),{class:"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-background p-6 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"},{default:a(()=>[i("div",q,[i("div",D,[t(o($),{class:"h-5 w-5 text-muted-foreground"})]),t(o(C),{class:"text-lg font-semibold tracking-tight"},{default:a(()=>[s(c(e.title),1)]),_:1}),t(o(T),{class:"text-sm text-muted-foreground"},{default:a(()=>[s(c(e.description),1)]),_:1})]),e.confirmText?(u(),w("div",I,[i("label",U,[l[2]||(l[2]=s(" Type ",-1)),i("span",j,c(e.confirmText),1),l[3]||(l[3]=s(" to confirm ",-1))]),t(_,{modelValue:n.value,"onUpdate:modelValue":l[0]||(l[0]=r=>n.value=r),placeholder:e.confirmText,class:"w-full"},null,8,["modelValue","placeholder"])])):V("",!0),i("div",E,[t(o(B),{"as-child":""},{default:a(()=>[t(m,{variant:"outline",class:"flex-1",onClick:p},{default:a(()=>[s(c(e.cancelLabel),1)]),_:1})]),_:1}),t(o(L),{"as-child":""},{default:a(()=>[t(m,{variant:e.variant==="destructive"?"destructive":"default",class:"flex-1",disabled:e.confirmText?n.value!==e.confirmText:!1,onClick:v},{default:a(()=>[s(c(e.confirmLabel),1)]),_:1},8,["variant","disabled"])]),_:1})])]),_:1})]),_:1})]),_:1},8,["open"]))}});export{Y as _};

View file

@ -1,4 +1,4 @@
import{d as z,c as a,a as e,F as h,r as w,e as l,w as u,i as F,o,q as k,n as M,t as y,s as b,x as E,y as ee,k as v,h as m,l as K,R as P,j as D,z as T,f as te,A as oe}from"./index-DJpSDPva.js";import{d as S}from"./dashboard-BlrxnIhU.js";import{_ as B,a as V}from"./CardContent.vue_vue_type_script_setup_true_lang-C9faiyKN.js";import{_ as A,a as I}from"./CardTitle.vue_vue_type_script_setup_true_lang-_oLDZ6CL.js";import{_ as se}from"./Button.vue_vue_type_script_setup_true_lang-CXC2vi8v.js";import{c as q,f as R,i as H}from"./utils-7WVCegLb.js";import{_ as ae}from"./Skeleton.vue_vue_type_script_setup_true_lang-FxYuf1D4.js";import{c as U}from"./createLucideIcon-D1lNol2c.js";import{C as le}from"./calendar-days-CFQpHiQQ.js";import{F as re,_ as ne}from"./Progress.vue_vue_type_script_setup_true_lang-fZ7s4HRh.js";import{_ as de}from"./Tooltip.vue_vue_type_script_setup_true_lang-B2NHtSGj.js";import{u as ie}from"./tasks-52Yg_X9L.js";import{u as ue}from"./devops-CxP-_OUa.js";import"./Spinner.vue_vue_type_script_setup_true_lang-DiLd-UCb.js";const ce={class:"flex flex-wrap items-center gap-3"},me={class:"flex items-center rounded-lg border border-border overflow-hidden bg-muted/30"},ge=["onClick"],fe=["value"],pe=["value"],xe=z({__name:"DateRangeFilter",props:{preset:{},customFrom:{},customTo:{},loading:{type:Boolean}},emits:["update:preset","update:customFrom","update:customTo","apply"],setup(t,{emit:g}){const c=g;return(i,s)=>(o(),a("div",ce,[s[5]||(s[5]=e("h2",{class:"text-base font-semibold text-foreground flex-1 tracking-tight"},"Overview",-1)),e("div",me,[(o(),a(h,null,w(["today","7d","30d","custom"],r=>e("button",{key:r,class:M(["px-3 py-1.5 text-xs font-medium transition-colors",t.preset===r?"bg-primary text-primary-foreground":"text-muted-foreground hover:text-foreground hover:bg-muted/50"]),onClick:p=>c("update:preset",r)},y(r==="today"?"Today":r==="7d"?"7 days":r==="30d"?"30 days":"Custom"),11,ge)),64))]),t.preset==="custom"?(o(),a(h,{key:0},[e("input",{value:t.customFrom,type:"date",class:"h-8 rounded-lg border border-input bg-muted/30 px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring",onInput:s[0]||(s[0]=r=>c("update:customFrom",r.target.value))},null,40,fe),s[4]||(s[4]=e("span",{class:"text-xs text-muted-foreground"},"to",-1)),e("input",{value:t.customTo,type:"date",class:"h-8 rounded-lg border border-input bg-muted/30 px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring",onInput:s[1]||(s[1]=r=>c("update:customTo",r.target.value))},null,40,pe),l(se,{size:"sm",loading:t.loading,onClick:s[2]||(s[2]=r=>c("apply"))},{default:u(()=>[...s[3]||(s[3]=[k("Apply",-1)])]),_:1},8,["loading"])],64)):F("",!0)]))}});/**
import{d as z,c as a,a as e,F as h,r as w,e as l,w as u,i as F,o,q as k,n as M,t as y,s as b,x as E,y as ee,k as v,h as m,l as K,R as P,j as D,z as T,f as te,A as oe}from"./index-DXo25S1G.js";import{d as S}from"./dashboard-C4Xw6rO4.js";import{_ as B,a as V}from"./CardContent.vue_vue_type_script_setup_true_lang-CtMCMZF8.js";import{_ as A,a as I}from"./CardTitle.vue_vue_type_script_setup_true_lang-CQJa_Kn5.js";import{_ as se}from"./Button.vue_vue_type_script_setup_true_lang-CFigHNuj.js";import{c as q,f as R,i as H}from"./utils-7WVCegLb.js";import{_ as ae}from"./Skeleton.vue_vue_type_script_setup_true_lang-OUeRYs40.js";import{c as U}from"./createLucideIcon-DiGgSOrB.js";import{C as le}from"./calendar-days-D5uJAOhA.js";import{F as re,_ as ne}from"./Progress.vue_vue_type_script_setup_true_lang-CLZGg6Fw.js";import{_ as de}from"./Tooltip.vue_vue_type_script_setup_true_lang-Btv0o0qb.js";import{u as ie}from"./tasks-CRNk50QZ.js";import{u as ue}from"./devops-DiU8p5cB.js";import"./Spinner.vue_vue_type_script_setup_true_lang-DlENUZyI.js";const ce={class:"flex flex-wrap items-center gap-3"},me={class:"flex items-center rounded-lg border border-border overflow-hidden bg-muted/30"},ge=["onClick"],fe=["value"],pe=["value"],xe=z({__name:"DateRangeFilter",props:{preset:{},customFrom:{},customTo:{},loading:{type:Boolean}},emits:["update:preset","update:customFrom","update:customTo","apply"],setup(t,{emit:g}){const c=g;return(i,s)=>(o(),a("div",ce,[s[5]||(s[5]=e("h2",{class:"text-base font-semibold text-foreground flex-1 tracking-tight"},"Overview",-1)),e("div",me,[(o(),a(h,null,w(["today","7d","30d","custom"],r=>e("button",{key:r,class:M(["px-3 py-1.5 text-xs font-medium transition-colors",t.preset===r?"bg-primary text-primary-foreground":"text-muted-foreground hover:text-foreground hover:bg-muted/50"]),onClick:p=>c("update:preset",r)},y(r==="today"?"Today":r==="7d"?"7 days":r==="30d"?"30 days":"Custom"),11,ge)),64))]),t.preset==="custom"?(o(),a(h,{key:0},[e("input",{value:t.customFrom,type:"date",class:"h-8 rounded-lg border border-input bg-muted/30 px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring",onInput:s[0]||(s[0]=r=>c("update:customFrom",r.target.value))},null,40,fe),s[4]||(s[4]=e("span",{class:"text-xs text-muted-foreground"},"to",-1)),e("input",{value:t.customTo,type:"date",class:"h-8 rounded-lg border border-input bg-muted/30 px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring",onInput:s[1]||(s[1]=r=>c("update:customTo",r.target.value))},null,40,pe),l(se,{size:"sm",loading:t.loading,onClick:s[2]||(s[2]=r=>c("apply"))},{default:u(()=>[...s[3]||(s[3]=[k("Apply",-1)])]),_:1},8,["loading"])],64)):F("",!0)]))}});/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -1 +1 @@
import{u as b}from"./devops-CxP-_OUa.js";import{_ as y}from"./Input.vue_vue_type_script_setup_true_lang-BB-oIhte.js";import{_ as k}from"./Button.vue_vue_type_script_setup_true_lang-CXC2vi8v.js";import{_ as j}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-vAfa-ZNl.js";import{d as z,s as i,o as d,c as p,F,a as o,h as n,q as v,t as u,i as g,e as c,w,k as U,K as m}from"./index-DJpSDPva.js";const A={class:"space-y-4"},B={key:0,class:"text-xs text-muted-foreground space-y-1"},I={class:"text-foreground"},N={class:"text-foreground"},O={key:0},P={key:1,class:"text-red-400"},S={class:"grid grid-cols-2 gap-3"},T={class:"space-y-1.5"},$={class:"space-y-1.5"},q={class:"space-y-1.5"},E={class:"flex items-center gap-2"},Q=z({__name:"DevopsConnectForm",setup(K){var x,V;const t=b(),r=i(((x=t.integration)==null?void 0:x.organization)??""),l=i(((V=t.integration)==null?void 0:V.project)??""),s=i(""),f=i(!1),_=i(!1);async function C(){if(!r.value||!l.value||!s.value){m.error("All fields are required");return}f.value=!0;try{await t.saveIntegration({organization:r.value,project:l.value,pat:s.value}),s.value="",m.success("Integration saved")}catch{m.error("Failed to save integration")}finally{f.value=!1}}async function D(){try{await t.deleteIntegration(),r.value="",l.value="",s.value="",m.success("Integration deleted")}catch{m.error("Failed to delete integration")}}return(L,e)=>(d(),p(F,null,[o("div",A,[n(t).integration?(d(),p("div",B,[o("p",null,[e[5]||(e[5]=v(" Connected to ",-1)),o("strong",I,u(n(t).integration.organization),1),e[6]||(e[6]=v(" / ",-1)),o("strong",N,u(n(t).integration.project),1)]),n(t).integration.last_synced_at?(d(),p("p",O," Last synced: "+u(new Date(n(t).integration.last_synced_at).toLocaleString()),1)):g("",!0),n(t).integration.last_sync_error?(d(),p("p",P," Error: "+u(n(t).integration.last_sync_error),1)):g("",!0)])):g("",!0),o("div",S,[o("div",T,[e[7]||(e[7]=o("label",{class:"text-sm font-medium text-foreground"},"Organization",-1)),c(y,{modelValue:r.value,"onUpdate:modelValue":e[0]||(e[0]=a=>r.value=a),placeholder:"myorg"},null,8,["modelValue"])]),o("div",$,[e[8]||(e[8]=o("label",{class:"text-sm font-medium text-foreground"},"Project",-1)),c(y,{modelValue:l.value,"onUpdate:modelValue":e[1]||(e[1]=a=>l.value=a),placeholder:"myproject"},null,8,["modelValue"])])]),o("div",q,[e[9]||(e[9]=o("label",{class:"text-sm font-medium text-foreground"}," Personal Access Token ",-1)),c(y,{modelValue:s.value,"onUpdate:modelValue":e[2]||(e[2]=a=>s.value=a),type:"password",placeholder:"••••••••",autocomplete:"new-password"},null,8,["modelValue"])]),o("div",E,[c(k,{loading:f.value,onClick:C},{default:w(()=>[v(u(n(t).integration?"Update":"Connect"),1)]),_:1},8,["loading"]),n(t).integration?(d(),U(k,{key:0,variant:"destructive",size:"sm",onClick:e[3]||(e[3]=a=>_.value=!0)},{default:w(()=>[...e[10]||(e[10]=[v(" Disconnect ",-1)])]),_:1})):g("",!0)])]),c(j,{open:_.value,"onUpdate:open":e[4]||(e[4]=a=>_.value=a),title:"Disconnect Azure DevOps",description:"This will remove the ADO integration and all synced work items. This action cannot be undone.","confirm-label":"Disconnect","cancel-label":"Cancel",variant:"destructive",onConfirm:D},null,8,["open"])],64))}});export{Q as _};
import{u as b}from"./devops-DiU8p5cB.js";import{_ as y}from"./Input.vue_vue_type_script_setup_true_lang-hvAdVdJA.js";import{_ as k}from"./Button.vue_vue_type_script_setup_true_lang-CFigHNuj.js";import{_ as j}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-B8oTMOBa.js";import{d as z,s as i,o as d,c as p,F,a as o,h as n,q as v,t as u,i as g,e as c,w,k as U,K as m}from"./index-DXo25S1G.js";const A={class:"space-y-4"},B={key:0,class:"text-xs text-muted-foreground space-y-1"},I={class:"text-foreground"},N={class:"text-foreground"},O={key:0},P={key:1,class:"text-red-400"},S={class:"grid grid-cols-2 gap-3"},T={class:"space-y-1.5"},$={class:"space-y-1.5"},q={class:"space-y-1.5"},E={class:"flex items-center gap-2"},Q=z({__name:"DevopsConnectForm",setup(K){var x,V;const t=b(),r=i(((x=t.integration)==null?void 0:x.organization)??""),l=i(((V=t.integration)==null?void 0:V.project)??""),s=i(""),f=i(!1),_=i(!1);async function C(){if(!r.value||!l.value||!s.value){m.error("All fields are required");return}f.value=!0;try{await t.saveIntegration({organization:r.value,project:l.value,pat:s.value}),s.value="",m.success("Integration saved")}catch{m.error("Failed to save integration")}finally{f.value=!1}}async function D(){try{await t.deleteIntegration(),r.value="",l.value="",s.value="",m.success("Integration deleted")}catch{m.error("Failed to delete integration")}}return(L,e)=>(d(),p(F,null,[o("div",A,[n(t).integration?(d(),p("div",B,[o("p",null,[e[5]||(e[5]=v(" Connected to ",-1)),o("strong",I,u(n(t).integration.organization),1),e[6]||(e[6]=v(" / ",-1)),o("strong",N,u(n(t).integration.project),1)]),n(t).integration.last_synced_at?(d(),p("p",O," Last synced: "+u(new Date(n(t).integration.last_synced_at).toLocaleString()),1)):g("",!0),n(t).integration.last_sync_error?(d(),p("p",P," Error: "+u(n(t).integration.last_sync_error),1)):g("",!0)])):g("",!0),o("div",S,[o("div",T,[e[7]||(e[7]=o("label",{class:"text-sm font-medium text-foreground"},"Organization",-1)),c(y,{modelValue:r.value,"onUpdate:modelValue":e[0]||(e[0]=a=>r.value=a),placeholder:"myorg"},null,8,["modelValue"])]),o("div",$,[e[8]||(e[8]=o("label",{class:"text-sm font-medium text-foreground"},"Project",-1)),c(y,{modelValue:l.value,"onUpdate:modelValue":e[1]||(e[1]=a=>l.value=a),placeholder:"myproject"},null,8,["modelValue"])])]),o("div",q,[e[9]||(e[9]=o("label",{class:"text-sm font-medium text-foreground"}," Personal Access Token ",-1)),c(y,{modelValue:s.value,"onUpdate:modelValue":e[2]||(e[2]=a=>s.value=a),type:"password",placeholder:"••••••••",autocomplete:"new-password"},null,8,["modelValue"])]),o("div",E,[c(k,{loading:f.value,onClick:C},{default:w(()=>[v(u(n(t).integration?"Update":"Connect"),1)]),_:1},8,["loading"]),n(t).integration?(d(),U(k,{key:0,variant:"destructive",size:"sm",onClick:e[3]||(e[3]=a=>_.value=!0)},{default:w(()=>[...e[10]||(e[10]=[v(" Disconnect ",-1)])]),_:1})):g("",!0)])]),c(j,{open:_.value,"onUpdate:open":e[4]||(e[4]=a=>_.value=a),title:"Disconnect Azure DevOps",description:"This will remove the ADO integration and all synced work items. This action cannot be undone.","confirm-label":"Disconnect","cancel-label":"Cancel",variant:"destructive",onConfirm:D},null,8,["open"])],64))}});export{Q 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{x as h,y as b,d as w,A as x,k as g,J as E,e as v,T as C,w as p,o as c,c as u,a,p as m,i as f,n as L,s as $,t as y,L as B}from"./index-DJpSDPva.js";import{_ as T}from"./Button.vue_vue_type_script_setup_true_lang-CXC2vi8v.js";const j=["[autofocus]","button:not([disabled])","[href]","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])'].join(", ");function D(t,d){let r=null;function i(){return t.value?Array.from(t.value.querySelectorAll(j)):[]}function l(s){if(!d.value||!t.value||s.key!=="Tab")return;const e=i();if(!e.length){s.preventDefault();return}const o=e[0],n=e[e.length-1];s.shiftKey?document.activeElement===o&&(s.preventDefault(),n.focus()):document.activeElement===n&&(s.preventDefault(),o.focus())}h(d,s=>{if(s)r=document.activeElement,document.addEventListener("keydown",l),setTimeout(()=>{const e=i();e.length&&e[0].focus()},50);else{document.removeEventListener("keydown",l);const e=r;setTimeout(()=>{e&&"focus"in e&&e.focus()},150)}}),b(()=>{document.removeEventListener("keydown",l)})}const z={key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4"},A=["aria-label"],F={key:0,class:"flex items-center justify-between p-6 pb-4"},S={class:"text-lg font-semibold text-foreground"},K={key:0,class:"text-sm text-muted-foreground mt-1"},M={class:"px-6 pb-4 max-h-[85vh] overflow-y-auto"},N={key:1,class:"flex justify-end gap-2 px-6 pb-6"},W=w({__name:"Dialog",props:{open:{type:Boolean},title:{},description:{},maxWidth:{default:"max-w-lg"}},emits:["close"],setup(t,{emit:d}){const r=t,i=d,l=$(null),s=B(r,"open");D(l,s);function e(o){o.key==="Escape"&&r.open&&i("close")}return x(()=>document.addEventListener("keydown",e)),b(()=>document.removeEventListener("keydown",e)),(o,n)=>(c(),g(E,{to:"body"},[v(C,{"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:p(()=>[t.open?(c(),u("div",z,[a("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:n[0]||(n[0]=k=>i("close"))}),a("div",{ref_key:"contentRef",ref:l,class:L(["relative w-full bg-card border border-border rounded-lg shadow-xl z-10",t.maxWidth]),role:"dialog","aria-modal":"true","aria-label":t.title},[t.title||o.$slots.header?(c(),u("div",F,[a("div",null,[m(o.$slots,"header",{},()=>[a("h2",S,y(t.title),1),t.description?(c(),u("p",K,y(t.description),1)):f("",!0)])]),v(T,{variant:"ghost",size:"icon",class:"shrink-0",onClick:n[1]||(n[1]=k=>i("close"))},{default:p(()=>[...n[2]||(n[2]=[a("svg",{class:"h-4 w-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[a("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])]),_:1})])):f("",!0),a("div",M,[m(o.$slots,"default")]),o.$slots.footer?(c(),u("div",N,[m(o.$slots,"footer")])):f("",!0)],10,A)])):f("",!0)]),_:3})]))}});export{W as _};
import{x as h,y as b,d as w,A as x,k as g,L as E,e as v,T as C,w as p,o as c,c as u,a,p as m,i as f,n as L,s as $,t as y,M as B}from"./index-DXo25S1G.js";import{_ as T}from"./Button.vue_vue_type_script_setup_true_lang-CFigHNuj.js";const j=["[autofocus]","button:not([disabled])","[href]","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])'].join(", ");function D(t,d){let r=null;function i(){return t.value?Array.from(t.value.querySelectorAll(j)):[]}function l(s){if(!d.value||!t.value||s.key!=="Tab")return;const e=i();if(!e.length){s.preventDefault();return}const o=e[0],n=e[e.length-1];s.shiftKey?document.activeElement===o&&(s.preventDefault(),n.focus()):document.activeElement===n&&(s.preventDefault(),o.focus())}h(d,s=>{if(s)r=document.activeElement,document.addEventListener("keydown",l),setTimeout(()=>{const e=i();e.length&&e[0].focus()},50);else{document.removeEventListener("keydown",l);const e=r;setTimeout(()=>{e&&"focus"in e&&e.focus()},150)}}),b(()=>{document.removeEventListener("keydown",l)})}const z={key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4"},A=["aria-label"],F={key:0,class:"flex items-center justify-between p-6 pb-4"},M={class:"text-lg font-semibold text-foreground"},S={key:0,class:"text-sm text-muted-foreground mt-1"},K={class:"px-6 pb-4 max-h-[85vh] overflow-y-auto"},N={key:1,class:"flex justify-end gap-2 px-6 pb-6"},W=w({__name:"Dialog",props:{open:{type:Boolean},title:{},description:{},maxWidth:{default:"max-w-lg"}},emits:["close"],setup(t,{emit:d}){const r=t,i=d,l=$(null),s=B(r,"open");D(l,s);function e(o){o.key==="Escape"&&r.open&&i("close")}return x(()=>document.addEventListener("keydown",e)),b(()=>document.removeEventListener("keydown",e)),(o,n)=>(c(),g(E,{to:"body"},[v(C,{"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:p(()=>[t.open?(c(),u("div",z,[a("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:n[0]||(n[0]=k=>i("close"))}),a("div",{ref_key:"contentRef",ref:l,class:L(["relative w-full bg-card border border-border rounded-lg shadow-xl z-10",t.maxWidth]),role:"dialog","aria-modal":"true","aria-label":t.title},[t.title||o.$slots.header?(c(),u("div",F,[a("div",null,[m(o.$slots,"header",{},()=>[a("h2",M,y(t.title),1),t.description?(c(),u("p",S,y(t.description),1)):f("",!0)])]),v(T,{variant:"ghost",size:"icon",class:"shrink-0",onClick:n[1]||(n[1]=k=>i("close"))},{default:p(()=>[...n[2]||(n[2]=[a("svg",{class:"h-4 w-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[a("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])]),_:1})])):f("",!0),a("div",K,[m(o.$slots,"default")]),o.$slots.footer?(c(),u("div",N,[m(o.$slots,"footer")])):f("",!0)],10,A)])):f("",!0)]),_:3})]))}});export{W as _};

View file

@ -1 +1 @@
import{c as s}from"./utils-7WVCegLb.js";import{_ as k}from"./Button.vue_vue_type_script_setup_true_lang-CXC2vi8v.js";import{d as w,c as a,n as o,h as r,F as S,r as B,i as n,a as m,t as i,k as l,w as L,o as t,l as f,q as j}from"./index-DJpSDPva.js";const I=["aria-label"],N={key:0,class:"relative mb-6 flex items-end justify-center gap-2"},V={class:"space-y-2"},$=w({__name:"EmptyState",props:{title:{},description:{},icons:{},actionLabel:{},actionIcon:{},size:{default:"default"}},emits:["action"],setup(e,{emit:x}){const g=x,h={sm:"p-6",default:"p-8",lg:"p-12"},b={sm:"h-9 w-9",default:"h-11 w-11",lg:"h-14 w-14"},v=["z-10 translate-y-1 -rotate-6 group-hover:-translate-x-3 group-hover:-translate-y-1 group-hover:-rotate-12","z-20 group-hover:-translate-y-3","z-10 translate-y-1 rotate-6 group-hover:translate-x-3 group-hover:-translate-y-1 group-hover:rotate-12"],y={sm:"text-sm",default:"text-base",lg:"text-lg"},z={sm:"text-xs",default:"text-sm",lg:"text-base"},C={sm:"h-7 text-xs px-3",default:"",lg:"h-11 text-base px-6"};return(p,c)=>(t(),a("div",{class:o(r(s)("group flex flex-col items-center justify-center text-center","rounded-xl border-2 border-dashed border-border bg-card","transition-all duration-300 hover:border-foreground/30",h[e.size])),role:"status","aria-label":e.title},[e.icons&&e.icons.length?(t(),a("div",N,[(t(!0),a(S,null,B(e.icons.slice(0,3),(d,u)=>(t(),a("div",{key:u,class:o(r(s)("flex items-center justify-center rounded-xl border border-border bg-background shadow-sm","text-muted-foreground transition-all duration-300",b[e.size],v[u]??"z-20"))},[(t(),l(f(d),{class:"h-5 w-5"}))],2))),128))])):n("",!0),m("div",V,[m("h3",{class:o(r(s)("font-semibold text-foreground",y[e.size]))},i(e.title),3),e.description?(t(),a("p",{key:0,class:o(r(s)("text-muted-foreground",z[e.size]))},i(e.description),3)):n("",!0)]),e.actionLabel?(t(),l(k,{key:1,variant:"outline",class:o(r(s)("mt-6",C[e.size])),onClick:c[0]||(c[0]=d=>g("action"))},{default:L(()=>[e.actionIcon?(t(),l(f(e.actionIcon),{key:0,class:"mr-2 h-4 w-4 transition-transform duration-200 group-hover/btn:rotate-90"})):n("",!0),j(" "+i(e.actionLabel),1)]),_:1},8,["class"])):n("",!0)],10,I))}});export{$ as _};
import{c as s}from"./utils-7WVCegLb.js";import{_ as k}from"./Button.vue_vue_type_script_setup_true_lang-CFigHNuj.js";import{d as w,c as a,n as o,h as r,F as S,r as B,i as n,a as m,t as i,k as l,w as L,o as t,l as f,q as j}from"./index-DXo25S1G.js";const I=["aria-label"],N={key:0,class:"relative mb-6 flex items-end justify-center gap-2"},V={class:"space-y-2"},$=w({__name:"EmptyState",props:{title:{},description:{},icons:{},actionLabel:{},actionIcon:{},size:{default:"default"}},emits:["action"],setup(e,{emit:x}){const g=x,h={sm:"p-6",default:"p-8",lg:"p-12"},b={sm:"h-9 w-9",default:"h-11 w-11",lg:"h-14 w-14"},v=["z-10 translate-y-1 -rotate-6 group-hover:-translate-x-3 group-hover:-translate-y-1 group-hover:-rotate-12","z-20 group-hover:-translate-y-3","z-10 translate-y-1 rotate-6 group-hover:translate-x-3 group-hover:-translate-y-1 group-hover:rotate-12"],y={sm:"text-sm",default:"text-base",lg:"text-lg"},z={sm:"text-xs",default:"text-sm",lg:"text-base"},C={sm:"h-7 text-xs px-3",default:"",lg:"h-11 text-base px-6"};return(p,c)=>(t(),a("div",{class:o(r(s)("group flex flex-col items-center justify-center text-center","rounded-xl border-2 border-dashed border-border bg-card","transition-all duration-300 hover:border-foreground/30",h[e.size])),role:"status","aria-label":e.title},[e.icons&&e.icons.length?(t(),a("div",N,[(t(!0),a(S,null,B(e.icons.slice(0,3),(d,u)=>(t(),a("div",{key:u,class:o(r(s)("flex items-center justify-center rounded-xl border border-border bg-background shadow-sm","text-muted-foreground transition-all duration-300",b[e.size],v[u]??"z-20"))},[(t(),l(f(d),{class:"h-5 w-5"}))],2))),128))])):n("",!0),m("div",V,[m("h3",{class:o(r(s)("font-semibold text-foreground",y[e.size]))},i(e.title),3),e.description?(t(),a("p",{key:0,class:o(r(s)("text-muted-foreground",z[e.size]))},i(e.description),3)):n("",!0)]),e.actionLabel?(t(),l(k,{key:1,variant:"outline",class:o(r(s)("mt-6",C[e.size])),onClick:c[0]||(c[0]=d=>g("action"))},{default:L(()=>[e.actionIcon?(t(),l(f(e.actionIcon),{key:0,class:"mr-2 h-4 w-4 transition-transform duration-200 group-hover/btn:rotate-90"})):n("",!0),j(" "+i(e.actionLabel),1)]),_:1},8,["class"])):n("",!0)],10,I))}});export{$ as _};

View file

@ -1 +1 @@
import{c as i}from"./utils-7WVCegLb.js";import{d,c as s,n as u,h as m,o as r}from"./index-DJpSDPva.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)=>(r(),s("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:u(m(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,c as s,n as u,h as m,o as r}from"./index-DXo25S1G.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)=>(r(),s("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:u(m(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,4 +1,4 @@
import{a as h}from"./admin-BXEwmj8v.js";import{_ as N,a as R}from"./CardContent.vue_vue_type_script_setup_true_lang-C9faiyKN.js";import{_ as y}from"./Button.vue_vue_type_script_setup_true_lang-CXC2vi8v.js";import{_ as B}from"./Dialog.vue_vue_type_script_setup_true_lang-DrFHk9To.js";import{_ as j}from"./Input.vue_vue_type_script_setup_true_lang-BB-oIhte.js";import{_ as D}from"./Spinner.vue_vue_type_script_setup_true_lang-DiLd-UCb.js";import{_ as F}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-vAfa-ZNl.js";import{_ as S}from"./EmptyState.vue_vue_type_script_setup_true_lang-Dfap6ytm.js";import{d as q,A as z,c as n,a as t,e as o,w as r,s as i,o as l,q as _,h as p,F as M,r as U,t as c,k as T,i as E,K as k}from"./index-DJpSDPva.js";import{a as V}from"./utils-7WVCegLb.js";import{c as A}from"./createLucideIcon-D1lNol2c.js";import{P as G}from"./plus-13ZJQJe1.js";/**
import{a as h}from"./admin-CpfPkRBL.js";import{_ as N,a as R}from"./CardContent.vue_vue_type_script_setup_true_lang-CtMCMZF8.js";import{_ as y}from"./Button.vue_vue_type_script_setup_true_lang-CFigHNuj.js";import{_ as B}from"./Dialog.vue_vue_type_script_setup_true_lang-DVaPMwc2.js";import{_ as j}from"./Input.vue_vue_type_script_setup_true_lang-hvAdVdJA.js";import{_ as D}from"./Spinner.vue_vue_type_script_setup_true_lang-DlENUZyI.js";import{_ as F}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-B8oTMOBa.js";import{_ as S}from"./EmptyState.vue_vue_type_script_setup_true_lang-a0IekmoF.js";import{d as q,A as z,c as n,a as t,e as o,w as r,s as i,o as l,q as _,h as p,F as M,r as U,t as c,k as T,i as E,K as k}from"./index-DXo25S1G.js";import{a as V}from"./utils-7WVCegLb.js";import{c as A}from"./createLucideIcon-DiGgSOrB.js";import{P as G}from"./plus-CzGoYG6x.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -1,4 +1,4 @@
import{y as J,s as b,d as M,u as O,A as V,c as f,a as i,n as k,h as o,t as v,k as N,w as g,i as C,e as _,o as l,q as j,F as B,r as F,j as I}from"./index-DJpSDPva.js";import{_ as R,a as z}from"./CardContent.vue_vue_type_script_setup_true_lang-C9faiyKN.js";import{_ as L}from"./Button.vue_vue_type_script_setup_true_lang-CXC2vi8v.js";import{_ as S}from"./Skeleton.vue_vue_type_script_setup_true_lang-FxYuf1D4.js";import{_ as A}from"./EmptyState.vue_vue_type_script_setup_true_lang-Dfap6ytm.js";import{c as D}from"./createLucideIcon-D1lNol2c.js";import{Z as U}from"./zap-C8wAfp_V.js";import"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-DiLd-UCb.js";/**
import{y as J,s as b,d as M,u as O,A as V,c as f,a as i,n as k,h as o,t as v,k as N,w as g,i as C,e as _,o as l,q as j,F as B,r as F,j as I}from"./index-DXo25S1G.js";import{_ as R,a as z}from"./CardContent.vue_vue_type_script_setup_true_lang-CtMCMZF8.js";import{_ as L}from"./Button.vue_vue_type_script_setup_true_lang-CFigHNuj.js";import{_ as S}from"./Skeleton.vue_vue_type_script_setup_true_lang-OUeRYs40.js";import{_ as A}from"./EmptyState.vue_vue_type_script_setup_true_lang-a0IekmoF.js";import{c as D}from"./createLucideIcon-DiGgSOrB.js";import{Z as U}from"./zap-ByRPGP2I.js";import"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-DlENUZyI.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -1 +1 @@
import{d as h,u as f,c as o,a as e,b as g,e as a,w as d,o as i,f as m,g as p,h as r,t as x,i as b}from"./index-DJpSDPva.js";import{_ as v,a as w}from"./CardContent.vue_vue_type_script_setup_true_lang-C9faiyKN.js";import"./utils-7WVCegLb.js";const y={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 l=m(),c=p(),s=f();async function u(){try{await s.loginWithMicrosoft();const n=c.query.redirect;l.push(n??"/")}catch{}}return(n,t)=>(i(),o("div",y,[e("div",_,[t[2]||(t[2]=g('<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(v,null,{default:d(()=>[a(w,{class:"pt-6"},{default:d(()=>[e("div",k,[r(s).error?(i(),o("div",C,x(r(s).error),1)):b("",!0),e("button",{type:"button",disabled:r(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-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",onClick:u},[t[0]||(t[0]=e("svg",{class:"h-5 w-5 shrink-0",viewBox:"0 0 21 21",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[e("rect",{x:"1",y:"1",width:"9",height:"9",fill:"#F25022"}),e("rect",{x:"11",y:"1",width:"9",height:"9",fill:"#7FBA00"}),e("rect",{x:"1",y:"11",width:"9",height:"9",fill:"#00A4EF"}),e("rect",{x:"11",y:"11",width:"9",height:"9",fill:"#FFB900"})],-1)),r(s).loading?(i(),o("span",V,"Signing in…")):(i(),o("span",S,"Sign in with Microsoft"))],8,B),t[1]||(t[1]=e("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 e,b as g,e as a,w as d,o as i,f as m,g as p,h as r,t as x,i as b}from"./index-DXo25S1G.js";import{_ as v,a as w}from"./CardContent.vue_vue_type_script_setup_true_lang-CtMCMZF8.js";import"./utils-7WVCegLb.js";const y={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 l=m(),c=p(),s=f();async function u(){try{await s.loginWithMicrosoft();const n=c.query.redirect;l.push(n??"/")}catch{}}return(n,t)=>(i(),o("div",y,[e("div",_,[t[2]||(t[2]=g('<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(v,null,{default:d(()=>[a(w,{class:"pt-6"},{default:d(()=>[e("div",k,[r(s).error?(i(),o("div",C,x(r(s).error),1)):b("",!0),e("button",{type:"button",disabled:r(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-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",onClick:u},[t[0]||(t[0]=e("svg",{class:"h-5 w-5 shrink-0",viewBox:"0 0 21 21",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[e("rect",{x:"1",y:"1",width:"9",height:"9",fill:"#F25022"}),e("rect",{x:"11",y:"1",width:"9",height:"9",fill:"#7FBA00"}),e("rect",{x:"1",y:"11",width:"9",height:"9",fill:"#00A4EF"}),e("rect",{x:"11",y:"11",width:"9",height:"9",fill:"#FFB900"})],-1)),r(s).loading?(i(),o("span",V,"Signing in…")):(i(),o("span",S,"Sign in with Microsoft"))],8,B),t[1]||(t[1]=e("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

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
import{c as r}from"./createLucideIcon-D1lNol2c.js";import{c as s}from"./utils-7WVCegLb.js";import{d as n,o as c,c as t,n as l,h as d,a as u,z as m}from"./index-DJpSDPva.js";/**
import{c as r}from"./createLucideIcon-DiGgSOrB.js";import{c as s}from"./utils-7WVCegLb.js";import{d as n,o as c,c as t,n as l,h as d,a as u,z as m}from"./index-DXo25S1G.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

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,4 +1,4 @@
var Ee=Object.defineProperty;var pe=a=>{throw TypeError(a)};var Le=(a,t,e)=>t in a?Ee(a,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):a[t]=e;var x=(a,t,e)=>Le(a,typeof t!="symbol"?t+"":t,e),Be=(a,t,e)=>t.has(a)||pe("Cannot "+e);var he=(a,t,e)=>t.has(a)?pe("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(a):t.set(a,e);var D=(a,t,e)=>(Be(a,t,"access private method"),e);import{D as G,d as qe,u as Ze,A as Pe,c as v,a as w,e as z,w as S,h as W,F as Me,r as De,s as E,o as T,q as L,k as ue,t as X,i as fe,n as Qe,K as Q}from"./index-DJpSDPva.js";import{a as je,_ as Oe}from"./CardContent.vue_vue_type_script_setup_true_lang-C9faiyKN.js";import{_ as de}from"./Badge.vue_vue_type_script_setup_true_lang-BPDqRYvu.js";import{_ as K}from"./Button.vue_vue_type_script_setup_true_lang-CXC2vi8v.js";import{_ as Ne}from"./Spinner.vue_vue_type_script_setup_true_lang-DiLd-UCb.js";import{_ as Fe}from"./SegmentedControl.vue_vue_type_script_setup_true_lang-DyYWxSJy.js";import{_ as He}from"./EmptyState.vue_vue_type_script_setup_true_lang-Dfap6ytm.js";import{a as Ue,i as Ve}from"./utils-7WVCegLb.js";import{F as Ge}from"./file-text-ibA2OaEg.js";import{C as We}from"./calendar-BLPZmopU.js";import{_ as Xe}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./createLucideIcon-D1lNol2c.js";const ge={list:()=>G.get("/api/reports"),get:a=>G.get(`/api/reports/${a}`),generate:a=>G.post("/api/reports/generate",a)};function ee(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}let C=ee();function ye(a){C=a}const $e=/[&<>"']/,Ke=new RegExp($e.source,"g"),_e=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,Je=new RegExp(_e.source,"g"),Ye={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},ke=a=>Ye[a];function b(a,t){if(t){if($e.test(a))return a.replace(Ke,ke)}else if(_e.test(a))return a.replace(Je,ke);return a}const et=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig;function tt(a){return a.replace(et,(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 nt=/(^|[^\[])\^/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(nt,"$1"),e=e.replace(i,s),n},getRegex:()=>new RegExp(e,t)};return n}function xe(a){try{a=encodeURI(a).replace(/%25/g,"%")}catch{return null}return a}const q={exec:()=>null};function me(a,t){const e=a.replace(/\|/g,(r,s,l)=>{let o=!1,u=s;for(;--u>=0&&l[u]==="\\";)o=!o;return o?"|":" |"}),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 j(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 st(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 be(a,t,e,n){const i=t.href,r=t.title?b(t.title):null,s=a[1].replace(/\\([\[\]])/g,"$1");if(a[0].charAt(0)!=="!"){n.state.inLink=!0;const l={type:"link",raw:e,href:i,title:r,text:s,tokens:n.inlineTokens(s)};return n.state.inLink=!1,l}return{type:"image",raw:e,href:i,title:r,text:b(s)}}function it(a,t){const e=a.match(/^(\s+)(?:```)/);if(e===null)return t;const n=e[1];return t.split(`
var Ee=Object.defineProperty;var pe=a=>{throw TypeError(a)};var Le=(a,t,e)=>t in a?Ee(a,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):a[t]=e;var x=(a,t,e)=>Le(a,typeof t!="symbol"?t+"":t,e),Be=(a,t,e)=>t.has(a)||pe("Cannot "+e);var he=(a,t,e)=>t.has(a)?pe("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(a):t.set(a,e);var D=(a,t,e)=>(Be(a,t,"access private method"),e);import{D as G,d as qe,u as Ze,A as Pe,c as v,a as w,e as z,w as S,h as W,F as Me,r as De,s as E,o as T,q as L,k as ue,t as X,i as fe,n as Qe,K as Q}from"./index-DXo25S1G.js";import{a as je,_ as Oe}from"./CardContent.vue_vue_type_script_setup_true_lang-CtMCMZF8.js";import{_ as de}from"./Badge.vue_vue_type_script_setup_true_lang-fQCaDGBh.js";import{_ as K}from"./Button.vue_vue_type_script_setup_true_lang-CFigHNuj.js";import{_ as Ne}from"./Spinner.vue_vue_type_script_setup_true_lang-DlENUZyI.js";import{_ as Fe}from"./SegmentedControl.vue_vue_type_script_setup_true_lang-H1LzUOsx.js";import{_ as He}from"./EmptyState.vue_vue_type_script_setup_true_lang-a0IekmoF.js";import{a as Ue,i as Ve}from"./utils-7WVCegLb.js";import{F as Ge}from"./file-text-DJvPR3_2.js";import{C as We}from"./calendar-DdMrjbrI.js";import{_ as Xe}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./createLucideIcon-DiGgSOrB.js";const ge={list:()=>G.get("/api/reports"),get:a=>G.get(`/api/reports/${a}`),generate:a=>G.post("/api/reports/generate",a)};function ee(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}let C=ee();function ye(a){C=a}const $e=/[&<>"']/,Ke=new RegExp($e.source,"g"),_e=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,Je=new RegExp(_e.source,"g"),Ye={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},ke=a=>Ye[a];function b(a,t){if(t){if($e.test(a))return a.replace(Ke,ke)}else if(_e.test(a))return a.replace(Je,ke);return a}const et=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig;function tt(a){return a.replace(et,(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 nt=/(^|[^\[])\^/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(nt,"$1"),e=e.replace(i,s),n},getRegex:()=>new RegExp(e,t)};return n}function xe(a){try{a=encodeURI(a).replace(/%25/g,"%")}catch{return null}return a}const q={exec:()=>null};function me(a,t){const e=a.replace(/\|/g,(r,s,l)=>{let o=!1,u=s;for(;--u>=0&&l[u]==="\\";)o=!o;return o?"|":" |"}),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 j(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 st(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 be(a,t,e,n){const i=t.href,r=t.title?b(t.title):null,s=a[1].replace(/\\([\[\]])/g,"$1");if(a[0].charAt(0)!=="!"){n.state.inLink=!0;const l={type:"link",raw:e,href:i,title:r,text:s,tokens:n.inlineTokens(s)};return n.state.inLink=!1,l}return{type:"image",raw:e,href:i,title:r,text:b(s)}}function it(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 N{constructor(t){x(this,"options");x(this,"rules");x(this,"lexer");this.options=t||C}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:j(n,`
`)}}}fences(t){const e=this.rules.block.fences.exec(t);if(e){const n=e[0],i=it(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=j(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{c as d}from"./utils-7WVCegLb.js";import{d as c,c as s,F as m,r as f,o as a,n as p,h as g,k as v,l as b,i as x,q as h,t as k}from"./index-DJpSDPva.js";const y=["aria-label"],V=["aria-pressed","onClick"],A=c({__name:"SegmentedControl",props:{modelValue:{},options:{},ariaLabel:{}},emits:["update:modelValue"],setup(t,{emit:i}){const o=t,l=i;function u(n){const r=o.options.findIndex(e=>e.value===o.modelValue);if(n.key==="ArrowRight"||n.key==="ArrowDown"){n.preventDefault();const e=o.options[(r+1)%o.options.length];l("update:modelValue",e.value)}else if(n.key==="ArrowLeft"||n.key==="ArrowUp"){n.preventDefault();const e=o.options[(r-1+o.options.length)%o.options.length];l("update:modelValue",e.value)}}return(n,r)=>(a(),s("div",{class:"inline-flex items-center rounded-lg border border-border bg-muted/40 p-1",role:"group","aria-label":t.ariaLabel,onKeydown:u},[(a(!0),s(m,null,f(t.options,e=>(a(),s("button",{key:e.value,type:"button","aria-pressed":t.modelValue===e.value,class:p(g(d)("inline-flex items-center gap-1.5 rounded-md px-3 h-8 text-xs font-medium transition-all","focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",t.modelValue===e.value?"bg-background text-foreground shadow-sm":"text-muted-foreground hover:text-foreground")),onClick:w=>l("update:modelValue",e.value)},[e.icon?(a(),v(b(e.icon),{key:0,class:"h-3.5 w-3.5"})):x("",!0),h(" "+k(e.label),1)],10,V))),128))],40,y))}});export{A as _};
import{c as d}from"./utils-7WVCegLb.js";import{d as c,c as s,F as m,r as f,o as a,n as p,h as g,k as v,l as b,i as x,q as h,t as k}from"./index-DXo25S1G.js";const y=["aria-label"],V=["aria-pressed","onClick"],A=c({__name:"SegmentedControl",props:{modelValue:{},options:{},ariaLabel:{}},emits:["update:modelValue"],setup(t,{emit:i}){const o=t,l=i;function u(n){const r=o.options.findIndex(e=>e.value===o.modelValue);if(n.key==="ArrowRight"||n.key==="ArrowDown"){n.preventDefault();const e=o.options[(r+1)%o.options.length];l("update:modelValue",e.value)}else if(n.key==="ArrowLeft"||n.key==="ArrowUp"){n.preventDefault();const e=o.options[(r-1+o.options.length)%o.options.length];l("update:modelValue",e.value)}}return(n,r)=>(a(),s("div",{class:"inline-flex items-center rounded-lg border border-border bg-muted/40 p-1",role:"group","aria-label":t.ariaLabel,onKeydown:u},[(a(!0),s(m,null,f(t.options,e=>(a(),s("button",{key:e.value,type:"button","aria-pressed":t.modelValue===e.value,class:p(g(d)("inline-flex items-center gap-1.5 rounded-md px-3 h-8 text-xs font-medium transition-all","focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",t.modelValue===e.value?"bg-background text-foreground shadow-sm":"text-muted-foreground hover:text-foreground")),onClick:w=>l("update:modelValue",e.value)},[e.icon?(a(),v(b(e.icon),{key:0,class:"h-3.5 w-3.5"})):x("",!0),h(" "+k(e.label),1)],10,V))),128))],40,y))}});export{A as _};

View file

@ -1 +1 @@
import{c as s}from"./utils-7WVCegLb.js";import{d as r,o as t,c as n,m as o,h as a}from"./index-DJpSDPva.js";const i=r({inheritAttrs:!1,__name:"Skeleton",setup(m){return(e,c)=>(t(),n("div",o(e.$attrs,{class:a(s)("skeleton-shimmer rounded-md",e.$attrs.class)}),null,16))}});export{i as _};
import{c as s}from"./utils-7WVCegLb.js";import{d as r,o as t,c as n,m as o,h as a}from"./index-DXo25S1G.js";const i=r({inheritAttrs:!1,__name:"Skeleton",setup(m){return(e,c)=>(t(),n("div",o(e.$attrs,{class:a(s)("skeleton-shimmer rounded-md",e.$attrs.class)}),null,16))}});export{i as _};

View file

@ -1 +1 @@
import{d as l,o as n,c as o,n as t,a as r}from"./index-DJpSDPva.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-DXo25S1G.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

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-DXo25S1G.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{d,k as i,w as t,h as e,W as r,o as l,e as s,p as n,M as f,U as m,n as c,q as u,t as p,N as g}from"./index-DJpSDPva.js";import{c as x}from"./utils-7WVCegLb.js";const w=d({__name:"Tooltip",props:{content:{},side:{default:"top"},sideOffset:{default:6}},setup(a){return(o,h)=>(l(),i(e(r),null,{default:t(()=>[s(e(f),{"as-child":""},{default:t(()=>[n(o.$slots,"default")]),_:3}),s(e(g),null,{default:t(()=>[s(e(m),{side:a.side,"side-offset":a.sideOffset,class:c(e(x)("z-50 max-w-[280px] rounded-lg border border-border bg-popover px-3 py-1.5","text-xs text-popover-foreground shadow-md","animate-in fade-in-0 zoom-in-95","data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95","data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2","data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"))},{default:t(()=>[u(p(a.content),1)]),_:1},8,["side","side-offset","class"])]),_:1})]),_:3}))}});export{w as _};
import{d,k as i,w as t,h as e,W as r,o as l,e as s,p as n,N as f,U as m,n as c,q as u,t as p,O as g}from"./index-DXo25S1G.js";import{c as x}from"./utils-7WVCegLb.js";const w=d({__name:"Tooltip",props:{content:{},side:{default:"top"},sideOffset:{default:6}},setup(a){return(o,h)=>(l(),i(e(r),null,{default:t(()=>[s(e(f),{"as-child":""},{default:t(()=>[n(o.$slots,"default")]),_:3}),s(e(g),null,{default:t(()=>[s(e(m),{side:a.side,"side-offset":a.sideOffset,class:c(e(x)("z-50 max-w-[280px] rounded-lg border border-border bg-popover px-3 py-1.5","text-xs text-popover-foreground shadow-md","animate-in fade-in-0 zoom-in-95","data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95","data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2","data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"))},{default:t(()=>[u(p(a.content),1)]),_:1},8,["side","side-offset","class"])]),_:1})]),_:3}))}});export{w as _};

View file

@ -1 +1 @@
import{D as e}from"./index-DJpSDPva.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{D as e}from"./index-DXo25S1G.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,4 +1,4 @@
import{c as e}from"./createLucideIcon-D1lNol2c.js";/**
import{c as e}from"./createLucideIcon-DiGgSOrB.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -1,4 +1,4 @@
import{c as h}from"./createLucideIcon-D1lNol2c.js";/**
import{c as h}from"./createLucideIcon-DiGgSOrB.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -1,4 +1,4 @@
import{a3 as a}from"./index-DJpSDPva.js";/**
import{a4 as a}from"./index-DXo25S1G.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -1 +1 @@
import{D as t}from"./index-DJpSDPva.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{D as t}from"./index-DXo25S1G.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 +1 @@
import{D as n,B as I,s as o}from"./index-DJpSDPva.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};
import{D as n,B as I,s as o}from"./index-DXo25S1G.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};

View file

@ -1,4 +1,4 @@
import{c as e}from"./createLucideIcon-D1lNol2c.js";/**
import{c as e}from"./createLucideIcon-DiGgSOrB.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -1,4 +1,4 @@
import{c as X}from"./createLucideIcon-D1lNol2c.js";/**
import{c as X}from"./createLucideIcon-DiGgSOrB.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

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,4 +1,4 @@
import{c as e}from"./createLucideIcon-D1lNol2c.js";/**
import{c as e}from"./createLucideIcon-DiGgSOrB.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -0,0 +1 @@
import{D as p}from"./index-DXo25S1G.js";const i={list:()=>p.get("/api/projects")};export{i as p};

View file

@ -1 +0,0 @@
import{c as i}from"./utils-7WVCegLb.js";import{d,o as r,c as n,n as c,h as u,D as m}from"./index-DJpSDPva.js";const p=["id","value","placeholder","disabled","rows"],x=d({__name:"Textarea",props:{modelValue:{},placeholder:{},disabled:{type:Boolean},rows:{},class:{},id:{}},emits:["update:modelValue"],setup(e,{emit:l}){const s=e,a=l;return(f,o)=>(r(),n("textarea",{id:e.id,value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,rows:e.rows??3,class:c(u(i)("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",s.class)),onInput:o[0]||(o[0]=t=>a("update:modelValue",t.target.value))},null,42,p))}}),v={list:()=>m.get("/api/projects")};export{x as _,v as p};

View file

@ -1 +1 @@
import{D as l,B as h,s as i}from"./index-DJpSDPva.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"})),$=h("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(g=>g.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 B(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:B}});export{b as t,$ as u};
import{D as l,B as h,s as i}from"./index-DXo25S1G.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"})),$=h("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(g=>g.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 B(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:B}});export{b as t,$ as u};

View file

@ -1,4 +1,4 @@
import{c as a}from"./createLucideIcon-D1lNol2c.js";/**
import{c as a}from"./createLucideIcon-DiGgSOrB.js";/**
* @license lucide-vue-next v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View file

@ -14,8 +14,8 @@
else { document.documentElement.classList.remove('dark'); }
})();
</script>
<script type="module" crossorigin src="/cc-dashboard/static/assets/index-DJpSDPva.js"></script>
<link rel="stylesheet" crossorigin href="/cc-dashboard/static/assets/index-Crx9eGr0.css">
<script type="module" crossorigin src="/cc-dashboard/static/assets/index-DXo25S1G.js"></script>
<link rel="stylesheet" crossorigin href="/cc-dashboard/static/assets/index-DsK4Kst6.css">
</head>
<body>
<div id="app"></div>

View file

@ -2,15 +2,27 @@ import axios from 'axios'
const apiClient = axios.create({
baseURL: '/cc-dashboard',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
let isRefreshing = false
let refreshQueue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void }> = []
function drainQueue(err: unknown, token: string | null) {
for (const { resolve, reject } of refreshQueue) {
err ? reject(err) : resolve(token!)
}
refreshQueue = []
}
// We set up interceptors lazily after stores are initialized
export function setupInterceptors(
getToken: () => string | null,
onUnauthorized: () => void
onUnauthorized: () => void,
onTokenRefreshed: (token: string) => void,
) {
apiClient.interceptors.request.use((config) => {
const token = getToken()
@ -22,11 +34,39 @@ export function setupInterceptors(
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
onUnauthorized()
async (error) => {
const original = error.config
const isRefreshEndpoint = original.url?.includes('/api/auth/refresh')
if (error.response?.status !== 401 || original._retry || isRefreshEndpoint) {
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject })
}).then((token) => {
original.headers.Authorization = `Bearer ${token}`
return apiClient(original)
})
}
original._retry = true
isRefreshing = true
try {
const res = await apiClient.post<{ access_token: string }>('/api/auth/refresh')
const newToken = res.data.access_token
onTokenRefreshed(newToken)
drainQueue(null, newToken)
original.headers.Authorization = `Bearer ${newToken}`
return apiClient(original)
} catch (refreshErr) {
drainQueue(refreshErr, null)
onUnauthorized()
return Promise.reject(refreshErr)
} finally {
isRefreshing = false
}
return Promise.reject(error)
}
)
}

View file

@ -9,7 +9,7 @@ import { initMsal } from './api/msal'
import './styles/globals.css'
// Initialize MSAL before mounting — handles redirect callback if present
initMsal().then(() => {
initMsal().then(async () => {
const app = createApp(App)
const pinia = createPinia()
@ -23,8 +23,12 @@ initMsal().then(() => {
() => {
authStore.logout()
router.push({ name: 'login' })
}
},
(token) => authStore.setToken(token),
)
// Restore session from HttpOnly refresh cookie before mounting
await authStore.init()
app.mount('#app')
})

View file

@ -5,7 +5,6 @@ import { msalInstance, LOGIN_SCOPES } from '@/api/msal'
import type { UserOut } from '@/types'
export const useAuthStore = defineStore('auth', () => {
// Token stored in memory only - lost on page refresh (by design)
const token = ref<string | null>(null)
const user = ref<UserOut | null>(null)
const loading = ref(false)
@ -14,13 +13,27 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => token.value !== null)
const isAdmin = computed(() => user.value?.role === 'admin')
async function init(): Promise<void> {
try {
const res = await apiClient.post<{ access_token: string }>('/api/auth/refresh')
token.value = res.data.access_token
await fetchMe()
} catch {
// No valid refresh cookie — user needs to log in
}
}
function setToken(newToken: string): void {
token.value = newToken
}
async function loginWithMicrosoft(): Promise<void> {
loading.value = true
error.value = null
try {
const result = await msalInstance.loginPopup({ scopes: LOGIN_SCOPES })
const idToken = result.idToken
const res = await apiClient.post<{ access_token: string; refresh_token: string }>(
const res = await apiClient.post<{ access_token: string }>(
'/api/auth/microsoft',
{ id_token: idToken }
)
@ -36,6 +49,11 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout(): Promise<void> {
try {
await apiClient.post('/api/auth/logout')
} catch {
// best-effort
}
token.value = null
user.value = null
try {
@ -61,6 +79,8 @@ export const useAuthStore = defineStore('auth', () => {
error,
isAuthenticated,
isAdmin,
init,
setToken,
loginWithMicrosoft,
logout,
fetchMe,

View file

@ -230,6 +230,7 @@ export interface AzureWorkItem {
team_project?: string
priority?: number
created_date?: string
omg_number?: string
}
export interface OmgEntry {

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useDevopsStore } from '@/stores/devops'
import { devopsApi } from '@/api/endpoints/devops'
import { projectsApi } from '@/api/endpoints/projects'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
import CardTitle from '@/components/ui/CardTitle.vue'
@ -14,13 +16,27 @@ import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import Tooltip from '@/components/ui/Tooltip.vue'
import DevopsConnectForm from '@/components/devops/DevopsConnectForm.vue'
import { toast } from 'vue-sonner'
import type { ProjectOut } from '@/types'
const devopsStore = useDevopsStore()
const ccProjects = ref<ProjectOut[]>([])
type StateFilter = 'All' | 'Active' | 'Resolved' | 'Closed'
const stateFilter = ref<StateFilter>('All')
const cloningId = ref<string | null>(null)
const projectByJobNumber = computed(() => {
const map: Record<string, ProjectOut> = {}
for (const p of ccProjects.value) {
if (p.job_number) map[p.job_number.trim()] = p
}
return map
})
const missingOmgCount = computed(() =>
devopsStore.workItems.filter((wi) => !(wi as any).omg_number).length
)
const stateOptions = [
{ value: 'All', label: 'All' },
{ value: 'Active', label: 'Active' },
@ -33,6 +49,12 @@ onMounted(async () => {
if (devopsStore.integration) {
await devopsStore.fetchWorkItems()
}
try {
const res = await projectsApi.list()
ccProjects.value = res.data
} catch {
// non-critical
}
})
const filteredWorkItems = computed(() => {
@ -46,6 +68,7 @@ const columns: TableColumn[] = [
{ key: 'ado_id', title: '#', width: 70, minWidth: 50, sortable: true, resizable: true },
{ key: 'title', title: 'Title', minWidth: 120, sortable: true, filterable: true, resizable: true },
{ key: 'team_project', title: 'Project', width: 140, minWidth: 80, sortable: true, filterable: true, resizable: true },
{ key: 'omg_number', title: 'OMG #', width: 110, minWidth: 80, sortable: true, resizable: true },
{ key: 'priority', title: 'P', width: 60, minWidth: 50, sortable: true, align: 'center', resizable: true },
{ key: 'created_date', title: 'Created', width: 110, minWidth: 80, sortable: true, resizable: true },
{ key: 'state', title: 'State', width: 110, minWidth: 80, sortable: true, filterable: true, resizable: true },
@ -147,6 +170,11 @@ function stateClass(s: string) {
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-foreground">Work Items</h3>
<span class="text-xs text-muted-foreground tabular-nums">{{ filteredWorkItems.length }}</span>
<span
v-if="missingOmgCount > 0"
class="text-xs text-amber-500 tabular-nums"
:title="`${missingOmgCount} work items have no OMG Deliverable Number`"
>· {{ missingOmgCount }} без OMG #</span>
</div>
<SegmentedControl
v-model="stateFilter"
@ -185,6 +213,22 @@ function stateClass(s: string) {
</span>
</template>
<!-- OMG # column -->
<template #cell-omg_number="{ value }">
<template v-if="value">
<RouterLink
v-if="projectByJobNumber[String(value)]"
:to="`/projects/${projectByJobNumber[String(value)].id}`"
class="text-xs tabular-nums text-primary hover:underline"
@click.stop
>{{ value }}</RouterLink>
<Tooltip v-else :content="`OMG # ${value} — no matching CC project`">
<span class="text-xs tabular-nums text-muted-foreground">{{ value }}</span>
</Tooltip>
</template>
<span v-else class="text-xs text-amber-500/70 italic">no OMG #</span>
</template>
<!-- Priority column -->
<template #cell-priority="{ value }">
<span

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { ref, computed, onMounted, nextTick } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { omgApi, type OmgEntryPayload } from '@/api/endpoints/omg'
import { projectsApi } from '@/api/endpoints/projects'
import Dialog from '@/components/ui/Dialog.vue'
@ -15,9 +15,11 @@ import { toast } from 'vue-sonner'
import { Pencil, Trash2, Plus, FileText } from 'lucide-vue-next'
import type { OmgEntry, ProjectOut } from '@/types'
const route = useRoute()
const entries = ref<OmgEntry[]>([])
const projects = ref<ProjectOut[]>([])
const loading = ref(false)
const highlightedJobNumber = ref<string | null>(null)
// Map job_number project for quick lookup
const projectByJobNumber = computed(() => {
@ -54,6 +56,14 @@ const pendingDeleteId = ref<string | null>(null)
onMounted(async () => {
await Promise.all([loadEntries(), loadProjects()])
const hl = route.query.highlight as string | undefined
if (hl) {
highlightedJobNumber.value = hl
await nextTick()
const el = document.getElementById(`omg-row-${hl}`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => { highlightedJobNumber.value = null }, 2500)
}
})
async function loadEntries() {
@ -212,7 +222,9 @@ function cancelInline() {
<div
v-for="entry in entries"
:key="entry.id"
:id="`omg-row-${entry.job_number}`"
class="grid grid-cols-[1fr_1fr_120px_180px_96px] gap-4 px-4 py-3 border-b border-border last:border-0 items-center hover:bg-muted/10 transition-colors"
:class="{ 'ring-2 ring-inset ring-primary/40 bg-primary/5': highlightedJobNumber === entry.job_number }"
>
<!-- Name (inline edit on dblclick) -->
<div>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, RouterLink } from 'vue-router'
import { dashboardApi } from '@/api/endpoints/dashboard'
import Card from '@/components/ui/Card.vue'
import CardContent from '@/components/ui/CardContent.vue'
@ -85,12 +85,14 @@ const progressColor = (pct: number | null) => {
<p class="font-semibold text-sm text-foreground truncate">{{ proj.display_name }}</p>
<p v-if="proj.client" class="text-xs text-muted-foreground truncate">{{ proj.client }}</p>
</div>
<span
<RouterLink
v-if="proj.job_number"
class="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded shrink-0"
:to="{ name: 'omg', query: { highlight: proj.job_number } }"
class="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded shrink-0 hover:bg-primary/10 hover:text-primary transition-colors"
@click.stop
>
{{ proj.job_number }}
</span>
</RouterLink>
</div>
<!-- Stats -->
@ -129,8 +131,9 @@ const progressColor = (pct: number | null) => {
<!-- List view -->
<div v-else class="border border-border rounded-lg overflow-hidden">
<!-- Table header -->
<div class="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 px-4 py-2.5 bg-muted/30 border-b border-border text-xs font-medium text-muted-foreground uppercase tracking-wide">
<div class="grid grid-cols-[1fr_80px_auto_auto_auto_auto] gap-4 px-4 py-2.5 bg-muted/30 border-b border-border text-xs font-medium text-muted-foreground uppercase tracking-wide">
<span>Project</span>
<span class="text-right">OMG #</span>
<span class="text-right w-20">Hours</span>
<span class="text-right w-16">Sessions</span>
<span class="text-right w-24">Last Active</span>
@ -140,13 +143,22 @@ const progressColor = (pct: number | null) => {
<div
v-for="proj in projects"
:key="proj.project_id"
class="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 px-4 py-3 border-b border-border last:border-0 cursor-pointer hover:bg-muted/20 transition-colors items-center"
class="grid grid-cols-[1fr_80px_auto_auto_auto_auto] gap-4 px-4 py-3 border-b border-border last:border-0 cursor-pointer hover:bg-muted/20 transition-colors items-center"
@click="router.push(`/projects/${proj.project_id}`)"
>
<div class="min-w-0">
<p class="text-sm font-medium text-foreground truncate">{{ proj.display_name }}</p>
<p v-if="proj.client" class="text-xs text-muted-foreground truncate">{{ proj.client }}</p>
</div>
<div class="text-right">
<RouterLink
v-if="proj.job_number"
:to="{ name: 'omg', query: { highlight: proj.job_number } }"
class="text-xs tabular-nums text-primary hover:underline"
@click.stop
>{{ proj.job_number }}</RouterLink>
<span v-else class="text-xs text-muted-foreground/40"></span>
</div>
<span class="text-sm text-foreground tabular-nums text-right w-20">{{ formatDuration(proj.total_hours) }}</span>
<span class="text-sm text-muted-foreground tabular-nums text-right w-16">{{ proj.session_count }}</span>
<span class="text-xs text-muted-foreground text-right w-24">{{ proj.last_active ? formatDate(proj.last_active) : '—' }}</span>