feat: light theme redesign + DataTable with sort/filter/resize + bug fixes

- Sidebar: white background, orange gradient logo, orange active pill
- TopBar: glassmorphism (white/80 + backdrop-blur-xl)
- AppLayout: warm gradient background mesh
- DataTable: new reusable component with column sort, filter, resize
- DevopsView: rebuilt with DataTable; connection shows "all assigned work items"
- ADO work item URLs: use org-level URL (no project in path)
- CalendarBlock: planned blocks show task title instead of project name
- Reports export: replaced <a download> with fetch+blob to send JWT auth header
- Sidebar Tasks: fixed path /board/ (trailing slash for Apache ProxyPass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-07 13:15:19 +01:00
parent e16e4a16a5
commit 09cfc0a89a
43 changed files with 596 additions and 267 deletions

View file

@ -85,7 +85,7 @@ async def sync_user_work_items(user: User, db: AsyncSession) -> int:
wi.assigned_to_email = assigned_email
wi.iteration_path = f.get("System.IterationPath", "")
wi.area_path = f.get("System.AreaPath", "")
wi.url = f"https://dev.azure.com/{integ.organization}/{integ.project}/_workitems/edit/{ado_id}"
wi.url = f"https://dev.azure.com/{integ.organization}/_workitems/edit/{ado_id}"
wi.fields_json = f
wi.synced_at = datetime.now(timezone.utc)

View file

@ -1 +1 @@
import{d as p,u as y,y as h,c as r,a as t,e as n,k as v,w as d,f as b,s as m,o as s,F as g,r as k,t as a,q as u,h as A}from"./index-BP_aNEdP.js";import{a as w}from"./admin-C27haAMd.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-B8VjSxLa.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-B2H6z2RD.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-Cu49-Cc4.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6"},C={key:0,class:"flex items-center justify-center h-20"},D={class:"w-full"},E={class:"px-4 py-3"},F={class:"text-sm font-medium text-foreground"},R={class:"px-4 py-3 text-sm text-muted-foreground"},U={class:"px-4 py-3"},j={class:"px-4 py-3"},q={class:"px-4 py-3 text-xs text-muted-foreground"},H=p({__name:"AdminView",setup(I){const x=y(),_=b(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){_.push("/");return}l.value=!0;try{const c=await w.users();i.value=c.data}finally{l.value=!1}}),(c,o)=>(s(),r("div",N,[o[1]||(o[1]=t("h2",{class:"text-lg font-semibold text-foreground mb-6"},"Admin — Users",-1)),l.value?(s(),r("div",C,[n(V,{class:"text-primary"})])):(s(),v(B,{key:1},{default:d(()=>[n(S,{class:"p-0"},{default:d(()=>[t("table",D,[o[0]||(o[0]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"User"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Email"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Role"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Status"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Joined")])],-1)),t("tbody",null,[(s(!0),r(g,null,k(i.value,e=>(s(),r("tr",{key:e.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",E,[t("p",F,a(e.username),1)]),t("td",R,a(e.email),1),t("td",U,[n(f,{variant:e.role==="admin"?"default":"secondary",class:"text-xs"},{default:d(()=>[u(a(e.role),1)]),_:2},1032,["variant"])]),t("td",j,[n(f,{variant:e.is_active?"success":"outline",class:"text-xs"},{default:d(()=>[u(a(e.is_active?"Active":"Inactive"),1)]),_:2},1032,["variant"])]),t("td",q,a(A($)(e.created_at)),1)]))),128))])])]),_:1})]),_:1}))]))}});export{H as default};
import{d as p,u as y,y as h,c as r,a as t,e as n,k as v,w as d,f as b,s as m,o as s,F as g,r as k,t as a,q as u,h as A}from"./index-DVV3ZbZ2.js";import{a as w}from"./admin-xS9EtPqv.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-DdGXaWEa.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-WigJUOyH.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-BkmDerVR.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6"},C={key:0,class:"flex items-center justify-center h-20"},D={class:"w-full"},E={class:"px-4 py-3"},F={class:"text-sm font-medium text-foreground"},R={class:"px-4 py-3 text-sm text-muted-foreground"},U={class:"px-4 py-3"},j={class:"px-4 py-3"},q={class:"px-4 py-3 text-xs text-muted-foreground"},H=p({__name:"AdminView",setup(I){const x=y(),_=b(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){_.push("/");return}l.value=!0;try{const c=await w.users();i.value=c.data}finally{l.value=!1}}),(c,o)=>(s(),r("div",N,[o[1]||(o[1]=t("h2",{class:"text-lg font-semibold text-foreground mb-6"},"Admin — Users",-1)),l.value?(s(),r("div",C,[n(V,{class:"text-primary"})])):(s(),v(B,{key:1},{default:d(()=>[n(S,{class:"p-0"},{default:d(()=>[t("table",D,[o[0]||(o[0]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"User"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Email"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Role"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Status"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Joined")])],-1)),t("tbody",null,[(s(!0),r(g,null,k(i.value,e=>(s(),r("tr",{key:e.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",E,[t("p",F,a(e.username),1)]),t("td",R,a(e.email),1),t("td",U,[n(f,{variant:e.role==="admin"?"default":"secondary",class:"text-xs"},{default:d(()=>[u(a(e.role),1)]),_:2},1032,["variant"])]),t("td",j,[n(f,{variant:e.is_active?"success":"outline",class:"text-xs"},{default:d(()=>[u(a(e.is_active?"Active":"Inactive"),1)]),_:2},1032,["variant"])]),t("td",q,a(A($)(e.created_at)),1)]))),128))])])]),_:1})]),_:1}))]))}});export{H as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{c as a}from"./utils-7WVCegLb.js";import{d as n,o,c as s,n as d,h as i,p as c}from"./index-BP_aNEdP.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-DVV3ZbZ2.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-Cu49-Cc4.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-BP_aNEdP.js";const y=["type","disabled"],z=u({__name:"Button",props:{variant:{default:"default"},size:{default:"md"},loading:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},type:{default:"button"},class:{}},emits:["click"],setup(t,{emit:s}){const e=t,a=s,r=p(()=>l("inline-flex items-center justify-center rounded-md font-medium transition-colors","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:pointer-events-none disabled:opacity-50",{"bg-primary text-primary-foreground hover:bg-primary/90":e.variant==="default","border border-input bg-background hover:bg-accent hover:text-accent-foreground":e.variant==="outline","hover:bg-accent hover:text-accent-foreground":e.variant==="ghost","bg-destructive text-destructive-foreground hover:bg-destructive/90":e.variant==="destructive","bg-secondary text-secondary-foreground hover:bg-secondary/80":e.variant==="secondary","underline-offset-4 hover:underline text-primary":e.variant==="link","h-8 px-3 text-xs":e.size==="sm","h-10 px-4 py-2 text-sm":e.size==="md","h-11 px-8 text-base":e.size==="lg","h-9 w-9 p-0":e.size==="icon"},e.class));return(i,o)=>(n(),f("button",{class:m(r.value),type:t.type,disabled:t.disabled||t.loading,onClick:o[0]||(o[0]=d=>a("click",d))},[t.loading?(n(),b(c,{key:0,size:"sm",class:"mr-2"})):v("",!0),g(i.$slots,"default")],10,y))}});export{z as _};
import{_ as c}from"./Spinner.vue_vue_type_script_setup_true_lang-BkmDerVR.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-DVV3ZbZ2.js";const y=["type","disabled"],z=u({__name:"Button",props:{variant:{default:"default"},size:{default:"md"},loading:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},type:{default:"button"},class:{}},emits:["click"],setup(t,{emit:s}){const e=t,a=s,r=p(()=>l("inline-flex items-center justify-center rounded-md font-medium transition-colors","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:pointer-events-none disabled:opacity-50",{"bg-primary text-primary-foreground hover:bg-primary/90":e.variant==="default","border border-input bg-background hover:bg-accent hover:text-accent-foreground":e.variant==="outline","hover:bg-accent hover:text-accent-foreground":e.variant==="ghost","bg-destructive text-destructive-foreground hover:bg-destructive/90":e.variant==="destructive","bg-secondary text-secondary-foreground hover:bg-secondary/80":e.variant==="secondary","underline-offset-4 hover:underline text-primary":e.variant==="link","h-8 px-3 text-xs":e.size==="sm","h-10 px-4 py-2 text-sm":e.size==="md","h-11 px-8 text-base":e.size==="lg","h-9 w-9 p-0":e.size==="icon"},e.class));return(i,o)=>(n(),f("button",{class:m(r.value),type:t.type,disabled:t.disabled||t.loading,onClick:o[0]||(o[0]=d=>a("click",d))},[t.loading?(n(),b(c,{key:0,size:"sm",class:"mr-2"})):v("",!0),g(i.$slots,"default")],10,y))}});export{z as _};

View file

@ -1 +1 @@
.calendar-block--manual[data-v-978cfc69]{background-image:repeating-linear-gradient(45deg,transparent,transparent 3px,rgba(255,255,255,.1) 3px,rgba(255,255,255,.1) 6px)}
.calendar-block--manual[data-v-53efb3d3]{background-image:repeating-linear-gradient(45deg,transparent,transparent 3px,rgba(255,255,255,.1) 3px,rgba(255,255,255,.1) 6px)}

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-BP_aNEdP.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-DVV3ZbZ2.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-BP_aNEdP.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-DVV3ZbZ2.js";const f=n({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(a,i)=>(o(),r("div",{class:c(l(t)("flex flex-col space-y-1.5 p-6",e.class))},[p(a.$slots,"default")],2))}}),_=n({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(a,i)=>(o(),r("h3",{class:c(l(t)("text-lg font-semibold leading-none tracking-tight",e.class))},[p(a.$slots,"default")],2))}});export{f as _,_ as a};

View file

@ -1 +1 @@
import{u as D}from"./devops-DxRDHPW5.js";import{_}from"./Input.vue_vue_type_script_setup_true_lang-SeygKlpx.js";import{_ as V}from"./Button.vue_vue_type_script_setup_true_lang-B_YE_XNW.js";import{d as j,s as c,o as i,c as m,h as a,a as o,q as g,t as d,i as p,e as v,w as k,k as z,K as u}from"./index-BP_aNEdP.js";const B={class:"space-y-4"},I={key:0,class:"text-xs text-muted-foreground space-y-1"},N={class:"text-foreground"},P={class:"text-foreground"},S={key:0},U={key:1,class:"text-red-400"},b={class:"grid grid-cols-2 gap-3"},A={class:"space-y-1.5"},F={class:"space-y-1.5"},O={class:"space-y-1.5"},q={class:"flex items-center gap-2"},G=j({__name:"DevopsConnectForm",setup(E){var y,x;const t=D(),n=c(((y=t.integration)==null?void 0:y.organization)??""),r=c(((x=t.integration)==null?void 0:x.project)??""),s=c(""),f=c(!1);async function w(){if(!n.value||!r.value||!s.value){u.error("All fields are required");return}f.value=!0;try{await t.saveIntegration({organization:n.value,project:r.value,pat:s.value}),s.value="",u.success("Integration saved")}catch{u.error("Failed to save integration")}finally{f.value=!1}}async function C(){if(confirm("Delete ADO integration?"))try{await t.deleteIntegration(),n.value="",r.value="",s.value="",u.success("Integration deleted")}catch{u.error("Failed to delete integration")}}return(K,e)=>(i(),m("div",B,[a(t).integration?(i(),m("div",I,[o("p",null,[e[3]||(e[3]=g(" Connected to ",-1)),o("strong",N,d(a(t).integration.organization),1),e[4]||(e[4]=g(" / ",-1)),o("strong",P,d(a(t).integration.project),1)]),a(t).integration.last_synced_at?(i(),m("p",S," Last synced: "+d(new Date(a(t).integration.last_synced_at).toLocaleString()),1)):p("",!0),a(t).integration.last_sync_error?(i(),m("p",U," Error: "+d(a(t).integration.last_sync_error),1)):p("",!0)])):p("",!0),o("div",b,[o("div",A,[e[5]||(e[5]=o("label",{class:"text-sm font-medium text-foreground"},"Organization",-1)),v(_,{modelValue:n.value,"onUpdate:modelValue":e[0]||(e[0]=l=>n.value=l),placeholder:"myorg"},null,8,["modelValue"])]),o("div",F,[e[6]||(e[6]=o("label",{class:"text-sm font-medium text-foreground"},"Project",-1)),v(_,{modelValue:r.value,"onUpdate:modelValue":e[1]||(e[1]=l=>r.value=l),placeholder:"myproject"},null,8,["modelValue"])])]),o("div",O,[e[7]||(e[7]=o("label",{class:"text-sm font-medium text-foreground"}," Personal Access Token ",-1)),v(_,{modelValue:s.value,"onUpdate:modelValue":e[2]||(e[2]=l=>s.value=l),type:"password",placeholder:"••••••••",autocomplete:"new-password"},null,8,["modelValue"])]),o("div",q,[v(V,{loading:f.value,onClick:w},{default:k(()=>[g(d(a(t).integration?"Update":"Connect"),1)]),_:1},8,["loading"]),a(t).integration?(i(),z(V,{key:0,variant:"destructive",size:"sm",onClick:C},{default:k(()=>[...e[8]||(e[8]=[g(" Disconnect ",-1)])]),_:1})):p("",!0)])]))}});export{G as _};
import{u as D}from"./devops-HjUgCfao.js";import{_}from"./Input.vue_vue_type_script_setup_true_lang-BM1xhrlf.js";import{_ as V}from"./Button.vue_vue_type_script_setup_true_lang-ClV4YfXb.js";import{d as j,s as c,o as i,c as m,h as a,a as o,q as g,t as d,i as p,e as v,w as k,k as z,K as u}from"./index-DVV3ZbZ2.js";const B={class:"space-y-4"},I={key:0,class:"text-xs text-muted-foreground space-y-1"},N={class:"text-foreground"},P={class:"text-foreground"},S={key:0},U={key:1,class:"text-red-400"},b={class:"grid grid-cols-2 gap-3"},A={class:"space-y-1.5"},F={class:"space-y-1.5"},O={class:"space-y-1.5"},q={class:"flex items-center gap-2"},G=j({__name:"DevopsConnectForm",setup(E){var y,x;const t=D(),n=c(((y=t.integration)==null?void 0:y.organization)??""),r=c(((x=t.integration)==null?void 0:x.project)??""),s=c(""),f=c(!1);async function w(){if(!n.value||!r.value||!s.value){u.error("All fields are required");return}f.value=!0;try{await t.saveIntegration({organization:n.value,project:r.value,pat:s.value}),s.value="",u.success("Integration saved")}catch{u.error("Failed to save integration")}finally{f.value=!1}}async function C(){if(confirm("Delete ADO integration?"))try{await t.deleteIntegration(),n.value="",r.value="",s.value="",u.success("Integration deleted")}catch{u.error("Failed to delete integration")}}return(K,e)=>(i(),m("div",B,[a(t).integration?(i(),m("div",I,[o("p",null,[e[3]||(e[3]=g(" Connected to ",-1)),o("strong",N,d(a(t).integration.organization),1),e[4]||(e[4]=g(" / ",-1)),o("strong",P,d(a(t).integration.project),1)]),a(t).integration.last_synced_at?(i(),m("p",S," Last synced: "+d(new Date(a(t).integration.last_synced_at).toLocaleString()),1)):p("",!0),a(t).integration.last_sync_error?(i(),m("p",U," Error: "+d(a(t).integration.last_sync_error),1)):p("",!0)])):p("",!0),o("div",b,[o("div",A,[e[5]||(e[5]=o("label",{class:"text-sm font-medium text-foreground"},"Organization",-1)),v(_,{modelValue:n.value,"onUpdate:modelValue":e[0]||(e[0]=l=>n.value=l),placeholder:"myorg"},null,8,["modelValue"])]),o("div",F,[e[6]||(e[6]=o("label",{class:"text-sm font-medium text-foreground"},"Project",-1)),v(_,{modelValue:r.value,"onUpdate:modelValue":e[1]||(e[1]=l=>r.value=l),placeholder:"myproject"},null,8,["modelValue"])])]),o("div",O,[e[7]||(e[7]=o("label",{class:"text-sm font-medium text-foreground"}," Personal Access Token ",-1)),v(_,{modelValue:s.value,"onUpdate:modelValue":e[2]||(e[2]=l=>s.value=l),type:"password",placeholder:"••••••••",autocomplete:"new-password"},null,8,["modelValue"])]),o("div",q,[v(V,{loading:f.value,onClick:w},{default:k(()=>[g(d(a(t).integration?"Update":"Connect"),1)]),_:1},8,["loading"]),a(t).integration?(i(),z(V,{key:0,variant:"destructive",size:"sm",onClick:C},{default:k(()=>[...e[8]||(e[8]=[g(" Disconnect ",-1)])]),_:1})):p("",!0)])]))}});export{G as _};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{d as y,y as k,G as b,k as h,H as g,e as c,T as x,w as u,o as a,c as n,a as o,p as r,t as m,i,n as w}from"./index-BP_aNEdP.js";import{_ as $}from"./Button.vue_vue_type_script_setup_true_lang-B_YE_XNW.js";const C={key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4"},B=["aria-label"],j={key:0,class:"flex items-center justify-between p-6 pb-4"},z={class:"text-lg font-semibold text-foreground"},E={key:0,class:"text-sm text-muted-foreground mt-1"},L={class:"px-6 pb-4"},M={key:1,class:"flex justify-end gap-2 px-6 pb-6"},V=y({__name:"Dialog",props:{open:{type:Boolean},title:{},description:{},maxWidth:{default:"max-w-lg"}},emits:["close"],setup(e,{emit:f}){const p=e,l=f;function d(t){t.key==="Escape"&&p.open&&l("close")}return k(()=>document.addEventListener("keydown",d)),b(()=>document.removeEventListener("keydown",d)),(t,s)=>(a(),h(g,{to:"body"},[c(x,{"enter-active-class":"transition-opacity duration-200","enter-from-class":"opacity-0","enter-to-class":"opacity-100","leave-active-class":"transition-opacity duration-200","leave-from-class":"opacity-100","leave-to-class":"opacity-0"},{default:u(()=>[e.open?(a(),n("div",C,[o("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:s[0]||(s[0]=v=>l("close"))}),o("div",{class:w(["relative w-full bg-card border border-border rounded-lg shadow-xl z-10",e.maxWidth]),role:"dialog","aria-modal":!0,"aria-label":e.title},[e.title||t.$slots.header?(a(),n("div",j,[o("div",null,[r(t.$slots,"header",{},()=>[o("h2",z,m(e.title),1),e.description?(a(),n("p",E,m(e.description),1)):i("",!0)])]),c($,{variant:"ghost",size:"icon",class:"shrink-0",onClick:s[1]||(s[1]=v=>l("close"))},{default:u(()=>[...s[2]||(s[2]=[o("svg",{class:"h-4 w-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])]),_:1})])):i("",!0),o("div",L,[r(t.$slots,"default")]),t.$slots.footer?(a(),n("div",M,[r(t.$slots,"footer")])):i("",!0)],10,B)])):i("",!0)]),_:3})]))}});export{V as _};
import{d as y,y as k,G as b,k as h,H as g,e as c,T as x,w as u,o as a,c as n,a as o,p as r,t as m,i,n as w}from"./index-DVV3ZbZ2.js";import{_ as $}from"./Button.vue_vue_type_script_setup_true_lang-ClV4YfXb.js";const C={key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4"},B=["aria-label"],j={key:0,class:"flex items-center justify-between p-6 pb-4"},z={class:"text-lg font-semibold text-foreground"},E={key:0,class:"text-sm text-muted-foreground mt-1"},L={class:"px-6 pb-4"},M={key:1,class:"flex justify-end gap-2 px-6 pb-6"},V=y({__name:"Dialog",props:{open:{type:Boolean},title:{},description:{},maxWidth:{default:"max-w-lg"}},emits:["close"],setup(e,{emit:f}){const p=e,l=f;function d(t){t.key==="Escape"&&p.open&&l("close")}return k(()=>document.addEventListener("keydown",d)),b(()=>document.removeEventListener("keydown",d)),(t,s)=>(a(),h(g,{to:"body"},[c(x,{"enter-active-class":"transition-opacity duration-200","enter-from-class":"opacity-0","enter-to-class":"opacity-100","leave-active-class":"transition-opacity duration-200","leave-from-class":"opacity-100","leave-to-class":"opacity-0"},{default:u(()=>[e.open?(a(),n("div",C,[o("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:s[0]||(s[0]=v=>l("close"))}),o("div",{class:w(["relative w-full bg-card border border-border rounded-lg shadow-xl z-10",e.maxWidth]),role:"dialog","aria-modal":!0,"aria-label":e.title},[e.title||t.$slots.header?(a(),n("div",j,[o("div",null,[r(t.$slots,"header",{},()=>[o("h2",z,m(e.title),1),e.description?(a(),n("p",E,m(e.description),1)):i("",!0)])]),c($,{variant:"ghost",size:"icon",class:"shrink-0",onClick:s[1]||(s[1]=v=>l("close"))},{default:u(()=>[...s[2]||(s[2]=[o("svg",{class:"h-4 w-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])]),_:1})])):i("",!0),o("div",L,[r(t.$slots,"default")]),t.$slots.footer?(a(),n("div",M,[r(t.$slots,"footer")])):i("",!0)],10,B)])):i("",!0)]),_:3})]))}});export{V as _};

View file

@ -1 +1 @@
import{c as i}from"./utils-7WVCegLb.js";import{d,o as s,c as u,n as m,h as r}from"./index-BP_aNEdP.js";const c=["id","name","type","value","placeholder","disabled","autocomplete","min","max","step"],g=d({__name:"Input",props:{modelValue:{},type:{},placeholder:{},disabled:{type:Boolean},class:{},id:{},name:{},autocomplete:{},min:{},max:{},step:{}},emits:["update:modelValue","change","blur","focus"],setup(e,{emit:n}){const a=e,o=n;return(f,t)=>(s(),u("input",{id:e.id,name:e.name,type:e.type??"text",value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,autocomplete:e.autocomplete,min:e.min,max:e.max,step:e.step,class:m(r(i)("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm","ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium","placeholder:text-muted-foreground","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-50",a.class)),onInput:t[0]||(t[0]=l=>o("update:modelValue",l.target.value)),onChange:t[1]||(t[1]=l=>o("change",l.target.value)),onBlur:t[2]||(t[2]=l=>o("blur",l)),onFocus:t[3]||(t[3]=l=>o("focus",l))},null,42,c))}});export{g as _};
import{c as i}from"./utils-7WVCegLb.js";import{d,o as s,c as u,n as m,h as r}from"./index-DVV3ZbZ2.js";const c=["id","name","type","value","placeholder","disabled","autocomplete","min","max","step"],g=d({__name:"Input",props:{modelValue:{},type:{},placeholder:{},disabled:{type:Boolean},class:{},id:{},name:{},autocomplete:{},min:{},max:{},step:{}},emits:["update:modelValue","change","blur","focus"],setup(e,{emit:n}){const a=e,o=n;return(f,t)=>(s(),u("input",{id:e.id,name:e.name,type:e.type??"text",value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,autocomplete:e.autocomplete,min:e.min,max:e.max,step:e.step,class:m(r(i)("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm","ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium","placeholder:text-muted-foreground","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-50",a.class)),onInput:t[0]||(t[0]=l=>o("update:modelValue",l.target.value)),onChange:t[1]||(t[1]=l=>o("change",l.target.value)),onBlur:t[2]||(t[2]=l=>o("blur",l)),onFocus:t[3]||(t[3]=l=>o("focus",l))},null,42,c))}});export{g as _};

View file

@ -1 +1 @@
import{a as b}from"./admin-C27haAMd.js";import{_ as K,a as $}from"./CardContent.vue_vue_type_script_setup_true_lang-B8VjSxLa.js";import{_ as v}from"./Button.vue_vue_type_script_setup_true_lang-B_YE_XNW.js";import{_ as V}from"./Dialog.vue_vue_type_script_setup_true_lang-v8iqnRk8.js";import{_ as N}from"./Input.vue_vue_type_script_setup_true_lang-SeygKlpx.js";import{_ as A}from"./Spinner.vue_vue_type_script_setup_true_lang-Cu49-Cc4.js";import{d as B,y as L,c as l,a as t,e as r,w as n,s as i,o as a,q as p,F as P,r as F,t as u,h as k,k as I,i as j,K as y}from"./index-BP_aNEdP.js";import{a as h}from"./utils-7WVCegLb.js";const D={class:"p-6"},R={class:"flex items-center justify-between mb-6"},z={key:0,class:"flex items-center justify-center h-20"},M={key:1,class:"text-center text-muted-foreground py-8 text-sm"},T={key:2,class:"w-full"},U={class:"px-4 py-3 text-sm text-foreground"},q={class:"px-4 py-3 text-sm font-mono text-muted-foreground"},E={class:"px-4 py-3 text-xs text-muted-foreground"},H={class:"px-4 py-3 text-xs text-muted-foreground"},S={class:"px-4 py-3 text-right"},G={class:"space-y-4"},J={key:0,class:"rounded-md bg-emerald-500/10 border border-emerald-500/30 p-3"},O={class:"text-xs font-mono text-foreground break-all"},Q={key:1,class:"space-y-1.5"},re=B({__name:"KeysView",setup(W){const f=i([]),_=i(!1),c=i(!1),m=i(""),x=i(!1),d=i(null);L(()=>g());async function g(){_.value=!0;try{const o=await b.keys();f.value=o.data}finally{_.value=!1}}async function w(){if(m.value.trim()){x.value=!0;try{const o=await b.createKey({label:m.value});d.value=o.data.key,y.success("API key created"),await g(),m.value=""}catch{y.error("Failed to create key")}finally{x.value=!1}}}async function C(o){if(confirm(`Revoke key "${o.label}"? This cannot be undone.`))try{await b.revokeKey(o.id),y.success("Key revoked"),f.value=f.value.filter(e=>e.id!==o.id)}catch{y.error("Failed to revoke key")}}return(o,e)=>(a(),l("div",D,[t("div",R,[e[5]||(e[5]=t("h2",{class:"text-lg font-semibold text-foreground"},"API Keys",-1)),r(v,{size:"sm",onClick:e[0]||(e[0]=s=>{c.value=!0,d.value=null})},{default:n(()=>[...e[4]||(e[4]=[t("svg",{class:"h-4 w-4 mr-1.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),p(" New Key ",-1)])]),_:1})]),r(K,null,{default:n(()=>[r($,{class:"p-0"},{default:n(()=>[_.value?(a(),l("div",z,[r(A,{class:"text-primary"})])):f.value.length===0?(a(),l("div",M," No API keys ")):(a(),l("table",T,[e[7]||(e[7]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Label"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Prefix"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Created"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Last Used"),t("th",{class:"px-4 py-3"})])],-1)),t("tbody",null,[(a(!0),l(P,null,F(f.value,s=>(a(),l("tr",{key:s.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",U,u(s.label),1),t("td",q,u(s.prefix)+"...",1),t("td",E,u(k(h)(s.created_at)),1),t("td",H,u(s.last_used?k(h)(s.last_used):"Never"),1),t("td",S,[r(v,{variant:"ghost",size:"sm",class:"text-destructive",onClick:X=>C(s)},{default:n(()=>[...e[6]||(e[6]=[p(" Revoke ",-1)])]),_:1},8,["onClick"])])]))),128))])]))]),_:1})]),_:1}),r(V,{open:c.value,title:"Create API Key",onClose:e[3]||(e[3]=s=>c.value=!1)},{footer:n(()=>[r(v,{variant:"outline",onClick:e[2]||(e[2]=s=>c.value=!1)},{default:n(()=>[p(u(d.value?"Done":"Cancel"),1)]),_:1}),d.value?j("",!0):(a(),I(v,{key:0,loading:x.value,onClick:w},{default:n(()=>[...e[10]||(e[10]=[p(" Create ",-1)])]),_:1},8,["loading"]))]),default:n(()=>[t("div",G,[d.value?(a(),l("div",J,[e[8]||(e[8]=t("p",{class:"text-xs text-emerald-400 font-medium mb-1"},"Key created — save it now!",-1)),t("p",O,u(d.value),1)])):(a(),l("div",Q,[e[9]||(e[9]=t("label",{class:"text-sm font-medium text-foreground"},"Label",-1)),r(N,{modelValue:m.value,"onUpdate:modelValue":e[1]||(e[1]=s=>m.value=s),placeholder:"e.g. claude-collector",disabled:x.value},null,8,["modelValue","disabled"])]))])]),_:1},8,["open"])]))}});export{re as default};
import{a as b}from"./admin-xS9EtPqv.js";import{_ as K,a as $}from"./CardContent.vue_vue_type_script_setup_true_lang-DdGXaWEa.js";import{_ as v}from"./Button.vue_vue_type_script_setup_true_lang-ClV4YfXb.js";import{_ as V}from"./Dialog.vue_vue_type_script_setup_true_lang-CSPK-Rcg.js";import{_ as N}from"./Input.vue_vue_type_script_setup_true_lang-BM1xhrlf.js";import{_ as A}from"./Spinner.vue_vue_type_script_setup_true_lang-BkmDerVR.js";import{d as B,y as L,c as l,a as t,e as r,w as n,s as i,o as a,q as p,F as P,r as F,t as u,h as k,k as I,i as j,K as y}from"./index-DVV3ZbZ2.js";import{a as h}from"./utils-7WVCegLb.js";const D={class:"p-6"},R={class:"flex items-center justify-between mb-6"},z={key:0,class:"flex items-center justify-center h-20"},M={key:1,class:"text-center text-muted-foreground py-8 text-sm"},T={key:2,class:"w-full"},U={class:"px-4 py-3 text-sm text-foreground"},q={class:"px-4 py-3 text-sm font-mono text-muted-foreground"},E={class:"px-4 py-3 text-xs text-muted-foreground"},H={class:"px-4 py-3 text-xs text-muted-foreground"},S={class:"px-4 py-3 text-right"},G={class:"space-y-4"},J={key:0,class:"rounded-md bg-emerald-500/10 border border-emerald-500/30 p-3"},O={class:"text-xs font-mono text-foreground break-all"},Q={key:1,class:"space-y-1.5"},re=B({__name:"KeysView",setup(W){const f=i([]),_=i(!1),c=i(!1),m=i(""),x=i(!1),d=i(null);L(()=>g());async function g(){_.value=!0;try{const o=await b.keys();f.value=o.data}finally{_.value=!1}}async function w(){if(m.value.trim()){x.value=!0;try{const o=await b.createKey({label:m.value});d.value=o.data.key,y.success("API key created"),await g(),m.value=""}catch{y.error("Failed to create key")}finally{x.value=!1}}}async function C(o){if(confirm(`Revoke key "${o.label}"? This cannot be undone.`))try{await b.revokeKey(o.id),y.success("Key revoked"),f.value=f.value.filter(e=>e.id!==o.id)}catch{y.error("Failed to revoke key")}}return(o,e)=>(a(),l("div",D,[t("div",R,[e[5]||(e[5]=t("h2",{class:"text-lg font-semibold text-foreground"},"API Keys",-1)),r(v,{size:"sm",onClick:e[0]||(e[0]=s=>{c.value=!0,d.value=null})},{default:n(()=>[...e[4]||(e[4]=[t("svg",{class:"h-4 w-4 mr-1.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),p(" New Key ",-1)])]),_:1})]),r(K,null,{default:n(()=>[r($,{class:"p-0"},{default:n(()=>[_.value?(a(),l("div",z,[r(A,{class:"text-primary"})])):f.value.length===0?(a(),l("div",M," No API keys ")):(a(),l("table",T,[e[7]||(e[7]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Label"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Prefix"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Created"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Last Used"),t("th",{class:"px-4 py-3"})])],-1)),t("tbody",null,[(a(!0),l(P,null,F(f.value,s=>(a(),l("tr",{key:s.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",U,u(s.label),1),t("td",q,u(s.prefix)+"...",1),t("td",E,u(k(h)(s.created_at)),1),t("td",H,u(s.last_used?k(h)(s.last_used):"Never"),1),t("td",S,[r(v,{variant:"ghost",size:"sm",class:"text-destructive",onClick:X=>C(s)},{default:n(()=>[...e[6]||(e[6]=[p(" Revoke ",-1)])]),_:1},8,["onClick"])])]))),128))])]))]),_:1})]),_:1}),r(V,{open:c.value,title:"Create API Key",onClose:e[3]||(e[3]=s=>c.value=!1)},{footer:n(()=>[r(v,{variant:"outline",onClick:e[2]||(e[2]=s=>c.value=!1)},{default:n(()=>[p(u(d.value?"Done":"Cancel"),1)]),_:1}),d.value?j("",!0):(a(),I(v,{key:0,loading:x.value,onClick:w},{default:n(()=>[...e[10]||(e[10]=[p(" Create ",-1)])]),_:1},8,["loading"]))]),default:n(()=>[t("div",G,[d.value?(a(),l("div",J,[e[8]||(e[8]=t("p",{class:"text-xs text-emerald-400 font-medium mb-1"},"Key created — save it now!",-1)),t("p",O,u(d.value),1)])):(a(),l("div",Q,[e[9]||(e[9]=t("label",{class:"text-sm font-medium text-foreground"},"Label",-1)),r(N,{modelValue:m.value,"onUpdate:modelValue":e[1]||(e[1]=s=>m.value=s),placeholder:"e.g. claude-collector",disabled:x.value},null,8,["modelValue","disabled"])]))])]),_:1},8,["open"])]))}});export{re as default};

View file

@ -1 +1 @@
import{G as T,s as g,d as J,u as O,y as V,c as f,a as o,n as b,h as l,t as v,k as $,w as x,i as k,e as C,o as c,q as w,F as B,r as F,j as z}from"./index-BP_aNEdP.js";import{_ as A,a as D}from"./CardContent.vue_vue_type_script_setup_true_lang-B8VjSxLa.js";import{_ as N}from"./Button.vue_vue_type_script_setup_true_lang-B_YE_XNW.js";import"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-Cu49-Cc4.js";function U(E){const e=g([]),i=g(!1),m=g(null);let s=null,r=null,u=!1;function p(){if(!u)try{s=new EventSource(E),s.onopen=()=>{i.value=!0,m.value=null},s.onmessage=n=>{try{const y=JSON.parse(n.data);e.value.push({type:"message",data:y}),e.value.length>200&&e.value.shift()}catch{e.value.push({type:"message",data:n.data})}},s.addEventListener("session_start",n=>{try{e.value.push({type:"session_start",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_start",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("session_end",n=>{try{e.value.push({type:"session_end",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_end",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("activity",n=>{try{e.value.push({type:"activity",data:JSON.parse(n.data)})}catch{e.value.push({type:"activity",data:n.data})}e.value.length>200&&e.value.shift()}),s.onerror=()=>{i.value=!1,m.value="Connection lost, reconnecting...",s==null||s.close(),s=null,u||(r=setTimeout(()=>p(),5e3))}}catch{m.value="Failed to connect to event stream",u||(r=setTimeout(()=>p(),5e3))}}function _(){u=!0,r&&clearTimeout(r),s==null||s.close(),s=null,i.value=!1}function h(){e.value=[]}return T(()=>{_()}),{events:e,connected:i,error:m,connect:p,disconnect:_,clearEvents:h}}const I={class:"p-6 h-full flex flex-col"},R={class:"flex items-center gap-3 mb-4"},q={class:"flex items-center gap-2"},G={class:"text-xs text-muted-foreground"},M={key:0,class:"mb-4 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2"},P={key:0,class:"flex items-center justify-center h-full text-sm text-muted-foreground"},W={key:1,class:"overflow-y-auto h-full font-mono text-xs"},H={class:"flex-1 min-w-0"},K={class:"flex items-center gap-2 flex-wrap"},Q={key:0,class:"text-muted-foreground"},X={class:"text-muted-foreground truncate mt-0.5"},ne=J({__name:"LiveView",setup(E){const e=O(),i=e.getToken(),m=`/cc-dashboard/api/events${i?`?token=${encodeURIComponent(i)}`:""}`,{events:s,connected:r,error:u,connect:p,clearEvents:_}=U(m);V(()=>{e.isAuthenticated&&i&&p()});const h=z(()=>[...s.value].reverse().slice(0,100));function n(t){return t==="session_start"?"text-emerald-400":t==="session_end"?"text-amber-400":t==="activity"?"text-blue-400":"text-muted-foreground"}function y(t){return t==="session_start"?"▶":t==="session_end"?"■":t==="activity"?"●":"○"}function j(t){if(typeof t=="string")return t;if(t&&typeof t=="object"){const a=t;return a.message||a.summary||JSON.stringify(t)}return String(t)}function S(t){if(t&&typeof t=="object"){const a=t;return a.display_name||a.project_id||""}return""}return(t,a)=>(c(),f("div",I,[o("div",R,[a[2]||(a[2]=o("h2",{class:"text-lg font-semibold text-foreground flex-1"},"Live Feed",-1)),o("div",q,[o("div",{class:b(["h-2 w-2 rounded-full",l(r)?"bg-emerald-500 animate-pulse":"bg-red-500"])},null,2),o("span",G,v(l(r)?"Connected":"Disconnected"),1)]),l(r)?k("",!0):(c(),$(N,{key:0,variant:"outline",size:"sm",onClick:l(p)},{default:x(()=>[...a[0]||(a[0]=[w(" Reconnect ",-1)])]),_:1},8,["onClick"])),C(N,{variant:"ghost",size:"sm",onClick:l(_)},{default:x(()=>[...a[1]||(a[1]=[w(" Clear ",-1)])]),_:1},8,["onClick"])]),l(u)&&!l(r)?(c(),f("div",M,v(l(u)),1)):k("",!0),C(A,{class:"flex-1 overflow-hidden"},{default:x(()=>[C(D,{class:"p-0 h-full"},{default:x(()=>[h.value.length===0?(c(),f("div",P,[...a[3]||(a[3]=[o("div",{class:"text-center"},[o("div",{class:"text-2xl mb-2"},"📡"),o("p",null,"Waiting for events..."),o("p",{class:"text-xs mt-1"},"Activity will appear here in real-time")],-1)])])):(c(),f("div",W,[(c(!0),f(B,null,F(h.value,(d,L)=>(c(),f("div",{key:L,class:"flex items-start gap-2 px-4 py-1.5 hover:bg-muted/50 border-b border-border/30"},[o("span",{class:b([n(d.type),"shrink-0 mt-0.5"])},v(y(d.type)),3),o("div",H,[o("div",K,[o("span",{class:b([n(d.type),"font-medium"])},v(d.type),3),S(d.data)?(c(),f("span",Q,v(S(d.data)),1)):k("",!0)]),o("p",X,v(j(d.data)),1)])]))),128))]))]),_:1})]),_:1})]))}});export{ne as default};
import{G as T,s as g,d as J,u as O,y as V,c as f,a as o,n as b,h as l,t as v,k as $,w as x,i as k,e as C,o as c,q as w,F as B,r as F,j as z}from"./index-DVV3ZbZ2.js";import{_ as A,a as D}from"./CardContent.vue_vue_type_script_setup_true_lang-DdGXaWEa.js";import{_ as N}from"./Button.vue_vue_type_script_setup_true_lang-ClV4YfXb.js";import"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-BkmDerVR.js";function U(E){const e=g([]),i=g(!1),m=g(null);let s=null,r=null,u=!1;function p(){if(!u)try{s=new EventSource(E),s.onopen=()=>{i.value=!0,m.value=null},s.onmessage=n=>{try{const y=JSON.parse(n.data);e.value.push({type:"message",data:y}),e.value.length>200&&e.value.shift()}catch{e.value.push({type:"message",data:n.data})}},s.addEventListener("session_start",n=>{try{e.value.push({type:"session_start",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_start",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("session_end",n=>{try{e.value.push({type:"session_end",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_end",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("activity",n=>{try{e.value.push({type:"activity",data:JSON.parse(n.data)})}catch{e.value.push({type:"activity",data:n.data})}e.value.length>200&&e.value.shift()}),s.onerror=()=>{i.value=!1,m.value="Connection lost, reconnecting...",s==null||s.close(),s=null,u||(r=setTimeout(()=>p(),5e3))}}catch{m.value="Failed to connect to event stream",u||(r=setTimeout(()=>p(),5e3))}}function _(){u=!0,r&&clearTimeout(r),s==null||s.close(),s=null,i.value=!1}function h(){e.value=[]}return T(()=>{_()}),{events:e,connected:i,error:m,connect:p,disconnect:_,clearEvents:h}}const I={class:"p-6 h-full flex flex-col"},R={class:"flex items-center gap-3 mb-4"},q={class:"flex items-center gap-2"},G={class:"text-xs text-muted-foreground"},M={key:0,class:"mb-4 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2"},P={key:0,class:"flex items-center justify-center h-full text-sm text-muted-foreground"},W={key:1,class:"overflow-y-auto h-full font-mono text-xs"},H={class:"flex-1 min-w-0"},K={class:"flex items-center gap-2 flex-wrap"},Q={key:0,class:"text-muted-foreground"},X={class:"text-muted-foreground truncate mt-0.5"},ne=J({__name:"LiveView",setup(E){const e=O(),i=e.getToken(),m=`/cc-dashboard/api/events${i?`?token=${encodeURIComponent(i)}`:""}`,{events:s,connected:r,error:u,connect:p,clearEvents:_}=U(m);V(()=>{e.isAuthenticated&&i&&p()});const h=z(()=>[...s.value].reverse().slice(0,100));function n(t){return t==="session_start"?"text-emerald-400":t==="session_end"?"text-amber-400":t==="activity"?"text-blue-400":"text-muted-foreground"}function y(t){return t==="session_start"?"▶":t==="session_end"?"■":t==="activity"?"●":"○"}function j(t){if(typeof t=="string")return t;if(t&&typeof t=="object"){const a=t;return a.message||a.summary||JSON.stringify(t)}return String(t)}function S(t){if(t&&typeof t=="object"){const a=t;return a.display_name||a.project_id||""}return""}return(t,a)=>(c(),f("div",I,[o("div",R,[a[2]||(a[2]=o("h2",{class:"text-lg font-semibold text-foreground flex-1"},"Live Feed",-1)),o("div",q,[o("div",{class:b(["h-2 w-2 rounded-full",l(r)?"bg-emerald-500 animate-pulse":"bg-red-500"])},null,2),o("span",G,v(l(r)?"Connected":"Disconnected"),1)]),l(r)?k("",!0):(c(),$(N,{key:0,variant:"outline",size:"sm",onClick:l(p)},{default:x(()=>[...a[0]||(a[0]=[w(" Reconnect ",-1)])]),_:1},8,["onClick"])),C(N,{variant:"ghost",size:"sm",onClick:l(_)},{default:x(()=>[...a[1]||(a[1]=[w(" Clear ",-1)])]),_:1},8,["onClick"])]),l(u)&&!l(r)?(c(),f("div",M,v(l(u)),1)):k("",!0),C(A,{class:"flex-1 overflow-hidden"},{default:x(()=>[C(D,{class:"p-0 h-full"},{default:x(()=>[h.value.length===0?(c(),f("div",P,[...a[3]||(a[3]=[o("div",{class:"text-center"},[o("div",{class:"text-2xl mb-2"},"📡"),o("p",null,"Waiting for events..."),o("p",{class:"text-xs mt-1"},"Activity will appear here in real-time")],-1)])])):(c(),f("div",W,[(c(!0),f(B,null,F(h.value,(d,L)=>(c(),f("div",{key:L,class:"flex items-start gap-2 px-4 py-1.5 hover:bg-muted/50 border-b border-border/30"},[o("span",{class:b([n(d.type),"shrink-0 mt-0.5"])},v(y(d.type)),3),o("div",H,[o("div",K,[o("span",{class:b([n(d.type),"font-medium"])},v(d.type),3),S(d.data)?(c(),f("span",Q,v(S(d.data)),1)):k("",!0)]),o("p",X,v(j(d.data)),1)])]))),128))]))]),_:1})]),_:1})]))}});export{ne as default};

View file

@ -1 +1 @@
import{d as h,u as f,c as o,a as t,b as m,e as a,w as d,o as r,f as g,g as p,h as i,t as x,i as w}from"./index-BP_aNEdP.js";import{_ as y,a as b}from"./CardContent.vue_vue_type_script_setup_true_lang-B8VjSxLa.js";import"./utils-7WVCegLb.js";const v={class:"min-h-screen flex items-center justify-center bg-background p-4"},_={class:"w-full max-w-sm"},k={class:"space-y-4"},C={key:0,class:"rounded-md bg-destructive/10 border border-destructive/30 px-3 py-2 text-sm text-destructive"},B=["disabled"],V={key:0},S={key:1},M=h({__name:"LoginView",setup(F){const c=g(),l=p(),s=f();async function u(){try{await s.loginWithMicrosoft();const n=l.query.redirect;c.push(n??"/")}catch{}}return(n,e)=>(r(),o("div",v,[t("div",_,[e[2]||(e[2]=m('<div class="text-center mb-8"><div class="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary mb-3"><svg class="h-7 w-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg></div><h1 class="text-2xl font-bold text-foreground">CC Dashboard</h1><p class="text-sm text-muted-foreground mt-1">Corporate Planning Hub</p></div>',1)),a(y,null,{default:d(()=>[a(b,{class:"pt-6"},{default:d(()=>[t("div",k,[i(s).error?(r(),o("div",C,x(i(s).error),1)):w("",!0),t("button",{type:"button",disabled:i(s).loading,class:"w-full flex items-center justify-center gap-3 rounded-md border border-border bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",onClick:u},[e[0]||(e[0]=t("svg",{class:"h-5 w-5 shrink-0",viewBox:"0 0 21 21",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"1",y:"1",width:"9",height:"9",fill:"#F25022"}),t("rect",{x:"11",y:"1",width:"9",height:"9",fill:"#7FBA00"}),t("rect",{x:"1",y:"11",width:"9",height:"9",fill:"#00A4EF"}),t("rect",{x:"11",y:"11",width:"9",height:"9",fill:"#FFB900"})],-1)),i(s).loading?(r(),o("span",V,"Signing in…")):(r(),o("span",S,"Sign in with Microsoft"))],8,B),e[1]||(e[1]=t("p",{class:"text-center text-xs text-muted-foreground"}," Use your @oliver.agency account ",-1))])]),_:1})]),_:1})])]))}});export{M as default};
import{d as h,u as f,c as o,a as t,b as m,e as a,w as d,o as r,f as g,g as p,h as i,t as x,i as w}from"./index-DVV3ZbZ2.js";import{_ as y,a as b}from"./CardContent.vue_vue_type_script_setup_true_lang-DdGXaWEa.js";import"./utils-7WVCegLb.js";const v={class:"min-h-screen flex items-center justify-center bg-background p-4"},_={class:"w-full max-w-sm"},k={class:"space-y-4"},C={key:0,class:"rounded-md bg-destructive/10 border border-destructive/30 px-3 py-2 text-sm text-destructive"},B=["disabled"],V={key:0},S={key:1},M=h({__name:"LoginView",setup(F){const c=g(),l=p(),s=f();async function u(){try{await s.loginWithMicrosoft();const n=l.query.redirect;c.push(n??"/")}catch{}}return(n,e)=>(r(),o("div",v,[t("div",_,[e[2]||(e[2]=m('<div class="text-center mb-8"><div class="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary mb-3"><svg class="h-7 w-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg></div><h1 class="text-2xl font-bold text-foreground">CC Dashboard</h1><p class="text-sm text-muted-foreground mt-1">Corporate Planning Hub</p></div>',1)),a(y,null,{default:d(()=>[a(b,{class:"pt-6"},{default:d(()=>[t("div",k,[i(s).error?(r(),o("div",C,x(i(s).error),1)):w("",!0),t("button",{type:"button",disabled:i(s).loading,class:"w-full flex items-center justify-center gap-3 rounded-md border border-border bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",onClick:u},[e[0]||(e[0]=t("svg",{class:"h-5 w-5 shrink-0",viewBox:"0 0 21 21",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"1",y:"1",width:"9",height:"9",fill:"#F25022"}),t("rect",{x:"11",y:"1",width:"9",height:"9",fill:"#7FBA00"}),t("rect",{x:"1",y:"11",width:"9",height:"9",fill:"#00A4EF"}),t("rect",{x:"11",y:"11",width:"9",height:"9",fill:"#FFB900"})],-1)),i(s).loading?(r(),o("span",V,"Signing in…")):(r(),o("span",S,"Sign in with Microsoft"))],8,B),e[1]||(e[1]=t("p",{class:"text-center text-xs text-muted-foreground"}," Use your @oliver.agency account ",-1))])]),_:1})]),_:1})])]))}});export{M as default};

View file

@ -1 +1 @@
import{c as r}from"./utils-7WVCegLb.js";import{d as s,o as n,c as t,n as l,h as c,a as d,B as u}from"./index-BP_aNEdP.js";const h=s({__name:"Progress",props:{value:{},max:{default:100},class:{},color:{default:"default"}},setup(a){const e=a,o=()=>Math.min(100,Math.max(0,e.value/e.max*100));return(i,m)=>(n(),t("div",{class:l(c(r)("relative h-2 w-full overflow-hidden rounded-full bg-secondary",e.class))},[d("div",{class:l(["h-full rounded-full transition-all duration-300",{"bg-primary":a.color==="default","bg-emerald-500":a.color==="success","bg-amber-500":a.color==="warning","bg-red-500":a.color==="danger"}]),style:u({width:`${o()}%`})},null,6)],2))}});export{h as _};
import{c as r}from"./utils-7WVCegLb.js";import{d as s,o as n,c as t,n as l,h as c,a as d,B as u}from"./index-DVV3ZbZ2.js";const h=s({__name:"Progress",props:{value:{},max:{default:100},class:{},color:{default:"default"}},setup(a){const e=a,o=()=>Math.min(100,Math.max(0,e.value/e.max*100));return(i,m)=>(n(),t("div",{class:l(c(r)("relative h-2 w-full overflow-hidden rounded-full bg-secondary",e.class))},[d("div",{class:l(["h-full rounded-full transition-all duration-300",{"bg-primary":a.color==="default","bg-emerald-500":a.color==="success","bg-amber-500":a.color==="warning","bg-red-500":a.color==="danger"}]),style:u({width:`${o()}%`})},null,6)],2))}});export{h as _};

View file

@ -0,0 +1 @@
[data-v-32c92954] .prose{color:hsl(var(--foreground))}[data-v-32c92954] .prose h1{color:hsl(var(--foreground));font-weight:600;margin-top:1rem;margin-bottom:.5rem}[data-v-32c92954] .prose p{margin-bottom:.75rem;color:hsl(var(--muted-foreground))}[data-v-32c92954] .prose ul{margin-left:1.25rem;color:hsl(var(--muted-foreground))}[data-v-32c92954] .prose li{margin-bottom:.25rem}[data-v-32c92954] .prose code{background:hsl(var(--muted));padding:.125rem .25rem;border-radius:.25rem;font-size:.85em;word-break:break-word;overflow-wrap:anywhere}[data-v-32c92954] .prose pre{white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;max-width:100%;overflow-x:auto}[data-v-32c92954] .prose pre code{word-break:break-word;overflow-wrap:anywhere}

View file

@ -1 +0,0 @@
[data-v-5c75fc6a] .prose{color:hsl(var(--foreground))}[data-v-5c75fc6a] .prose h1{color:hsl(var(--foreground));font-weight:600;margin-top:1rem;margin-bottom:.5rem}[data-v-5c75fc6a] .prose p{margin-bottom:.75rem;color:hsl(var(--muted-foreground))}[data-v-5c75fc6a] .prose ul{margin-left:1.25rem;color:hsl(var(--muted-foreground))}[data-v-5c75fc6a] .prose li{margin-bottom:.25rem}[data-v-5c75fc6a] .prose code{background:hsl(var(--muted));padding:.125rem .25rem;border-radius:.25rem;font-size:.85em;word-break:break-word;overflow-wrap:anywhere}[data-v-5c75fc6a] .prose pre{white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;max-width:100%;overflow-x:auto}[data-v-5c75fc6a] .prose pre code{word-break:break-word;overflow-wrap:anywhere}

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{d as N,u as E,y as P,c as U,a,e as t,w as s,s as f,o as k,q as u,h as c,k as z,i as B,E as I,K as x}from"./index-BP_aNEdP.js";import{u as F}from"./devops-DxRDHPW5.js";import{_ as w,a as V}from"./CardContent.vue_vue_type_script_setup_true_lang-B8VjSxLa.js";import{_ as $,a as S}from"./CardTitle.vue_vue_type_script_setup_true_lang-KH4UhlTi.js";import{_ as y}from"./Input.vue_vue_type_script_setup_true_lang-SeygKlpx.js";import{_}from"./Button.vue_vue_type_script_setup_true_lang-B_YE_XNW.js";import{_ as O}from"./DevopsConnectForm.vue_vue_type_script_setup_true_lang-DQc_dk7n.js";import{i as C}from"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-Cu49-Cc4.js";function T(i,l){const n=`/cc-dashboard/api/export/timesheet.csv?from=${i}&to=${l}`,o=document.createElement("a");o.href=n,o.download=`timesheet-${i}-${l}.csv`,o.click()}function A(i,l){const n=`/cc-dashboard/api/export/timesheet.ics?from=${i}&to=${l}`,o=document.createElement("a");o.href=n,o.download=`timesheet-${i}-${l}.ics`,o.click()}const H={class:"p-6 space-y-6 max-w-2xl"},K={class:"space-y-1.5"},M={class:"space-y-1.5"},j={class:"flex items-center justify-between"},q={class:"flex items-center gap-3 flex-wrap"},h={class:"space-y-1.5"},G={class:"space-y-1.5"},J={class:"flex items-center gap-2"},se=N({__name:"SettingsView",setup(i){const l=E(),n=F(),o=f(""),p=f(0),g=f(!1),d=f(""),m=f("");P(()=>{l.user&&(o.value=l.user.username,p.value=l.user.daily_overhead_hours??0),n.fetchIntegration();const v=new Date;m.value=C(v);const e=new Date(v);e.setDate(v.getDate()-30),d.value=C(e)});async function D(){g.value=!0;try{await I.patch("/api/auth/me",{username:o.value,daily_overhead_hours:p.value}),await l.fetchMe(),x.success("Profile saved")}catch{x.error("Failed to save profile")}finally{g.value=!1}}async function b(){try{await n.sync(),x.success("Sync complete")}catch{x.error(n.error??"Sync failed")}}return(v,e)=>(k(),U("div",H,[e[18]||(e[18]=a("h2",{class:"text-lg font-semibold text-foreground"},"Settings",-1)),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[t(S,{class:"text-sm"},{default:s(()=>[...e[6]||(e[6]=[u("Profile",-1)])]),_:1})]),_:1}),t(V,{class:"space-y-4"},{default:s(()=>[a("div",K,[e[7]||(e[7]=a("label",{class:"text-sm font-medium text-foreground"},"Username",-1)),t(y,{modelValue:o.value,"onUpdate:modelValue":e[0]||(e[0]=r=>o.value=r),placeholder:"username"},null,8,["modelValue"])]),a("div",M,[e[8]||(e[8]=a("label",{class:"text-sm font-medium text-foreground"},"Daily Overhead Hours",-1)),t(y,{modelValue:p.value,"onUpdate:modelValue":e[1]||(e[1]=r=>p.value=r),type:"number",min:"0",max:"8",step:"0.25",class:"w-32"},null,8,["modelValue"]),e[9]||(e[9]=a("p",{class:"text-xs text-muted-foreground"}," Hours per day to add for overhead / meetings ",-1))]),t(_,{loading:g.value,onClick:D},{default:s(()=>[...e[10]||(e[10]=[u("Save Profile",-1)])]),_:1},8,["loading"])]),_:1})]),_:1}),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[a("div",j,[t(S,{class:"text-sm"},{default:s(()=>[...e[11]||(e[11]=[u("Azure DevOps Integration",-1)])]),_:1}),c(n).integration?(k(),z(_,{key:0,variant:"outline",size:"sm",loading:c(n).syncing,onClick:b},{default:s(()=>[...e[12]||(e[12]=[u(" Sync Now ",-1)])]),_:1},8,["loading"])):B("",!0)])]),_:1}),t(V,null,{default:s(()=>[t(O)]),_:1})]),_:1}),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[t(S,{class:"text-sm"},{default:s(()=>[...e[13]||(e[13]=[u("Export",-1)])]),_:1})]),_:1}),t(V,{class:"space-y-4"},{default:s(()=>[a("div",q,[a("div",h,[e[14]||(e[14]=a("label",{class:"text-xs text-muted-foreground"},"From",-1)),t(y,{modelValue:d.value,"onUpdate:modelValue":e[2]||(e[2]=r=>d.value=r),type:"date",class:"h-8 text-xs"},null,8,["modelValue"])]),a("div",G,[e[15]||(e[15]=a("label",{class:"text-xs text-muted-foreground"},"To",-1)),t(y,{modelValue:m.value,"onUpdate:modelValue":e[3]||(e[3]=r=>m.value=r),type:"date",class:"h-8 text-xs"},null,8,["modelValue"])])]),a("div",J,[t(_,{variant:"outline",size:"sm",onClick:e[4]||(e[4]=r=>c(T)(d.value,m.value))},{default:s(()=>[...e[16]||(e[16]=[u(" Download CSV ",-1)])]),_:1}),t(_,{variant:"outline",size:"sm",onClick:e[5]||(e[5]=r=>c(A)(d.value,m.value))},{default:s(()=>[...e[17]||(e[17]=[u(" Download ICS ",-1)])]),_:1})])]),_:1})]),_:1})]))}});export{se as default};
import{d as N,u as E,y as P,c as U,a,e as t,w as s,s as f,o as k,q as u,h as c,k as z,i as B,E as I,K as x}from"./index-DVV3ZbZ2.js";import{u as F}from"./devops-HjUgCfao.js";import{_ as w,a as V}from"./CardContent.vue_vue_type_script_setup_true_lang-DdGXaWEa.js";import{_ as $,a as S}from"./CardTitle.vue_vue_type_script_setup_true_lang-CT9DRTds.js";import{_ as y}from"./Input.vue_vue_type_script_setup_true_lang-BM1xhrlf.js";import{_}from"./Button.vue_vue_type_script_setup_true_lang-ClV4YfXb.js";import{_ as O}from"./DevopsConnectForm.vue_vue_type_script_setup_true_lang-5nCaOxp1.js";import{i as C}from"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-BkmDerVR.js";function T(i,l){const n=`/cc-dashboard/api/export/timesheet.csv?from=${i}&to=${l}`,o=document.createElement("a");o.href=n,o.download=`timesheet-${i}-${l}.csv`,o.click()}function A(i,l){const n=`/cc-dashboard/api/export/timesheet.ics?from=${i}&to=${l}`,o=document.createElement("a");o.href=n,o.download=`timesheet-${i}-${l}.ics`,o.click()}const H={class:"p-6 space-y-6 max-w-2xl"},K={class:"space-y-1.5"},M={class:"space-y-1.5"},j={class:"flex items-center justify-between"},q={class:"flex items-center gap-3 flex-wrap"},h={class:"space-y-1.5"},G={class:"space-y-1.5"},J={class:"flex items-center gap-2"},se=N({__name:"SettingsView",setup(i){const l=E(),n=F(),o=f(""),p=f(0),g=f(!1),d=f(""),m=f("");P(()=>{l.user&&(o.value=l.user.username,p.value=l.user.daily_overhead_hours??0),n.fetchIntegration();const v=new Date;m.value=C(v);const e=new Date(v);e.setDate(v.getDate()-30),d.value=C(e)});async function D(){g.value=!0;try{await I.patch("/api/auth/me",{username:o.value,daily_overhead_hours:p.value}),await l.fetchMe(),x.success("Profile saved")}catch{x.error("Failed to save profile")}finally{g.value=!1}}async function b(){try{await n.sync(),x.success("Sync complete")}catch{x.error(n.error??"Sync failed")}}return(v,e)=>(k(),U("div",H,[e[18]||(e[18]=a("h2",{class:"text-lg font-semibold text-foreground"},"Settings",-1)),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[t(S,{class:"text-sm"},{default:s(()=>[...e[6]||(e[6]=[u("Profile",-1)])]),_:1})]),_:1}),t(V,{class:"space-y-4"},{default:s(()=>[a("div",K,[e[7]||(e[7]=a("label",{class:"text-sm font-medium text-foreground"},"Username",-1)),t(y,{modelValue:o.value,"onUpdate:modelValue":e[0]||(e[0]=r=>o.value=r),placeholder:"username"},null,8,["modelValue"])]),a("div",M,[e[8]||(e[8]=a("label",{class:"text-sm font-medium text-foreground"},"Daily Overhead Hours",-1)),t(y,{modelValue:p.value,"onUpdate:modelValue":e[1]||(e[1]=r=>p.value=r),type:"number",min:"0",max:"8",step:"0.25",class:"w-32"},null,8,["modelValue"]),e[9]||(e[9]=a("p",{class:"text-xs text-muted-foreground"}," Hours per day to add for overhead / meetings ",-1))]),t(_,{loading:g.value,onClick:D},{default:s(()=>[...e[10]||(e[10]=[u("Save Profile",-1)])]),_:1},8,["loading"])]),_:1})]),_:1}),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[a("div",j,[t(S,{class:"text-sm"},{default:s(()=>[...e[11]||(e[11]=[u("Azure DevOps Integration",-1)])]),_:1}),c(n).integration?(k(),z(_,{key:0,variant:"outline",size:"sm",loading:c(n).syncing,onClick:b},{default:s(()=>[...e[12]||(e[12]=[u(" Sync Now ",-1)])]),_:1},8,["loading"])):B("",!0)])]),_:1}),t(V,null,{default:s(()=>[t(O)]),_:1})]),_:1}),t(w,null,{default:s(()=>[t($,null,{default:s(()=>[t(S,{class:"text-sm"},{default:s(()=>[...e[13]||(e[13]=[u("Export",-1)])]),_:1})]),_:1}),t(V,{class:"space-y-4"},{default:s(()=>[a("div",q,[a("div",h,[e[14]||(e[14]=a("label",{class:"text-xs text-muted-foreground"},"From",-1)),t(y,{modelValue:d.value,"onUpdate:modelValue":e[2]||(e[2]=r=>d.value=r),type:"date",class:"h-8 text-xs"},null,8,["modelValue"])]),a("div",G,[e[15]||(e[15]=a("label",{class:"text-xs text-muted-foreground"},"To",-1)),t(y,{modelValue:m.value,"onUpdate:modelValue":e[3]||(e[3]=r=>m.value=r),type:"date",class:"h-8 text-xs"},null,8,["modelValue"])])]),a("div",J,[t(_,{variant:"outline",size:"sm",onClick:e[4]||(e[4]=r=>c(T)(d.value,m.value))},{default:s(()=>[...e[16]||(e[16]=[u(" Download CSV ",-1)])]),_:1}),t(_,{variant:"outline",size:"sm",onClick:e[5]||(e[5]=r=>c(A)(d.value,m.value))},{default:s(()=>[...e[17]||(e[17]=[u(" Download ICS ",-1)])]),_:1})])]),_:1})]),_:1})]))}});export{se as default};

View file

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

View file

@ -1 +1 @@
import{E as e}from"./index-BP_aNEdP.js";const i={users:()=>e.get("/api/admin/users"),keys:()=>e.get("/api/keys"),createKey:s=>e.post("/api/keys",s),revokeKey:s=>e.delete(`/api/keys/${s}`)};export{i as a};
import{E as e}from"./index-DVV3ZbZ2.js";const i={users:()=>e.get("/api/admin/users"),keys:()=>e.get("/api/keys"),createKey:s=>e.post("/api/keys",s),revokeKey:s=>e.delete(`/api/keys/${s}`)};export{i as a};

View file

@ -1 +1 @@
import{E as t}from"./index-BP_aNEdP.js";const e={summary:a=>t.get("/api/dashboard/summary",{params:a}),projects:a=>t.get("/api/dashboard/projects",{params:a}),timeline:a=>t.get("/api/dashboard/timeline",{params:a}),monthly:a=>t.get("/api/dashboard/monthly",{params:a}),dow:a=>t.get("/api/dashboard/dow",{params:a}),tools:a=>t.get("/api/dashboard/tools",{params:a}),activity:a=>t.get("/api/dashboard/activity",{params:a}),calendar:a=>t.get("/api/dashboard/calendar",{params:a}),project:(a,o)=>t.get("/api/dashboard/project/"+a,{params:o})};export{e as d};
import{E as t}from"./index-DVV3ZbZ2.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{E as s,C as I,s as o}from"./index-BP_aNEdP.js";const i={getIntegration:()=>s.get("/api/devops/integration"),saveIntegration:e=>s.put("/api/devops/integration",e),deleteIntegration:()=>s.delete("/api/devops/integration"),sync:()=>s.post("/api/devops/sync"),workItems:e=>s.get("/api/devops/work-items",{params:e?{state:e}:void 0})},m=I("devops",()=>{const e=o(null),l=o([]),r=o(!1),n=o(!1),c=o(null);async function u(){n.value=!0;try{const t=await i.getIntegration();e.value=t.data}catch{e.value=null}finally{n.value=!1}}async function d(t){const a=await i.saveIntegration(t);e.value=a.data}async function g(){await i.deleteIntegration(),e.value=null}async function f(){var t,a;r.value=!0,c.value=null;try{await i.sync(),await u()}catch(v){const p=v;throw c.value=((a=(t=p.response)==null?void 0:t.data)==null?void 0:a.detail)??p.message??"Sync failed",v}finally{r.value=!1}}async function y(t){n.value=!0;try{const a=await i.workItems(t);l.value=a.data}catch{l.value=[]}finally{n.value=!1}}return{integration:e,workItems:l,syncing:r,loading:n,error:c,fetchIntegration:u,saveIntegration:d,deleteIntegration:g,sync:f,fetchWorkItems:y}});export{m as u};
import{E as s,C as I,s as o}from"./index-DVV3ZbZ2.js";const i={getIntegration:()=>s.get("/api/devops/integration"),saveIntegration:e=>s.put("/api/devops/integration",e),deleteIntegration:()=>s.delete("/api/devops/integration"),sync:()=>s.post("/api/devops/sync"),workItems:e=>s.get("/api/devops/work-items",{params:e?{state:e}:void 0})},m=I("devops",()=>{const e=o(null),l=o([]),r=o(!1),n=o(!1),c=o(null);async function u(){n.value=!0;try{const t=await i.getIntegration();e.value=t.data}catch{e.value=null}finally{n.value=!1}}async function d(t){const a=await i.saveIntegration(t);e.value=a.data}async function g(){await i.deleteIntegration(),e.value=null}async function f(){var t,a;r.value=!0,c.value=null;try{await i.sync(),await u()}catch(v){const p=v;throw c.value=((a=(t=p.response)==null?void 0:t.data)==null?void 0:a.detail)??p.message??"Sync failed",v}finally{r.value=!1}}async function y(t){n.value=!0;try{const a=await i.workItems(t);l.value=a.data}catch{l.value=[]}finally{n.value=!1}}return{integration:e,workItems:l,syncing:r,loading:n,error:c,fetchIntegration:u,saveIntegration:d,deleteIntegration:g,sync:f,fetchWorkItems:y}});export{m as u};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{E as l,C as w,s as i}from"./index-BP_aNEdP.js";const o={list:a=>l.get("/api/tasks",{params:a}),get:a=>l.get(`/api/tasks/${a}`),create:a=>l.post("/api/tasks",a),update:(a,s)=>l.patch(`/api/tasks/${a}`,s),remove:a=>l.delete(`/api/tasks/${a}`),complete:a=>l.post(`/api/tasks/${a}/complete`),blocks:a=>l.get(`/api/tasks/${a}/blocks`),createBlock:(a,s)=>l.post(`/api/tasks/${a}/blocks`,s),updateBlock:(a,s)=>l.patch(`/api/tasks/blocks/${a}`,s),deleteBlock:a=>l.delete(`/api/tasks/blocks/${a}`)},b=Object.freeze(Object.defineProperty({__proto__:null,tasksApi:o},Symbol.toStringTag,{value:"Module"})),$=w("tasks",()=>{const a=i([]),s=i(!1),n=i(null);async function u(t){s.value=!0,n.value=null;try{const e=await o.list({date:t});a.value=e.data}catch(e){const c=e;n.value=c.message??"Failed to fetch tasks"}finally{s.value=!1}}async function d(t){s.value=!0,n.value=null;try{const e=await o.list(t?{project_id:t}:void 0);a.value=e.data}catch(e){const c=e;n.value=c.message??"Failed to fetch tasks"}finally{s.value=!1}}async function p(t){const e=await o.create(t);return a.value.push(e.data),e.data}async function k(t,e){const c=await o.update(t,e),r=a.value.findIndex(h=>h.id===t);return r!==-1&&(a.value[r]=c.data),c.data}async function f(t){await o.remove(t),a.value=a.value.filter(e=>e.id!==t)}async function v(t){const e=await o.complete(t),c=a.value.findIndex(r=>r.id===t);return c!==-1&&(a.value[c]=e.data),e.data}async function y(t,e){return(await o.createBlock(t,e)).data}async function m(t,e){return(await o.updateBlock(t,e)).data}async function g(t){await o.deleteBlock(t)}return{tasks:a,loading:s,error:n,fetchForDate:u,fetchAll:d,create:p,update:k,remove:f,complete:v,createBlock:y,updateBlock:m,deleteBlock:g}});export{b as t,$ as u};
import{E as l,C as w,s as i}from"./index-DVV3ZbZ2.js";const o={list:a=>l.get("/api/tasks",{params:a}),get:a=>l.get(`/api/tasks/${a}`),create:a=>l.post("/api/tasks",a),update:(a,s)=>l.patch(`/api/tasks/${a}`,s),remove:a=>l.delete(`/api/tasks/${a}`),complete:a=>l.post(`/api/tasks/${a}/complete`),blocks:a=>l.get(`/api/tasks/${a}/blocks`),createBlock:(a,s)=>l.post(`/api/tasks/${a}/blocks`,s),updateBlock:(a,s)=>l.patch(`/api/tasks/blocks/${a}`,s),deleteBlock:a=>l.delete(`/api/tasks/blocks/${a}`)},b=Object.freeze(Object.defineProperty({__proto__:null,tasksApi:o},Symbol.toStringTag,{value:"Module"})),$=w("tasks",()=>{const a=i([]),s=i(!1),n=i(null);async function u(t){s.value=!0,n.value=null;try{const e=await o.list({date:t});a.value=e.data}catch(e){const c=e;n.value=c.message??"Failed to fetch tasks"}finally{s.value=!1}}async function d(t){s.value=!0,n.value=null;try{const e=await o.list(t?{project_id:t}:void 0);a.value=e.data}catch(e){const c=e;n.value=c.message??"Failed to fetch tasks"}finally{s.value=!1}}async function p(t){const e=await o.create(t);return a.value.push(e.data),e.data}async function k(t,e){const c=await o.update(t,e),r=a.value.findIndex(h=>h.id===t);return r!==-1&&(a.value[r]=c.data),c.data}async function f(t){await o.remove(t),a.value=a.value.filter(e=>e.id!==t)}async function v(t){const e=await o.complete(t),c=a.value.findIndex(r=>r.id===t);return c!==-1&&(a.value[c]=e.data),e.data}async function y(t,e){return(await o.createBlock(t,e)).data}async function m(t,e){return(await o.updateBlock(t,e)).data}async function g(t){await o.deleteBlock(t)}return{tasks:a,loading:s,error:n,fetchForDate:u,fetchAll:d,create:p,update:k,remove:f,complete:v,createBlock:y,updateBlock:m,deleteBlock:g}});export{b as t,$ as u};

View file

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

View file

@ -66,7 +66,7 @@ const isShort = computed(() => effectiveHeight.value < 40)
>
<!-- Content -->
<div class="px-1.5 py-1 h-full flex flex-col text-white overflow-hidden">
<p class="text-xs font-semibold leading-tight truncate">{{ block.display_name }}</p>
<p class="text-xs font-semibold leading-tight truncate">{{ (block.kind === 'planned' && block.title) ? block.title : block.display_name }}</p>
<p v-if="!isShort && block.job_number" class="text-xs opacity-75 truncate">
{{ block.job_number }}
</p>

View file

@ -25,7 +25,7 @@ const pageTitle = computed(() => {
</script>
<template>
<div class="h-screen flex overflow-hidden bg-background">
<div class="h-screen flex overflow-hidden" style="background: linear-gradient(135deg, #f0f4fa 0%, #f8f9fd 50%, #fef9f5 100%)">
<!-- Mobile backdrop -->
<Transition
enter-active-class="transition-opacity duration-200"

View file

@ -19,7 +19,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ name: 'Dashboard', path: '/', icon: 'grid' },
{ name: 'Calendar', path: '/calendar', icon: 'calendar' },
{ name: 'Tasks', path: '/board', icon: 'check-square', external: true },
{ name: 'Tasks', path: '/board/', icon: 'check-square', external: true },
{ name: 'Projects', path: '/projects', icon: 'folder' },
{ name: 'Live Feed', path: '/live', icon: 'activity' },
{ name: 'Reports', path: '/reports', icon: 'file-text' },
@ -45,79 +45,79 @@ const userInitials = computed(() => {
</script>
<template>
<aside class="flex flex-col h-full bg-[hsl(222_44%_7%)] border-r border-border">
<aside class="flex flex-col h-full bg-white border-r border-slate-200/80">
<!-- Logo -->
<div class="h-14 flex items-center px-4 border-b border-border shrink-0">
<div class="h-16 flex items-center px-5 border-b border-slate-100 shrink-0">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-lg bg-primary/10 ring-1 ring-primary/25 flex items-center justify-center">
<svg class="h-4 w-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<div class="h-9 w-9 rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center shadow-md shadow-orange-200">
<svg class="h-4.5 w-4.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
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" />
</svg>
</div>
<div>
<p class="font-bold text-sm text-foreground leading-none tracking-tight">CC Dashboard</p>
<p class="text-[10px] text-white/45 mt-0.5 tracking-wide">Oliver Agency</p>
<p class="font-bold text-sm text-slate-800 leading-none tracking-tight">CC Dashboard</p>
<p class="text-[10px] text-slate-400 mt-0.5 tracking-wide font-medium">Oliver Agency</p>
</div>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto">
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<component
:is="item.external ? 'a' : RouterLink"
v-for="item in visibleItems"
:key="item.path"
v-bind="item.external ? { href: item.path, target: '_blank', rel: 'noopener' } : { to: item.path }"
:class="[
'relative flex items-center gap-3 px-3 h-10 rounded-lg text-sm font-medium transition-all duration-150 group',
'relative flex items-center gap-3 px-3 h-10 rounded-xl text-sm font-medium transition-all duration-200 group',
isActive(item.path)
? 'bg-primary/10 text-primary'
: 'text-white/60 hover:bg-white/5 hover:text-white',
? 'bg-orange-50 text-orange-600'
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-800',
]"
@click="emit('close')"
>
<!-- Active left indicator -->
<!-- Active left indicator pill -->
<span
v-if="isActive(item.path)"
class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-primary rounded-r-full"
class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-orange-500 rounded-r-full"
/>
<!-- Icons -->
<svg
v-if="item.icon === 'grid'"
:class="['h-4 w-4 shrink-0 transition-colors', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']"
:class="['h-4 w-4 shrink-0 transition-colors', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<svg v-else-if="item.icon === 'calendar'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'calendar'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<svg v-else-if="item.icon === 'check-square'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'check-square'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<svg v-else-if="item.icon === 'folder'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'folder'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<svg v-else-if="item.icon === 'activity'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'activity'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<svg v-else-if="item.icon === 'file-text'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'file-text'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<svg v-else-if="item.icon === 'key'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'key'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<svg v-else-if="item.icon === 'devops'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'devops'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<svg v-else-if="item.icon === 'settings'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'settings'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<svg v-else-if="item.icon === 'shield'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-primary' : 'text-white/40 group-hover:text-white/70']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-else-if="item.icon === 'shield'" :class="['h-4 w-4 shrink-0', isActive(item.path) ? 'text-orange-500' : 'text-slate-400 group-hover:text-slate-600']" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
@ -126,16 +126,16 @@ const userInitials = computed(() => {
</nav>
<!-- User info at bottom -->
<div class="p-3 border-t border-border shrink-0">
<div class="flex items-center gap-3 px-2 py-2 rounded-lg">
<div class="h-7 w-7 rounded-full bg-primary/15 ring-1 ring-primary/20 flex items-center justify-center text-[10px] font-bold text-primary shrink-0">
<div class="p-4 border-t border-slate-100 shrink-0">
<div class="flex items-center gap-3 px-2 py-2 rounded-xl bg-slate-50">
<div class="h-8 w-8 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center text-[11px] font-bold text-white shrink-0 shadow-sm shadow-orange-200">
{{ userInitials }}
</div>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-foreground truncate">{{ authStore.user?.username ?? authStore.user?.email }}</p>
<p class="text-xs font-semibold text-slate-700 truncate">{{ authStore.user?.username ?? authStore.user?.email }}</p>
<div class="flex items-center gap-1 mt-0.5">
<div class="h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]"></div>
<span class="text-[10px] text-white/45">Online</span>
<div class="h-1.5 w-1.5 rounded-full bg-emerald-400"></div>
<span class="text-[10px] text-slate-400 font-medium">Online</span>
</div>
</div>
</div>

View file

@ -31,7 +31,7 @@ function toggleDark() {
</script>
<template>
<header class="h-14 border-b border-border bg-card/95 backdrop-blur-sm flex items-center px-4 gap-3 shrink-0 sticky top-0 z-10">
<header class="h-14 border-b border-slate-200/80 bg-white/80 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0 sticky top-0 z-10 shadow-sm shadow-slate-100/60">
<!-- Mobile hamburger -->
<button
class="lg:hidden flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
@ -70,7 +70,7 @@ function toggleDark() {
<!-- User section -->
<div class="flex items-center gap-2.5">
<div class="h-7 w-7 rounded-full bg-primary/15 ring-1 ring-primary/25 flex items-center justify-center text-[10px] font-bold text-primary shrink-0">
<div class="h-7 w-7 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center text-[10px] font-bold text-white shrink-0 shadow-sm shadow-orange-200">
{{ (authStore.user?.username ?? authStore.user?.email ?? '?').slice(0, 2).toUpperCase() }}
</div>
<span class="hidden sm:block text-xs font-medium text-foreground max-w-[120px] truncate">

View file

@ -0,0 +1,250 @@
<script setup lang="ts">
import { ref, computed, reactive, watch } from 'vue'
export interface TableColumn {
key: string
title: string
width?: number
minWidth?: number
sortable?: boolean
filterable?: boolean
resizable?: boolean
align?: 'left' | 'center' | 'right'
className?: string
}
const props = withDefaults(defineProps<{
columns: TableColumn[]
rows: Record<string, unknown>[]
rowKey?: string
}>(), {
rowKey: 'id',
})
const emit = defineEmits<{
rowClick: [row: Record<string, unknown>]
}>()
const sortKey = ref<string | null>(null)
const sortDir = ref<'asc' | 'desc'>('asc')
const filters = reactive<Record<string, string>>({})
const colWidths = reactive<Record<string, number>>({})
const showFilters = ref(false)
props.columns.forEach((col) => {
if (col.width) colWidths[col.key] = col.width
})
function getWidth(col: TableColumn): string {
const w = colWidths[col.key] ?? col.width
return w ? `${w}px` : 'auto'
}
function toggleSort(col: TableColumn) {
if (!col.sortable) return
if (sortKey.value === col.key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = col.key
sortDir.value = 'asc'
}
}
const filtered = computed(() => {
let rows = [...props.rows]
for (const [k, v] of Object.entries(filters)) {
if (!v) continue
rows = rows.filter((row) => {
const val = String(row[k] ?? '').toLowerCase()
return val.includes(v.toLowerCase())
})
}
return rows
})
const displayed = computed(() => {
if (!sortKey.value) return filtered.value
const k = sortKey.value
return [...filtered.value].sort((a, b) => {
const av = a[k]
const bv = b[k]
if (av == null) return 1
if (bv == null) return -1
const cmp = String(av) < String(bv) ? -1 : String(av) > String(bv) ? 1 : 0
return sortDir.value === 'asc' ? cmp : -cmp
})
})
const hasActiveFilters = computed(() => Object.values(filters).some(Boolean))
let resizing: { key: string; startX: number; startW: number } | null = null
function startResize(col: TableColumn, e: MouseEvent) {
if (!col.resizable) return
e.preventDefault()
e.stopPropagation()
const currentW = colWidths[col.key] ?? col.width ?? 100
resizing = { key: col.key, startX: e.clientX, startW: currentW }
function onMove(e: MouseEvent) {
if (!resizing) return
const delta = e.clientX - resizing.startX
colWidths[resizing.key] = Math.max(col.minWidth ?? 60, resizing.startW + delta)
}
function onUp() {
resizing = null
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
function clearFilters() {
for (const key of Object.keys(filters)) {
filters[key] = ''
}
}
</script>
<template>
<div class="w-full">
<!-- Toolbar -->
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-muted-foreground">{{ displayed.length }} items</span>
<div class="flex-1" />
<button
v-if="columns.some(c => c.filterable)"
:class="[
'flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
(showFilters || hasActiveFilters)
? 'bg-primary/10 border-primary/20 text-primary'
: 'border-border text-muted-foreground hover:text-foreground hover:bg-muted/40',
]"
@click="showFilters = !showFilters"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" />
</svg>
Filter
<span v-if="hasActiveFilters" class="h-1.5 w-1.5 rounded-full bg-primary" />
</button>
<button
v-if="hasActiveFilters"
class="text-xs text-muted-foreground hover:text-foreground"
@click="clearFilters"
>
Clear
</button>
</div>
<!-- Table wrapper with horizontal scroll -->
<div class="overflow-x-auto rounded-xl border border-border">
<table class="w-full table-fixed border-collapse" style="min-width: 600px">
<colgroup>
<col
v-for="col in columns"
:key="col.key"
:style="{ width: getWidth(col) }"
/>
</colgroup>
<!-- Header -->
<thead class="bg-muted/30">
<tr>
<th
v-for="col in columns"
:key="col.key"
class="relative px-3 py-2 text-left group select-none"
:class="[col.sortable ? 'cursor-pointer hover:bg-muted/50' : '']"
@click="toggleSort(col)"
>
<div
class="flex items-center gap-1"
:class="{
'justify-center': col.align === 'center',
'justify-end': col.align === 'right',
}"
>
<span class="text-xs font-semibold text-muted-foreground tracking-wide uppercase">
{{ col.title }}
</span>
<!-- Sort indicator -->
<svg
v-if="col.sortable"
class="h-3 w-3 shrink-0 transition-opacity"
:class="sortKey === col.key ? 'opacity-100 text-primary' : 'opacity-0 group-hover:opacity-40'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
:d="sortKey === col.key && sortDir === 'desc'
? 'M19 9l-7 7-7-7'
: 'M5 15l7-7 7 7'"
/>
</svg>
</div>
<!-- Resize handle -->
<div
v-if="col.resizable"
class="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize opacity-0 group-hover:opacity-100 hover:opacity-100 hover:bg-primary/30 transition-opacity"
@mousedown.stop="startResize(col, $event)"
/>
</th>
</tr>
<!-- Filter row -->
<tr v-if="showFilters">
<th
v-for="col in columns"
:key="`filter-${col.key}`"
class="px-2 pb-2 pt-0"
>
<input
v-if="col.filterable"
v-model="filters[col.key]"
type="text"
class="w-full px-2 py-1 text-xs rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
:placeholder="`Filter…`"
@click.stop
/>
</th>
</tr>
</thead>
<!-- Body -->
<tbody>
<tr
v-for="(row, idx) in displayed"
:key="String(row[rowKey] ?? idx)"
class="border-t border-border/50 hover:bg-muted/20 transition-colors cursor-pointer"
@click="emit('rowClick', row)"
>
<td
v-for="col in columns"
:key="col.key"
class="px-3 py-2"
:class="[
col.className,
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left',
]"
>
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
<span class="text-sm text-foreground truncate block">{{ row[col.key] ?? '—' }}</span>
</slot>
</td>
</tr>
<tr v-if="displayed.length === 0">
<td :colspan="columns.length" class="text-center py-10 text-sm text-muted-foreground">
No items found
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View file

@ -1,5 +1,4 @@
@import url('https://api.fontshare.com/v2/css?f[]=satoshi@700,500,400&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@ -7,9 +6,9 @@
@layer base {
:root {
/* ── Light theme ─────────────────────────────────────────────── */
--background: 0 0% 98%;
--foreground: 222 47% 11%;
/* ── Soft light theme — warm white + amber accent ──────────────── */
--background: 220 33% 96%; /* #F0F4FA */
--foreground: 222 47% 11%; /* #0F172A */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
@ -17,61 +16,51 @@
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
/* Brand cyan */
--primary: 191 91% 37%;
/* Amber/orange primary */
--primary: 25 95% 53%; /* #F97316 */
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 94%;
--secondary-foreground: 222 47% 11%;
/* Sky secondary */
--secondary: 200 85% 50%; /* #0EA5E9 */
--secondary-foreground: 0 0% 100%;
--muted: 210 40% 94%;
--muted-foreground: 215 16% 47%;
--muted: 220 20% 93%;
--muted-foreground: 215 20% 48%;
--accent: 191 91% 92%;
--accent-foreground: 191 91% 25%;
--accent: 25 100% 96%;
--accent-foreground: 25 95% 40%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 214 32% 88%;
--input: 214 32% 88%;
--ring: 191 91% 37%;
--border: 220 20% 88%;
--input: 220 20% 88%;
--ring: 25 95% 53%;
--radius: 0.5rem;
--radius: 0.75rem;
--sidebar-background: 210 40% 96%;
--sidebar-foreground: 222 47% 11%;
--sidebar-primary: 191 91% 37%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 191 91% 92%;
--sidebar-accent-foreground: 191 91% 25%;
--sidebar-border: 214 32% 88%;
--sidebar-ring: 191 91% 37%;
/* Success / Warning */
--success: 158 64% 40%;
--success: 142 71% 45%;
--warning: 38 92% 50%;
}
/* Dark theme — operational dashboard dark navy / cyan */
/* Dark theme */
.dark {
--background: 226 49% 8%; /* #0b1020 */
--foreground: 220 40% 92%; /* #dce4f4 */
--background: 226 49% 8%;
--foreground: 220 40% 92%;
--card: 220 44% 10%; /* #0f1629 panel L1 */
--card: 220 44% 10%;
--card-foreground: 220 40% 92%;
--popover: 220 44% 12%; /* #111c34 panel L2 */
--popover: 220 44% 12%;
--popover-foreground: 220 40% 92%;
/* Cyan accent #57c7ff */
--primary: 200 100% 67%;
--primary: 25 95% 60%;
--primary-foreground: 226 49% 8%;
--secondary: 220 30% 14%;
--secondary-foreground: 220 20% 75%;
--secondary: 200 85% 55%;
--secondary-foreground: 226 49% 8%;
--muted: 220 30% 12%;
--muted: 220 30% 14%;
--muted-foreground: 220 12% 52%;
--accent: 220 30% 14%;
@ -82,9 +71,8 @@
--border: 220 28% 17%;
--input: 220 28% 17%;
--ring: 200 100% 67%;
--ring: 25 95% 60%;
/* Success / Warning */
--success: 158 64% 52%;
--warning: 38 92% 60%;
}
@ -97,40 +85,76 @@
body {
@apply bg-background text-foreground;
font-family: 'Satoshi', 'Inter', system-ui, -apple-system, sans-serif;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
}
/* Monospace for numeric values */
.tabular-nums,
[data-value],
.kpi-value {
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-variant-numeric: tabular-nums;
}
/* Thin scrollbar */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: hsl(220 28% 20%);
background: hsl(220 20% 80%);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(220 28% 30%);
background: hsl(220 20% 68%);
}
}
/* ── Utility: panel glow on hover ─────────────────────────────────── */
@layer utilities {
/* Glass card effect */
.glass-card {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 8px 32px rgba(0, 0, 0, 0.06);
}
.panel-glow {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 8px 32px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(255, 255, 255, 0.9);
}
.dark .panel-glow {
box-shadow: 0 0 0 1px hsl(var(--border)), 0 4px 24px -4px hsl(226 49% 4% / 0.6);
border-color: hsl(var(--border));
}
.panel-glow-hover:hover {
box-shadow: 0 0 0 1px hsl(200 100% 67% / 0.18), 0 8px 32px -4px hsl(200 100% 67% / 0.08);
box-shadow: 0 4px 24px rgba(249, 115, 22, 0.12), 0 1px 3px rgba(0,0,0,0.06);
border-color: rgba(249, 115, 22, 0.25);
transition: all 0.2s ease;
}
.accent-glow {
box-shadow: 0 0 16px -2px hsl(200 100% 67% / 0.35);
box-shadow: 0 0 20px -2px rgba(249, 115, 22, 0.4);
}
/* Smooth fade-in animation for page content */
.fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Slide up for cards */
.slide-up {
animation: slideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
}

View file

@ -8,6 +8,8 @@ import CardTitle from '@/components/ui/CardTitle.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Button from '@/components/ui/Button.vue'
import Spinner from '@/components/ui/Spinner.vue'
import DataTable from '@/components/ui/DataTable.vue'
import type { TableColumn } from '@/components/ui/DataTable.vue'
import DevopsConnectForm from '@/components/devops/DevopsConnectForm.vue'
import { toast } from 'vue-sonner'
@ -26,10 +28,22 @@ onMounted(async () => {
})
const filteredWorkItems = computed(() => {
if (stateFilter.value === 'All') return devopsStore.workItems
return devopsStore.workItems.filter((wi) => wi.state === stateFilter.value)
const items = stateFilter.value === 'All'
? devopsStore.workItems
: devopsStore.workItems.filter((wi) => wi.state === stateFilter.value)
return items as unknown as Record<string, unknown>[]
})
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: '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 },
{ key: 'id', title: 'Actions', width: 90, minWidth: 80, align: 'right' },
]
async function syncNow() {
try {
await devopsStore.sync()
@ -40,7 +54,8 @@ async function syncNow() {
}
}
async function cloneToTasks(wiId: string) {
async function cloneToTasks(wiId: string, e: Event) {
e.stopPropagation()
if (cloningId.value) return
cloningId.value = wiId
try {
@ -56,55 +71,64 @@ async function cloneToTasks(wiId: string) {
cloningId.value = null
}
}
function priorityClass(p: number | null | undefined) {
const v = p ?? 3
if (v <= 1) return 'text-red-500 font-bold'
if (v <= 2) return 'text-amber-500 font-semibold'
return 'text-slate-400'
}
function stateClass(s: string) {
if (s === 'Active' || s === 'Doing' || s === 'In Progress') return 'bg-blue-50 text-blue-600 border border-blue-100'
if (s === 'Resolved' || s === 'Done' || s === 'Closed') return 'bg-emerald-50 text-emerald-600 border border-emerald-100'
if (s === 'New') return 'bg-slate-50 text-slate-500 border border-slate-200'
return 'bg-slate-50 text-slate-500 border border-slate-200'
}
</script>
<template>
<div class="p-6 space-y-6">
<div class="p-6 space-y-5">
<div class="flex items-center justify-between gap-4 flex-wrap">
<h2 class="text-lg font-semibold text-foreground">Azure DevOps</h2>
<div class="flex items-center gap-2">
<Button
v-if="devopsStore.integration"
variant="outline"
size="sm"
:loading="devopsStore.syncing"
@click="syncNow"
>
Sync Now
</Button>
</div>
<Button
v-if="devopsStore.integration"
variant="outline"
size="sm"
:loading="devopsStore.syncing"
@click="syncNow"
>
Sync Now
</Button>
</div>
<!-- Connection status -->
<Card>
<CardContent class="pt-4">
<div v-if="devopsStore.loading && !devopsStore.integration" class="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Loading...</span>
</div>
<div v-else-if="devopsStore.integration" class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-[hsl(var(--success))]" />
<span class="text-sm text-foreground">
Connected to
<strong>{{ devopsStore.integration.organization }}</strong>
/
<strong>{{ devopsStore.integration.project }}</strong>
</span>
<span v-if="devopsStore.integration.last_synced_at" class="text-xs text-muted-foreground ml-2">
Last synced: {{ new Date(devopsStore.integration.last_synced_at).toLocaleString() }}
</span>
</div>
<div v-else class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-muted-foreground" />
<span class="text-sm text-muted-foreground">Not connected</span>
</div>
<p v-if="devopsStore.integration?.last_sync_error" class="text-xs text-destructive mt-2">
Error: {{ devopsStore.integration.last_sync_error }}
</p>
</CardContent>
</Card>
<div class="glass-card rounded-xl px-4 py-3 flex items-center gap-3 flex-wrap">
<template v-if="devopsStore.loading && !devopsStore.integration">
<Spinner size="sm" />
<span class="text-sm text-muted-foreground">Loading</span>
</template>
<template v-else-if="devopsStore.integration">
<div class="h-2 w-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-200" />
<span class="text-sm text-slate-700">
Connected to <strong class="text-slate-800">{{ devopsStore.integration.organization }}</strong>
<span class="text-slate-400 mx-1">·</span>
<span class="text-slate-500 text-xs">all assigned work items</span>
</span>
<span v-if="devopsStore.integration.last_synced_at" class="text-xs text-slate-400 ml-auto">
Last synced: {{ new Date(devopsStore.integration.last_synced_at).toLocaleString() }}
</span>
</template>
<template v-else>
<div class="h-2 w-2 rounded-full bg-slate-300" />
<span class="text-sm text-muted-foreground">Not connected</span>
</template>
<p v-if="devopsStore.integration?.last_sync_error" class="w-full text-xs text-destructive">
Error: {{ devopsStore.integration.last_sync_error }}
</p>
</div>
<!-- Connect form (shown when not connected) -->
<!-- Connect form -->
<Card v-if="!devopsStore.integration && !devopsStore.loading">
<CardHeader>
<CardTitle class="text-sm">Connect Azure DevOps</CardTitle>
@ -114,89 +138,94 @@ async function cloneToTasks(wiId: string) {
</CardContent>
</Card>
<!-- Work items list -->
<Card v-if="devopsStore.integration">
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<CardTitle class="text-sm">Work Items</CardTitle>
<!-- State filter -->
<div class="flex items-center rounded-lg border border-border overflow-hidden bg-muted/30">
<button
v-for="state in (['All', 'Active', 'Resolved', 'Closed'] as const)"
:key="state"
:class="[
'px-3 py-1 text-xs font-medium transition-colors',
stateFilter === state
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
]"
@click="stateFilter = state"
>
{{ state }}
</button>
</div>
</div>
</CardHeader>
<CardContent>
<div v-if="devopsStore.loading" class="flex items-center justify-center py-8">
<Spinner size="md" class="text-primary" />
</div>
<div v-else-if="filteredWorkItems.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No work items found
</div>
<div v-else class="space-y-0">
<!-- Header row -->
<div class="flex items-center gap-3 px-3 py-1.5 text-xs text-muted-foreground border-b border-border mb-1">
<span class="w-10 shrink-0">#</span>
<span class="flex-1">Title</span>
<span class="w-28 shrink-0 hidden md:block">Project</span>
<span class="w-8 shrink-0 text-center hidden sm:block">P</span>
<span class="w-20 shrink-0 hidden lg:block">Created</span>
<span class="w-20 shrink-0">State</span>
<span class="w-14 shrink-0 text-right">Link</span>
</div>
<div
v-for="wi in filteredWorkItems"
:key="wi.id"
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/30 transition-colors"
<!-- Work items table -->
<div v-if="devopsStore.integration" class="space-y-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<h3 class="text-sm font-semibold text-slate-700">Work Items</h3>
<!-- State filter tabs -->
<div class="flex items-center rounded-xl border border-slate-200 overflow-hidden bg-white shadow-sm">
<button
v-for="state in (['All', 'Active', 'Resolved', 'Closed'] as const)"
:key="state"
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
stateFilter === state
? 'bg-orange-500 text-white'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-50',
]"
@click="stateFilter = state"
>
<span class="text-xs font-mono text-muted-foreground w-10 shrink-0">#{{ wi.ado_id }}</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ wi.title }}</p>
<p class="text-xs text-muted-foreground">{{ wi.type }}</p>
{{ state }}
</button>
</div>
</div>
<div v-if="devopsStore.loading" class="flex items-center justify-center py-12">
<Spinner size="md" class="text-primary" />
</div>
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-200/80 overflow-hidden">
<DataTable
:columns="columns"
:rows="filteredWorkItems"
row-key="id"
>
<!-- # column -->
<template #cell-ado_id="{ value }">
<span class="text-xs font-mono text-slate-400">#{{ value }}</span>
</template>
<!-- Title column -->
<template #cell-title="{ row }">
<div class="min-w-0">
<p class="text-sm text-slate-800 truncate font-medium">{{ row.title }}</p>
<p class="text-xs text-slate-400 truncate">{{ row.type }}</p>
</div>
<span class="text-xs text-muted-foreground w-28 shrink-0 truncate hidden md:block" :title="wi.team_project">
{{ wi.team_project || '—' }}
</template>
<!-- Project column -->
<template #cell-team_project="{ value }">
<span class="text-xs text-slate-500 truncate block" :title="String(value ?? '')">
{{ value || '—' }}
</span>
</template>
<!-- Priority column -->
<template #cell-priority="{ value }">
<span
class="text-xs font-medium w-8 shrink-0 text-center hidden sm:block"
:class="(wi.priority ?? 3) <= 1 ? 'text-red-400' : (wi.priority ?? 3) <= 2 ? 'text-amber-400' : 'text-muted-foreground'"
:title="`Priority ${wi.priority}`"
class="text-xs font-semibold"
:class="priorityClass(value as number)"
>
P{{ wi.priority ?? 3 }}
P{{ value ?? 3 }}
</span>
<span class="text-xs text-muted-foreground w-20 shrink-0 hidden lg:block">
{{ wi.created_date ? new Date(wi.created_date).toLocaleDateString() : '—' }}
</template>
<!-- Created column -->
<template #cell-created_date="{ value }">
<span class="text-xs text-slate-400">
{{ value ? new Date(String(value)).toLocaleDateString() : '—' }}
</span>
</template>
<!-- State column -->
<template #cell-state="{ value }">
<span
:class="[
'text-xs px-2 py-0.5 rounded-full shrink-0 w-20 text-center',
wi.state === 'Active' ? 'bg-blue-500/10 text-blue-400' :
wi.state === 'Resolved' ? 'bg-green-500/10 text-green-400' :
wi.state === 'Closed' ? 'bg-muted text-muted-foreground' :
'bg-muted text-muted-foreground',
]"
class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="stateClass(String(value ?? ''))"
>
{{ wi.state }}
{{ value }}
</span>
<div class="flex items-center gap-1 shrink-0 w-14 justify-end">
</template>
<!-- Actions column -->
<template #cell-id="{ row }">
<div class="flex items-center gap-1 justify-end">
<button
class="p-1 rounded text-muted-foreground hover:text-primary transition-colors"
:class="{ 'opacity-50': cloningId === wi.id }"
class="p-1.5 rounded-lg text-slate-400 hover:text-orange-500 hover:bg-orange-50 transition-colors"
:class="{ 'opacity-50': cloningId === String(row.id) }"
title="Clone to Tasks"
@click.stop="cloneToTasks(wi.id)"
@click="cloneToTasks(String(row.id), $event)"
>
<svg v-if="cloningId !== wi.id" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-if="cloningId !== String(row.id)" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<svg v-else class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
@ -205,15 +234,22 @@ async function cloneToTasks(wiId: string) {
</svg>
</button>
<a
v-if="wi.url"
:href="wi.url"
v-if="row.url"
:href="String(row.url)"
target="_blank"
class="text-xs text-primary hover:underline"
></a>
rel="noopener"
class="p-1.5 rounded-lg text-slate-400 hover:text-blue-500 hover:bg-blue-50 transition-colors"
title="Open in Azure DevOps"
@click.stop
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
</DataTable>
</div>
</div>
</div>
</template>

View file

@ -10,6 +10,9 @@ import { toast } from 'vue-sonner'
import { formatDate, isoDateStr } from '@/lib/utils'
import { marked } from 'marked'
import type { AiReport } from '@/types'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const reports = ref<AiReport[]>([])
const loading = ref(false)
@ -52,6 +55,24 @@ function toggleExpand(id: string) {
function renderMarkdown(md: string): string {
return marked(md) as string
}
async function downloadReport(reportId: string, fmt: 'md' | 'html') {
try {
const res = await fetch(`/cc-dashboard/api/reports/${reportId}/export?format=${fmt}`, {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) { toast.error('Export failed'); return }
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `report.${fmt}`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('Export failed')
}
}
</script>
<template>
@ -132,16 +153,14 @@ function renderMarkdown(md: string): string {
<!-- Expanded content -->
<div v-if="expandedId === report.id" class="mt-4 pt-4 border-t border-border">
<div class="flex gap-2 mb-3">
<a
:href="`/cc-dashboard/api/reports/${report.id}/export?format=md`"
download
class="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
> Markdown</a>
<a
:href="`/cc-dashboard/api/reports/${report.id}/export?format=html`"
download
class="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
> HTML</a>
<button
class="text-xs px-2.5 py-1 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
@click="downloadReport(report.id, 'md')"
> Markdown</button>
<button
class="text-xs px-2.5 py-1 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
@click="downloadReport(report.id, 'html')"
> HTML</button>
</div>
<div
class="prose prose-sm prose-invert max-w-none text-sm text-foreground break-words"