Compare commits
11 Commits
d16ac3d76e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e1ec041de | |||
| 0ea463f1fa | |||
| 40d30600a0 | |||
| 034513e95b | |||
| c16c9b2d25 | |||
| c5557ce9d6 | |||
| a264ffdeff | |||
| c1803ef106 | |||
| 44cfd4d5f1 | |||
| ffb4dc28c5 | |||
| 884bbb11fc |
@@ -20,12 +20,20 @@ ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
|||||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
ARG NEXT_PUBLIC_SITE_URL
|
ARG NEXT_PUBLIC_SITE_URL
|
||||||
ARG NEXT_PUBLIC_N8N_WEBHOOK_URL
|
ARG NEXT_PUBLIC_N8N_WEBHOOK_URL
|
||||||
|
ARG NEXT_PUBLIC_GITEA_URL
|
||||||
|
ARG GITEA_URL
|
||||||
|
ARG GITEA_TOKEN
|
||||||
|
ARG SUPABASE_SERVICE_ROLE_KEY
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
ENV NEXT_PUBLIC_N8N_WEBHOOK_URL=$NEXT_PUBLIC_N8N_WEBHOOK_URL
|
ENV NEXT_PUBLIC_N8N_WEBHOOK_URL=$NEXT_PUBLIC_N8N_WEBHOOK_URL
|
||||||
|
ENV NEXT_PUBLIC_GITEA_URL=$NEXT_PUBLIC_GITEA_URL
|
||||||
|
ENV GITEA_URL=$GITEA_URL
|
||||||
|
ENV GITEA_TOKEN=$GITEA_TOKEN
|
||||||
|
ENV SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
240
package-lock.json
generated
240
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "mylder-frontend",
|
"name": "mylder-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@stripe/stripe-js": "^8.5.3",
|
"@stripe/stripe-js": "^8.5.3",
|
||||||
"@supabase/ssr": "^0.8.0",
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.87.1",
|
"@supabase/supabase-js": "^2.87.1",
|
||||||
@@ -1291,6 +1293,90 @@
|
|||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "1.1.15",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
@@ -2471,6 +2557,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@@ -2625,6 +2801,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/rect": {
|
"node_modules/@radix-ui/rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@stripe/stripe-js": "^8.5.3",
|
"@stripe/stripe-js": "^8.5.3",
|
||||||
"@supabase/ssr": "^0.8.0",
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.87.1",
|
"@supabase/supabase-js": "^2.87.1",
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft, Settings } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import type { Database } from '@/types/database'
|
import type { Database } from '@/types/database'
|
||||||
import { ProjectTabs } from './project-tabs'
|
import { ProjectView } from './project-view'
|
||||||
|
import { ProjectSettingsMenu } from './project-settings-menu'
|
||||||
|
|
||||||
type Project = Database['public']['Tables']['projects']['Row']
|
type Project = Database['public']['Tables']['projects']['Row']
|
||||||
type Message = Database['public']['Tables']['messages']['Row']
|
type Message = Database['public']['Tables']['messages']['Row']
|
||||||
@@ -32,33 +32,36 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-4rem)] flex flex-col">
|
<div className="h-[calc(100vh-4rem)] flex flex-col">
|
||||||
<div className="border-b bg-card px-4 py-3">
|
{/* Consolidated Project Header - compact single row */}
|
||||||
|
<div className="border-b bg-card/80 backdrop-blur-sm px-4 py-2">
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">
|
<Link
|
||||||
<ArrowLeft className="h-5 w-5" />
|
href="/dashboard"
|
||||||
|
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div className="h-4 w-px bg-border" />
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-sm font-medium">{project.name}</h1>
|
||||||
<h1 className="text-lg font-semibold">{project.name}</h1>
|
<Badge
|
||||||
<Badge variant={project.status === 'active' ? 'brand' : 'secondary'}>
|
variant={project.status === 'active' ? 'brand' : 'secondary'}
|
||||||
{project.status}
|
className="text-xs h-5"
|
||||||
</Badge>
|
>
|
||||||
</div>
|
{project.status}
|
||||||
{project.description && (
|
</Badge>
|
||||||
<p className="text-sm text-muted-foreground">{project.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon">
|
<ProjectSettingsMenu
|
||||||
<Settings className="h-5 w-5" />
|
projectId={id}
|
||||||
</Button>
|
projectName={project.name}
|
||||||
|
hasGiteaRepo={!!project.gitea_repo}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProjectTabs
|
<ProjectView
|
||||||
projectId={id}
|
projectId={id}
|
||||||
projectName={project.name}
|
project={project}
|
||||||
initialMessages={messages || []}
|
initialMessages={messages || []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
184
src/app/(dashboard)/projects/[id]/project-settings-menu.tsx
Normal file
184
src/app/(dashboard)/projects/[id]/project-settings-menu.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { deleteProject } from '@/lib/supabase/projects'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Settings, Trash2, ExternalLink, Loader2, AlertTriangle, GitBranch } from 'lucide-react'
|
||||||
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
interface ProjectSettingsMenuProps {
|
||||||
|
projectId: string
|
||||||
|
projectName: string
|
||||||
|
hasGiteaRepo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSettingsMenu({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
hasGiteaRepo,
|
||||||
|
}: ProjectSettingsMenuProps) {
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [confirmText, setConfirmText] = useState('')
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const canDelete = confirmText === projectName
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!canDelete) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const result = await deleteProject(projectId)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: 'Project deleted',
|
||||||
|
description: hasGiteaRepo
|
||||||
|
? 'Project and associated repository have been deleted.'
|
||||||
|
: 'Project has been deleted.',
|
||||||
|
})
|
||||||
|
router.push('/projects')
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete project',
|
||||||
|
description: result.error || 'An error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to delete project',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setConfirmText('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
{hasGiteaRepo && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a
|
||||||
|
href={`https://gitea.mylder.io/admin/${projectName.toLowerCase().replace(/\s+/g, '-')}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
View Repository
|
||||||
|
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete Project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||||
|
Delete Project
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>
|
||||||
|
This action cannot be undone. This will permanently delete the project
|
||||||
|
<strong className="text-foreground"> {projectName}</strong> and all
|
||||||
|
associated data including:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-sm space-y-1 text-muted-foreground">
|
||||||
|
<li>All messages and chat history</li>
|
||||||
|
<li>All agent runs and proposed changes</li>
|
||||||
|
<li>All backlog items and activities</li>
|
||||||
|
<li>All phase tracking data</li>
|
||||||
|
{hasGiteaRepo && (
|
||||||
|
<li className="text-destructive font-medium">
|
||||||
|
The Gitea repository and all its code
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="pt-2">
|
||||||
|
<p className="text-sm mb-2">
|
||||||
|
Type <strong className="text-foreground">{projectName}</strong> to confirm:
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleting} onClick={() => setConfirmText('')}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!canDelete || deleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete Project
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { ProjectChat } from '@/components/chat/project-chat'
|
import { ProjectChat } from '@/components/chat/project-chat'
|
||||||
import { AgenticDashboard } from '@/components/dashboard/agentic-dashboard'
|
import { DesignThinkingDashboard } from '@/components/project/design-thinking-dashboard'
|
||||||
import { MessageSquare, Workflow } from 'lucide-react'
|
import { MessageSquare, Compass } from 'lucide-react'
|
||||||
import type { Database } from '@/types/database'
|
import type { Database } from '@/types/database'
|
||||||
|
|
||||||
type Message = Database['public']['Tables']['messages']['Row']
|
type Message = Database['public']['Tables']['messages']['Row']
|
||||||
@@ -28,11 +28,11 @@ export function ProjectTabs({ projectId, projectName, initialMessages }: Project
|
|||||||
Chat
|
Chat
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="workflow"
|
value="phases"
|
||||||
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
|
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
|
||||||
>
|
>
|
||||||
<Workflow className="w-4 h-4 mr-2" />
|
<Compass className="w-4 h-4 mr-2" />
|
||||||
Workflow
|
Phases
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,12 +42,9 @@ export function ProjectTabs({ projectId, projectName, initialMessages }: Project
|
|||||||
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
|
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="workflow" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
|
<TabsContent value="phases" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<AgenticDashboard
|
<DesignThinkingDashboard projectId={projectId} />
|
||||||
projectId={projectId}
|
|
||||||
projectName={projectName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
137
src/app/(dashboard)/projects/[id]/project-view.tsx
Normal file
137
src/app/(dashboard)/projects/[id]/project-view.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ProjectChat } from '@/components/chat/project-chat'
|
||||||
|
import { DesignThinkingDashboard } from '@/components/project/design-thinking-dashboard'
|
||||||
|
import { PhaseIndicatorCompact } from '@/components/project/phase-indicator-compact'
|
||||||
|
import { ApprovalPanel } from '@/components/project/approval-panel'
|
||||||
|
import { useProject } from '@/hooks/use-project'
|
||||||
|
import { useAgentRuns } from '@/hooks/use-agent-runs'
|
||||||
|
import { MessageSquare, Compass, PanelRightOpen, PanelRightClose, Bell } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { Database } from '@/types/database'
|
||||||
|
import type { DesignPhase } from '@/types/design-thinking'
|
||||||
|
|
||||||
|
type DBProject = Database['public']['Tables']['projects']['Row']
|
||||||
|
type Message = Database['public']['Tables']['messages']['Row']
|
||||||
|
|
||||||
|
interface ProjectViewProps {
|
||||||
|
projectId: string
|
||||||
|
project: DBProject
|
||||||
|
initialMessages: Message[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarView = 'phases' | 'approvals' | null
|
||||||
|
|
||||||
|
export function ProjectView({ projectId, initialMessages }: ProjectViewProps) {
|
||||||
|
const [sidebarView, setSidebarView] = useState<SidebarView>(null)
|
||||||
|
const { project: projectData, phaseStatuses, loading } = useProject(projectId)
|
||||||
|
const { pendingRuns } = useAgentRuns(projectId)
|
||||||
|
|
||||||
|
const showPhases = sidebarView === 'phases'
|
||||||
|
const showApprovals = sidebarView === 'approvals'
|
||||||
|
const hasSidebar = sidebarView !== null
|
||||||
|
|
||||||
|
const handlePhaseClick = () => {
|
||||||
|
setSidebarView('phases')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 'empathize' if project not loaded yet
|
||||||
|
const currentPhase = projectData?.current_phase || 'empathize'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Main Chat Area */}
|
||||||
|
<div className={cn(
|
||||||
|
'flex-1 flex flex-col transition-all duration-300',
|
||||||
|
hasSidebar ? 'lg:mr-80' : ''
|
||||||
|
)}>
|
||||||
|
{/* Inline toolbar with phase indicator */}
|
||||||
|
<div className="border-b bg-muted/30 px-4 py-1.5">
|
||||||
|
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium">Chat</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-px bg-border" />
|
||||||
|
{!loading && (
|
||||||
|
<PhaseIndicatorCompact
|
||||||
|
currentPhase={currentPhase}
|
||||||
|
phaseStatuses={phaseStatuses}
|
||||||
|
onPhaseClick={handlePhaseClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Approvals button with badge */}
|
||||||
|
<Button
|
||||||
|
variant={showApprovals ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs relative"
|
||||||
|
onClick={() => setSidebarView(showApprovals ? null : 'approvals')}
|
||||||
|
>
|
||||||
|
<Bell className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Approvals</span>
|
||||||
|
{pendingRuns.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-brand text-[10px] text-white flex items-center justify-center">
|
||||||
|
{pendingRuns.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{/* Phases button */}
|
||||||
|
<Button
|
||||||
|
variant={showPhases ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
onClick={() => setSidebarView(showPhases ? null : 'phases')}
|
||||||
|
>
|
||||||
|
{hasSidebar ? (
|
||||||
|
<PanelRightClose className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<PanelRightOpen className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">Phases</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Content */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar - slides in from right */}
|
||||||
|
<div className={cn(
|
||||||
|
'fixed right-0 top-[calc(4rem+2.5rem+2rem)] bottom-0 w-80 bg-card border-l transform transition-transform duration-300 overflow-y-auto z-40',
|
||||||
|
hasSidebar ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
)}>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showPhases && <Compass className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
{showApprovals && <Bell className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
<h2 className="text-sm font-medium">
|
||||||
|
{showPhases && 'Design Phases'}
|
||||||
|
{showApprovals && 'Pending Approvals'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setSidebarView(null)}
|
||||||
|
>
|
||||||
|
<PanelRightClose className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{showPhases && <DesignThinkingDashboard projectId={projectId} />}
|
||||||
|
{showApprovals && <ApprovalPanel projectId={projectId} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
src/app/(dashboard)/projects/new/actions.ts
Normal file
122
src/app/(dashboard)/projects/new/actions.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import type { GiteaRepository, GiteaWebhook } from '@/lib/gitea/types'
|
||||||
|
|
||||||
|
const GITEA_URL = process.env.GITEA_URL || 'https://gitea.mylder.io'
|
||||||
|
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''
|
||||||
|
const GITEA_OWNER = 'admin'
|
||||||
|
const WWS_WEBHOOK_URL = 'https://wws.mylder.io/webhook/gitea'
|
||||||
|
|
||||||
|
interface CreateRepoResult {
|
||||||
|
success: boolean
|
||||||
|
repo?: GiteaRepository
|
||||||
|
webhook?: GiteaWebhook
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function giteaRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const url = `${GITEA_URL}/api/v1${endpoint}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${GITEA_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
}))
|
||||||
|
throw new Error(error.message || `Gitea API error: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGiteaRepo(slug: string, description?: string): Promise<CreateRepoResult> {
|
||||||
|
try {
|
||||||
|
// Check if repo already exists
|
||||||
|
const existingCheck = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${slug}`, {
|
||||||
|
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingCheck.ok) {
|
||||||
|
return { success: false, error: 'Repository already exists' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the repository
|
||||||
|
const repo = await giteaRequest<GiteaRepository>('/user/repos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: slug,
|
||||||
|
description: description || `Mylder project: ${slug}`,
|
||||||
|
private: false,
|
||||||
|
auto_init: true,
|
||||||
|
default_branch: 'main',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create webhook pointing to WWS (which routes to n8n)
|
||||||
|
const webhook = await giteaRequest<GiteaWebhook>(`/repos/${GITEA_OWNER}/${slug}/hooks`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'gitea',
|
||||||
|
config: {
|
||||||
|
url: WWS_WEBHOOK_URL,
|
||||||
|
content_type: 'json',
|
||||||
|
secret: '',
|
||||||
|
},
|
||||||
|
events: ['push', 'pull_request', 'issues', 'create', 'delete'],
|
||||||
|
active: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, repo, webhook }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create repository',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGiteaRepoUrl(slug: string): Promise<string> {
|
||||||
|
return `${GITEA_URL}/${GITEA_OWNER}/${slug}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteRepoResult {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGiteaRepo(repoName: string): Promise<DeleteRepoResult> {
|
||||||
|
try {
|
||||||
|
// Check if repo exists first
|
||||||
|
const checkResponse = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${repoName}`, {
|
||||||
|
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!checkResponse.ok) {
|
||||||
|
// Repo doesn't exist, consider it a success
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the repository
|
||||||
|
await giteaRequest<void>(`/repos/${GITEA_OWNER}/${repoName}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete repository',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,17 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
import { ArrowLeft, Loader2, GitBranch, Check } from 'lucide-react'
|
||||||
|
import { createGiteaRepo } from './actions'
|
||||||
|
|
||||||
|
type CreationStep = 'idle' | 'creating_project' | 'creating_repo' | 'complete'
|
||||||
|
|
||||||
export default function NewProjectPage() {
|
export default function NewProjectPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [step, setStep] = useState<CreationStep>('idle')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -37,6 +41,9 @@ export default function NewProjectPage() {
|
|||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/(^-|-$)/g, '')
|
.replace(/(^-|-$)/g, '')
|
||||||
|
|
||||||
|
// Step 1: Create Supabase project
|
||||||
|
setStep('creating_project')
|
||||||
|
|
||||||
// First, get or create the user's default team
|
// First, get or create the user's default team
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let { data: team } = await (supabase as any)
|
let { data: team } = await (supabase as any)
|
||||||
@@ -60,12 +67,13 @@ export default function NewProjectPage() {
|
|||||||
if (teamError) {
|
if (teamError) {
|
||||||
setError('Failed to create team')
|
setError('Failed to create team')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setStep('idle')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
team = newTeam
|
team = newTeam
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the project
|
// Create the project (without gitea_repo initially)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const { data: project, error: projectError } = await (supabase as any)
|
const { data: project, error: projectError } = await (supabase as any)
|
||||||
.from('projects')
|
.from('projects')
|
||||||
@@ -81,9 +89,34 @@ export default function NewProjectPage() {
|
|||||||
if (projectError) {
|
if (projectError) {
|
||||||
setError(projectError.message)
|
setError(projectError.message)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setStep('idle')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 2: Create Gitea repository
|
||||||
|
setStep('creating_repo')
|
||||||
|
const repoResult = await createGiteaRepo(slug, description || name)
|
||||||
|
|
||||||
|
if (repoResult.success && repoResult.repo) {
|
||||||
|
// Update project with gitea_repo URL
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (supabase as any)
|
||||||
|
.from('projects')
|
||||||
|
.update({
|
||||||
|
gitea_repo: repoResult.repo.html_url,
|
||||||
|
gitea_webhook_id: repoResult.webhook?.id,
|
||||||
|
})
|
||||||
|
.eq('id', project.id)
|
||||||
|
} else {
|
||||||
|
// Log warning but don't fail - project still created
|
||||||
|
console.warn('Gitea repo creation failed:', repoResult.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('complete')
|
||||||
|
|
||||||
|
// Small delay to show completion state
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
|
||||||
router.push(`/projects/${project.id}`)
|
router.push(`/projects/${project.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +160,42 @@ export default function NewProjectPage() {
|
|||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{step === 'creating_project' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-brand" />
|
||||||
|
) : step === 'creating_repo' || step === 'complete' ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-muted" />
|
||||||
|
)}
|
||||||
|
<span className={step === 'creating_project' ? 'text-foreground' : 'text-muted-foreground'}>
|
||||||
|
Creating project
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{step === 'creating_repo' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-brand" />
|
||||||
|
) : step === 'complete' ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-muted" />
|
||||||
|
)}
|
||||||
|
<span className={step === 'creating_repo' ? 'text-foreground' : 'text-muted-foreground'}>
|
||||||
|
<GitBranch className="inline h-3 w-3 mr-1" />
|
||||||
|
Creating Gitea repository
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button type="submit" variant="brand" className="w-full" disabled={isLoading || !name}>
|
<Button type="submit" variant="brand" className="w-full" disabled={isLoading || !name}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Creating...
|
{step === 'creating_project' && 'Creating project...'}
|
||||||
|
{step === 'creating_repo' && 'Setting up repository...'}
|
||||||
|
{step === 'complete' && 'Done!'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Create Project'
|
'Create Project'
|
||||||
|
|||||||
@@ -43,12 +43,15 @@ export function ProjectChat({ projectId, initialMessages }: ProjectChatProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.subscribe()
|
.subscribe((status) => {
|
||||||
|
console.log('Realtime subscription status:', status)
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
supabase.removeChannel(channel)
|
supabase.removeChannel(channel)
|
||||||
}
|
}
|
||||||
}, [projectId, supabase])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
// Scroll to bottom when messages change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
247
src/components/project/approval-panel.tsx
Normal file
247
src/components/project/approval-panel.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useAgentRuns } from '@/hooks/use-agent-runs'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
FileCode,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { Database } from '@/types/database'
|
||||||
|
|
||||||
|
type AgentRun = Database['public']['Tables']['agent_runs']['Row']
|
||||||
|
|
||||||
|
interface ApprovalPanelProps {
|
||||||
|
projectId: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApprovalPanel({ projectId, className }: ApprovalPanelProps) {
|
||||||
|
const { pendingRuns, loading, approve, reject } = useAgentRuns(projectId)
|
||||||
|
const [expandedRun, setExpandedRun] = useState<string | null>(null)
|
||||||
|
const [processing, setProcessing] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleApprove = async (runId: string) => {
|
||||||
|
setProcessing(runId)
|
||||||
|
try {
|
||||||
|
await approve(runId)
|
||||||
|
} finally {
|
||||||
|
setProcessing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async (runId: string) => {
|
||||||
|
setProcessing(runId)
|
||||||
|
try {
|
||||||
|
await reject(runId)
|
||||||
|
} finally {
|
||||||
|
setProcessing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingRuns.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('text-center py-8', className)}>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
No pending approvals
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
AI-proposed changes will appear here for review
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{pendingRuns.length} pending
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto max-h-[calc(100vh-20rem)] space-y-3 pr-1">
|
||||||
|
{pendingRuns.map((run) => (
|
||||||
|
<ApprovalCard
|
||||||
|
key={run.id}
|
||||||
|
run={run}
|
||||||
|
expanded={expandedRun === run.id}
|
||||||
|
onToggle={() => setExpandedRun(expandedRun === run.id ? null : run.id)}
|
||||||
|
onApprove={() => handleApprove(run.id)}
|
||||||
|
onReject={() => handleReject(run.id)}
|
||||||
|
processing={processing === run.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalCardProps {
|
||||||
|
run: AgentRun
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onApprove: () => void
|
||||||
|
onReject: () => void
|
||||||
|
processing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalCard({
|
||||||
|
run,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
processing,
|
||||||
|
}: ApprovalCardProps) {
|
||||||
|
const changes = run.proposed_changes
|
||||||
|
|
||||||
|
if (!changes) return null
|
||||||
|
|
||||||
|
const fileCount = changes.files?.length || 0
|
||||||
|
const operations = changes.files?.reduce(
|
||||||
|
(acc, file) => {
|
||||||
|
acc[file.operation] = (acc[file.operation] || 0) + 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
) || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader className="p-3 pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-sm font-medium truncate">
|
||||||
|
{run.command}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs mt-0.5 line-clamp-2">
|
||||||
|
{changes.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge variant="outline" className="text-xs gap-1">
|
||||||
|
<FileCode className="w-3 h-3" />
|
||||||
|
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
{operations.create && (
|
||||||
|
<Badge variant="outline" className="text-xs gap-1 text-emerald-600 border-emerald-200">
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
{operations.create}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{operations.update && (
|
||||||
|
<Badge variant="outline" className="text-xs gap-1 text-blue-600 border-blue-200">
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
{operations.update}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{operations.delete && (
|
||||||
|
<Badge variant="outline" className="text-xs gap-1 text-red-600 border-red-200">
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
{operations.delete}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{expanded && changes.files && (
|
||||||
|
<CardContent className="p-3 pt-0">
|
||||||
|
<div className="border rounded-md overflow-hidden mt-2">
|
||||||
|
{changes.files.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2 text-xs font-mono border-b last:border-b-0',
|
||||||
|
file.operation === 'create' && 'bg-emerald-50 dark:bg-emerald-950/20',
|
||||||
|
file.operation === 'update' && 'bg-blue-50 dark:bg-blue-950/20',
|
||||||
|
file.operation === 'delete' && 'bg-red-50 dark:bg-red-950/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{file.operation === 'create' && <Plus className="w-3 h-3 text-emerald-600" />}
|
||||||
|
{file.operation === 'update' && <Pencil className="w-3 h-3 text-blue-600" />}
|
||||||
|
{file.operation === 'delete' && <Trash2 className="w-3 h-3 text-red-600" />}
|
||||||
|
<span className="truncate">{file.path}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changes.estimated_impact && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Impact: {changes.estimated_impact}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 p-3 pt-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={onReject}
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
{processing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X className="w-3 h-3 mr-1" />
|
||||||
|
Reject
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="brand"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={onApprove}
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
{processing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
|
Approve
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
249
src/components/project/backlog-board.tsx
Normal file
249
src/components/project/backlog-board.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Clock,
|
||||||
|
Target,
|
||||||
|
AlertTriangle,
|
||||||
|
Zap,
|
||||||
|
MoreVertical,
|
||||||
|
GripVertical
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { designPhases, type BacklogItem, type DesignPhase } from '@/types/design-thinking'
|
||||||
|
|
||||||
|
interface BacklogBoardProps {
|
||||||
|
items: BacklogItem[]
|
||||||
|
onItemClick?: (item: BacklogItem) => void
|
||||||
|
onStatusChange?: (itemId: string, newStatus: BacklogItem['status']) => void
|
||||||
|
onPriorityChange?: (itemId: string, direction: 'up' | 'down') => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<BacklogItem['status'], string> = {
|
||||||
|
backlog: 'bg-muted text-muted-foreground',
|
||||||
|
ready: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||||
|
in_progress: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||||
|
done: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||||
|
blocked: 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriorityScore({ score }: { score: number }) {
|
||||||
|
const getColor = (s: number) => {
|
||||||
|
if (s >= 7) return 'text-emerald-500'
|
||||||
|
if (s >= 4) return 'text-amber-500'
|
||||||
|
return 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-1', getColor(score))}>
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
<span className="text-xs font-medium">{score.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BacklogCard({
|
||||||
|
item,
|
||||||
|
onItemClick,
|
||||||
|
onStatusChange,
|
||||||
|
onPriorityChange
|
||||||
|
}: {
|
||||||
|
item: BacklogItem
|
||||||
|
onItemClick?: (item: BacklogItem) => void
|
||||||
|
onStatusChange?: (itemId: string, newStatus: BacklogItem['status']) => void
|
||||||
|
onPriorityChange?: (itemId: string, direction: 'up' | 'down') => void
|
||||||
|
}) {
|
||||||
|
const phase = designPhases.find(p => p.id === item.phase)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-all hover:shadow-md',
|
||||||
|
item.status === 'blocked' && 'border-red-500/30'
|
||||||
|
)}
|
||||||
|
onClick={() => onItemClick?.(item)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<GripVertical className="w-4 h-4 text-muted-foreground/50 mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium truncate">{item.title}</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100">
|
||||||
|
<MoreVertical className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onStatusChange?.(item.id, 'ready') }}>
|
||||||
|
Mark Ready
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onStatusChange?.(item.id, 'in_progress') }}>
|
||||||
|
Start Work
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onStatusChange?.(item.id, 'done') }}>
|
||||||
|
Mark Done
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onStatusChange?.(item.id, 'blocked') }}>
|
||||||
|
Mark Blocked
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className={statusColors[item.status]}>
|
||||||
|
{item.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
{phase && (
|
||||||
|
<span className="text-xs">{phase.icon}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PriorityScore score={item.priority_score} />
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPriorityChange?.(item.id, 'up') }}
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPriorityChange?.(item.id, 'down') }}
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WSJF breakdown on hover */}
|
||||||
|
<div className="mt-2 pt-2 border-t border-dashed opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<div className="grid grid-cols-4 gap-1 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Target className="w-3 h-3 text-blue-500" />
|
||||||
|
<span className="text-muted-foreground">{item.user_value}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-amber-500" />
|
||||||
|
<span className="text-muted-foreground">{item.time_criticality}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||||
|
<span className="text-muted-foreground">{item.risk_reduction}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3 text-purple-500" />
|
||||||
|
<span className="text-muted-foreground">E:{item.effort}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BacklogBoard({
|
||||||
|
items,
|
||||||
|
onItemClick,
|
||||||
|
onStatusChange,
|
||||||
|
onPriorityChange,
|
||||||
|
className
|
||||||
|
}: BacklogBoardProps) {
|
||||||
|
const [filter, setFilter] = useState<BacklogItem['status'] | 'all'>('all')
|
||||||
|
|
||||||
|
const filteredItems = filter === 'all'
|
||||||
|
? items
|
||||||
|
: items.filter(item => item.status === filter)
|
||||||
|
|
||||||
|
const sortedItems = [...filteredItems].sort((a, b) => b.priority_score - a.priority_score)
|
||||||
|
|
||||||
|
const statusCounts = {
|
||||||
|
backlog: items.filter(i => i.status === 'backlog').length,
|
||||||
|
ready: items.filter(i => i.status === 'ready').length,
|
||||||
|
in_progress: items.filter(i => i.status === 'in_progress').length,
|
||||||
|
done: items.filter(i => i.status === 'done').length,
|
||||||
|
blocked: items.filter(i => i.status === 'blocked').length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-medium">Backlog</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['all', 'backlog', 'ready', 'in_progress', 'done', 'blocked'] as const).map((status) => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
variant={filter === status ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setFilter(status)}
|
||||||
|
>
|
||||||
|
{status === 'all' ? 'All' : status.replace('_', ' ')}
|
||||||
|
{status !== 'all' && (
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({statusCounts[status]})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{sortedItems.map((item) => (
|
||||||
|
<BacklogCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
onPriorityChange={onPriorityChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
{sortedItems.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
No items in {filter === 'all' ? 'backlog' : filter}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
src/components/project/delete-project-dialog.tsx
Normal file
143
src/components/project/delete-project-dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { deleteProject } from '@/lib/supabase/projects'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Trash2, Loader2, AlertTriangle } from 'lucide-react'
|
||||||
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
interface DeleteProjectDialogProps {
|
||||||
|
projectId: string
|
||||||
|
projectName: string
|
||||||
|
hasGiteaRepo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteProjectDialog({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
hasGiteaRepo,
|
||||||
|
}: DeleteProjectDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [confirmText, setConfirmText] = useState('')
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const canDelete = confirmText === projectName
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!canDelete) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const result = await deleteProject(projectId)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: 'Project deleted',
|
||||||
|
description: hasGiteaRepo
|
||||||
|
? 'Project and associated repository have been deleted.'
|
||||||
|
: 'Project has been deleted.',
|
||||||
|
})
|
||||||
|
router.push('/projects')
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete project',
|
||||||
|
description: result.error || 'An error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to delete project',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm" className="gap-2">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Delete Project
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||||
|
Delete Project
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="space-y-3">
|
||||||
|
<p>
|
||||||
|
This action cannot be undone. This will permanently delete the project
|
||||||
|
<strong className="text-foreground"> {projectName}</strong> and all
|
||||||
|
associated data including:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-sm space-y-1 text-muted-foreground">
|
||||||
|
<li>All messages and chat history</li>
|
||||||
|
<li>All agent runs and proposed changes</li>
|
||||||
|
<li>All backlog items and activities</li>
|
||||||
|
<li>All phase tracking data</li>
|
||||||
|
{hasGiteaRepo && (
|
||||||
|
<li className="text-destructive font-medium">
|
||||||
|
The Gitea repository and all its code
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="pt-2">
|
||||||
|
<p className="text-sm mb-2">
|
||||||
|
Type <strong className="text-foreground">{projectName}</strong> to confirm:
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!canDelete || deleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete Project
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
src/components/project/design-thinking-dashboard.tsx
Normal file
166
src/components/project/design-thinking-dashboard.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useProject } from '@/hooks/use-project'
|
||||||
|
import { PhaseNavigator } from './phase-navigator'
|
||||||
|
import { HealthWidget } from './health-widget'
|
||||||
|
import { BacklogBoard } from './backlog-board'
|
||||||
|
import { RecommendationsWidget } from './recommendations-widget'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { AlertCircle, RefreshCw, ChevronRight } from 'lucide-react'
|
||||||
|
import type { DesignPhase, BacklogItem, AIRecommendation } from '@/types/design-thinking'
|
||||||
|
|
||||||
|
interface DesignThinkingDashboardProps {
|
||||||
|
projectId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
<Skeleton className="h-48 lg:col-span-2" />
|
||||||
|
<Skeleton className="h-48" />
|
||||||
|
</div>
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<Card className="border-red-500/20 bg-red-500/5">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Failed to load project</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">{error.message}</p>
|
||||||
|
<Button onClick={onRetry} variant="outline" className="gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesignThinkingDashboard({ projectId }: DesignThinkingDashboardProps) {
|
||||||
|
const {
|
||||||
|
project,
|
||||||
|
phaseStatuses,
|
||||||
|
backlogItems,
|
||||||
|
health,
|
||||||
|
recommendations,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
advancePhase,
|
||||||
|
loopBack,
|
||||||
|
updateBacklog,
|
||||||
|
dismissRec
|
||||||
|
} = useProject(projectId)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <DashboardSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorState error={error} onRetry={refresh} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePhaseClick = (phase: DesignPhase) => {
|
||||||
|
// Could open a modal with phase details
|
||||||
|
console.log('Phase clicked:', phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoopBack = (fromPhase: DesignPhase, toPhase: DesignPhase) => {
|
||||||
|
loopBack(fromPhase, toPhase)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBacklogItemClick = (item: BacklogItem) => {
|
||||||
|
// Could open item detail modal
|
||||||
|
console.log('Backlog item clicked:', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStatusChange = (itemId: string, newStatus: BacklogItem['status']) => {
|
||||||
|
updateBacklog(itemId, { status: newStatus })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePriorityChange = (itemId: string, direction: 'up' | 'down') => {
|
||||||
|
const item = backlogItems.find(i => i.id === itemId)
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
// Adjust user_value to change priority
|
||||||
|
const adjustment = direction === 'up' ? 1 : -1
|
||||||
|
const newValue = Math.max(1, Math.min(10, item.user_value + adjustment))
|
||||||
|
updateBacklog(itemId, { user_value: newValue })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRecommendationAction = (rec: AIRecommendation) => {
|
||||||
|
if (rec.action_command) {
|
||||||
|
// Execute the command - this would integrate with the chat
|
||||||
|
console.log('Execute command:', rec.action_command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismissRecommendation = (recId: string) => {
|
||||||
|
dismissRec(recId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with project info */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">{project.name}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Current phase: <span className="capitalize font-medium">{project.current_phase}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => advancePhase(project.current_phase)} className="gap-2">
|
||||||
|
Complete Phase
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase Navigator */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<PhaseNavigator
|
||||||
|
currentPhase={project.current_phase}
|
||||||
|
phaseStatuses={phaseStatuses}
|
||||||
|
onPhaseClick={handlePhaseClick}
|
||||||
|
onLoopBack={handleLoopBack}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Health + Recommendations row */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<HealthWidget health={health} />
|
||||||
|
</div>
|
||||||
|
<RecommendationsWidget
|
||||||
|
recommendations={recommendations}
|
||||||
|
onAction={handleRecommendationAction}
|
||||||
|
onDismiss={handleDismissRecommendation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backlog Board - full width */}
|
||||||
|
<BacklogBoard
|
||||||
|
items={backlogItems}
|
||||||
|
onItemClick={handleBacklogItemClick}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onPriorityChange={handlePriorityChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
src/components/project/health-widget.tsx
Normal file
122
src/components/project/health-widget.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Activity, AlertTriangle, CheckCircle2, Clock, TrendingUp } from 'lucide-react'
|
||||||
|
import type { ProjectHealth } from '@/types/design-thinking'
|
||||||
|
|
||||||
|
interface HealthWidgetProps {
|
||||||
|
health: ProjectHealth
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthRing({ value, size = 120, strokeWidth = 8 }: { value: number; size?: number; strokeWidth?: number }) {
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = radius * 2 * Math.PI
|
||||||
|
const offset = circumference - (value / 100) * circumference
|
||||||
|
|
||||||
|
const getColor = (v: number) => {
|
||||||
|
if (v >= 80) return 'stroke-emerald-500'
|
||||||
|
if (v >= 60) return 'stroke-amber-500'
|
||||||
|
return 'stroke-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
|
<svg className="transform -rotate-90" width={size} height={size}>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="stroke-muted fill-none"
|
||||||
|
/>
|
||||||
|
<motion.circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className={cn('fill-none', getColor(value))}
|
||||||
|
strokeLinecap="round"
|
||||||
|
initial={{ strokeDashoffset: circumference }}
|
||||||
|
animate={{ strokeDashoffset: offset }}
|
||||||
|
transition={{ duration: 1, ease: 'easeOut' }}
|
||||||
|
style={{ strokeDasharray: circumference }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<motion.span
|
||||||
|
className="text-3xl font-bold"
|
||||||
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</motion.span>
|
||||||
|
<span className="text-xs text-muted-foreground">Health Score</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthWidget({ health, className }: HealthWidgetProps) {
|
||||||
|
const metrics = [
|
||||||
|
{
|
||||||
|
label: 'Velocity',
|
||||||
|
value: health.velocity,
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: health.velocity >= 80 ? 'text-emerald-500' : health.velocity >= 60 ? 'text-amber-500' : 'text-red-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Completion',
|
||||||
|
value: health.completion_rate,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: health.completion_rate >= 80 ? 'text-emerald-500' : health.completion_rate >= 60 ? 'text-amber-500' : 'text-red-500',
|
||||||
|
suffix: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Blockers',
|
||||||
|
value: health.blockers,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
color: health.blockers === 0 ? 'text-emerald-500' : health.blockers <= 2 ? 'text-amber-500' : 'text-red-500',
|
||||||
|
inverse: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Overdue',
|
||||||
|
value: health.overdue,
|
||||||
|
icon: Clock,
|
||||||
|
color: health.overdue === 0 ? 'text-emerald-500' : health.overdue <= 2 ? 'text-amber-500' : 'text-red-500',
|
||||||
|
inverse: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
Project Health
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<HealthRing value={health.overall} />
|
||||||
|
<div className="grid grid-cols-2 gap-4 flex-1">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<div key={metric.label} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<metric.icon className={cn('w-3.5 h-3.5', metric.color)} />
|
||||||
|
<span className="text-xs text-muted-foreground">{metric.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className={cn('text-lg font-semibold', metric.color)}>
|
||||||
|
{metric.value}{metric.suffix || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/components/project/index.ts
Normal file
5
src/components/project/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { PhaseNavigator } from './phase-navigator'
|
||||||
|
export { HealthWidget } from './health-widget'
|
||||||
|
export { BacklogBoard } from './backlog-board'
|
||||||
|
export { RecommendationsWidget } from './recommendations-widget'
|
||||||
|
export { DesignThinkingDashboard } from './design-thinking-dashboard'
|
||||||
78
src/components/project/phase-indicator-compact.tsx
Normal file
78
src/components/project/phase-indicator-compact.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { DESIGN_PHASES, designPhases, type DesignPhase, type PhaseStatus } from '@/types/design-thinking'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
interface PhaseIndicatorCompactProps {
|
||||||
|
currentPhase: DesignPhase
|
||||||
|
phaseStatuses?: Record<DesignPhase, PhaseStatus>
|
||||||
|
onPhaseClick?: (phase: DesignPhase) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaseIndicatorCompact({
|
||||||
|
currentPhase,
|
||||||
|
phaseStatuses,
|
||||||
|
onPhaseClick,
|
||||||
|
className
|
||||||
|
}: PhaseIndicatorCompactProps) {
|
||||||
|
const currentIndex = DESIGN_PHASES.indexOf(currentPhase)
|
||||||
|
const currentPhaseData = designPhases.find(p => p.id === currentPhase)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
|
{/* Mini progress dots */}
|
||||||
|
<div className="flex items-center gap-0.5 mr-2">
|
||||||
|
{DESIGN_PHASES.map((phase, index) => {
|
||||||
|
const status = phaseStatuses?.[phase]
|
||||||
|
const isCurrent = index === currentIndex
|
||||||
|
const isCompleted = status === 'completed' || index < currentIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={phase}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onPhaseClick?.(phase)}
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full transition-all',
|
||||||
|
isCurrent && 'w-3 h-3 bg-brand animate-pulse',
|
||||||
|
isCompleted && !isCurrent && 'bg-emerald-500',
|
||||||
|
!isCompleted && !isCurrent && 'bg-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
{designPhases.find(p => p.id === phase)?.label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current phase badge */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onPhaseClick?.(currentPhase)}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand/10 text-brand text-sm font-medium hover:bg-brand/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{currentPhaseData?.icon}</span>
|
||||||
|
<span className="hidden sm:inline">{currentPhaseData?.label}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
|
<p className="font-medium">{currentPhaseData?.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{currentPhaseData?.shortDescription}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
167
src/components/project/phase-navigator.tsx
Normal file
167
src/components/project/phase-navigator.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Check, Loader2, AlertCircle, RotateCcw } from 'lucide-react'
|
||||||
|
import { DESIGN_PHASES, designPhases, type DesignPhase, type PhaseStatus } from '@/types/design-thinking'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
interface PhaseNavigatorProps {
|
||||||
|
currentPhase: DesignPhase
|
||||||
|
phaseStatuses: Record<DesignPhase, PhaseStatus>
|
||||||
|
onPhaseClick?: (phase: DesignPhase) => void
|
||||||
|
onLoopBack?: (fromPhase: DesignPhase, toPhase: DesignPhase) => void
|
||||||
|
className?: string
|
||||||
|
variant?: 'horizontal' | 'compact'
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMap: Record<string, { bg: string; border: string; text: string; ring: string }> = {
|
||||||
|
purple: { bg: 'bg-purple-500/10', border: 'border-purple-500/50', text: 'text-purple-500', ring: 'ring-purple-500/30' },
|
||||||
|
blue: { bg: 'bg-blue-500/10', border: 'border-blue-500/50', text: 'text-blue-500', ring: 'ring-blue-500/30' },
|
||||||
|
amber: { bg: 'bg-amber-500/10', border: 'border-amber-500/50', text: 'text-amber-500', ring: 'ring-amber-500/30' },
|
||||||
|
orange: { bg: 'bg-orange-500/10', border: 'border-orange-500/50', text: 'text-orange-500', ring: 'ring-orange-500/30' },
|
||||||
|
emerald: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/50', text: 'text-emerald-500', ring: 'ring-emerald-500/30' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaseNavigator({
|
||||||
|
currentPhase,
|
||||||
|
phaseStatuses,
|
||||||
|
onPhaseClick,
|
||||||
|
onLoopBack,
|
||||||
|
className,
|
||||||
|
variant = 'horizontal'
|
||||||
|
}: PhaseNavigatorProps) {
|
||||||
|
const currentIndex = DESIGN_PHASES.indexOf(currentPhase)
|
||||||
|
const progress = ((currentIndex + 1) / DESIGN_PHASES.length) * 100
|
||||||
|
|
||||||
|
const getStatusIcon = (status: PhaseStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <Check className="w-4 h-4" />
|
||||||
|
case 'in_progress':
|
||||||
|
return <Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
case 'blocked':
|
||||||
|
case 'needs_review':
|
||||||
|
return <AlertCircle className="w-4 h-4" />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="relative h-1.5 bg-muted rounded-full overflow-hidden mb-6">
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-y-0 left-0 bg-gradient-to-r from-purple-500 via-blue-500 via-amber-500 via-orange-500 to-emerald-500"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase nodes */}
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
{designPhases.map((phase, index) => {
|
||||||
|
const status = phaseStatuses[phase.id]
|
||||||
|
const isCurrent = phase.id === currentPhase
|
||||||
|
const isCompleted = status === 'completed'
|
||||||
|
const isBlocked = status === 'blocked' || status === 'needs_review'
|
||||||
|
const colors = colorMap[phase.color]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={phase.id} className="flex flex-col items-center flex-1">
|
||||||
|
{/* Connector line */}
|
||||||
|
{index > 0 && (
|
||||||
|
<div className="absolute" style={{ left: `${((index - 0.5) / DESIGN_PHASES.length) * 100}%`, top: '0.75rem', width: `${(1 / DESIGN_PHASES.length) * 100}%` }}>
|
||||||
|
<div className={cn(
|
||||||
|
'h-0.5 w-full transition-colors duration-300',
|
||||||
|
index <= currentIndex ? 'bg-foreground/20' : 'bg-muted'
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Phase node */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => onPhaseClick?.(phase.id)}
|
||||||
|
className={cn(
|
||||||
|
'relative w-12 h-12 rounded-full flex items-center justify-center text-xl transition-all duration-300 border-2',
|
||||||
|
isCurrent && `${colors.bg} ${colors.border} ring-4 ${colors.ring}`,
|
||||||
|
isCompleted && 'bg-emerald-500/10 border-emerald-500/50',
|
||||||
|
isBlocked && 'bg-red-500/10 border-red-500/50',
|
||||||
|
!isCurrent && !isCompleted && !isBlocked && 'bg-muted border-transparent'
|
||||||
|
)}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
animate={isCurrent ? { scale: [1, 1.05, 1] } : {}}
|
||||||
|
transition={{ repeat: isCurrent ? Infinity : 0, duration: 2 }}
|
||||||
|
>
|
||||||
|
<span>{phase.icon}</span>
|
||||||
|
{/* Status indicator */}
|
||||||
|
{(isCompleted || status === 'in_progress' || isBlocked) && (
|
||||||
|
<div className={cn(
|
||||||
|
'absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center',
|
||||||
|
isCompleted && 'bg-emerald-500 text-white',
|
||||||
|
status === 'in_progress' && 'bg-blue-500 text-white',
|
||||||
|
isBlocked && 'bg-red-500 text-white'
|
||||||
|
)}>
|
||||||
|
{getStatusIcon(status)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">{phase.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{phase.fullDescription}</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{phase.commands.map(cmd => (
|
||||||
|
<code key={cmd} className="text-xs bg-muted px-1 rounded">{cmd}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
<span className={cn(
|
||||||
|
'text-sm font-medium mt-2 transition-colors',
|
||||||
|
isCurrent ? colors.text : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{phase.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||||
|
{phase.shortDescription}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loop back indicator */}
|
||||||
|
{onLoopBack && currentIndex > 0 && (
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onLoopBack(currentPhase, DESIGN_PHASES[currentIndex - 1])}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Loop back to {designPhases[currentIndex - 1].label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
src/components/project/recommendations-widget.tsx
Normal file
155
src/components/project/recommendations-widget.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Lightbulb,
|
||||||
|
AlertTriangle,
|
||||||
|
TrendingUp,
|
||||||
|
Zap,
|
||||||
|
ChevronRight,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { AIRecommendation } from '@/types/design-thinking'
|
||||||
|
|
||||||
|
interface RecommendationsWidgetProps {
|
||||||
|
recommendations: AIRecommendation[]
|
||||||
|
onAction?: (recommendation: AIRecommendation) => void
|
||||||
|
onDismiss?: (recommendationId: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig: Record<AIRecommendation['type'], { icon: typeof Sparkles; color: string; bg: string }> = {
|
||||||
|
action: { icon: Zap, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||||
|
warning: { icon: AlertTriangle, color: 'text-amber-500', bg: 'bg-amber-500/10' },
|
||||||
|
insight: { icon: Lightbulb, color: 'text-purple-500', bg: 'bg-purple-500/10' },
|
||||||
|
optimization: { icon: TrendingUp, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<AIRecommendation['priority'], string> = {
|
||||||
|
low: 'bg-muted text-muted-foreground',
|
||||||
|
medium: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
|
||||||
|
high: 'bg-red-500/10 text-red-600 border-red-500/20',
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendationCard({
|
||||||
|
recommendation,
|
||||||
|
onAction,
|
||||||
|
onDismiss
|
||||||
|
}: {
|
||||||
|
recommendation: AIRecommendation
|
||||||
|
onAction?: (recommendation: AIRecommendation) => void
|
||||||
|
onDismiss?: (recommendationId: string) => void
|
||||||
|
}) {
|
||||||
|
const config = typeConfig[recommendation.type]
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20, height: 0 }}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'relative p-3 rounded-lg border transition-all hover:shadow-sm',
|
||||||
|
config.bg,
|
||||||
|
'border-transparent hover:border-foreground/5'
|
||||||
|
)}>
|
||||||
|
{/* Dismiss button */}
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDismiss(recommendation.id)}
|
||||||
|
className="absolute top-2 right-2 p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-foreground/10"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={cn('p-1.5 rounded-lg', config.bg)}>
|
||||||
|
<Icon className={cn('w-4 h-4', config.color)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 pr-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium">{recommendation.title}</span>
|
||||||
|
<Badge variant="outline" className={cn('text-[10px] px-1.5', priorityColors[recommendation.priority])}>
|
||||||
|
{recommendation.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
{recommendation.description}
|
||||||
|
</p>
|
||||||
|
{recommendation.action_command && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1 -ml-2"
|
||||||
|
onClick={() => onAction?.(recommendation)}
|
||||||
|
>
|
||||||
|
<code className="bg-muted px-1 rounded">{recommendation.action_command}</code>
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecommendationsWidget({
|
||||||
|
recommendations,
|
||||||
|
onAction,
|
||||||
|
onDismiss,
|
||||||
|
className
|
||||||
|
}: RecommendationsWidgetProps) {
|
||||||
|
const sortedRecommendations = [...recommendations].sort((a, b) => {
|
||||||
|
const priorityOrder = { high: 0, medium: 1, low: 2 }
|
||||||
|
return priorityOrder[a.priority] - priorityOrder[b.priority]
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Sparkles className="w-4 h-4 text-purple-500" />
|
||||||
|
AI Recommendations
|
||||||
|
{recommendations.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
{recommendations.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{sortedRecommendations.map((rec) => (
|
||||||
|
<RecommendationCard
|
||||||
|
key={rec.id}
|
||||||
|
recommendation={rec}
|
||||||
|
onAction={onAction}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
{recommendations.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Sparkles className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No recommendations yet
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
AI insights will appear as you work
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@@ -10,24 +10,17 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
brand:
|
|
||||||
"bg-brand text-brand-foreground hover:bg-brand/90 shadow-brand hover:shadow-brand-lg transition-shadow",
|
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
success:
|
|
||||||
"bg-success text-success-foreground hover:bg-success/90",
|
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
"outline-brand":
|
|
||||||
"border-brand/30 bg-brand/5 text-brand hover:bg-brand/10 hover:border-brand/50",
|
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
"ghost-brand":
|
|
||||||
"text-brand hover:bg-brand/10 hover:text-brand",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
"link-brand": "text-brand underline-offset-4 hover:underline",
|
brand:
|
||||||
|
"bg-brand text-white hover:bg-brand/90 focus-visible:ring-brand/20",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
@@ -47,8 +40,8 @@ const buttonVariants = cva(
|
|||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant = "default",
|
||||||
size,
|
size = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
@@ -60,6 +53,8 @@ function Button({
|
|||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
31
src/components/ui/tooltip.tsx
Normal file
31
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
143
src/hooks/use-agent-runs.ts
Normal file
143
src/hooks/use-agent-runs.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import type { Database } from '@/types/database'
|
||||||
|
|
||||||
|
type AgentRun = Database['public']['Tables']['agent_runs']['Row']
|
||||||
|
type AgentRunInsert = Database['public']['Tables']['agent_runs']['Insert']
|
||||||
|
|
||||||
|
interface UseAgentRunsReturn {
|
||||||
|
runs: AgentRun[]
|
||||||
|
pendingRuns: AgentRun[]
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
approve: (runId: string) => Promise<void>
|
||||||
|
reject: (runId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentRuns(projectId: string): UseAgentRunsReturn {
|
||||||
|
const [runs, setRuns] = useState<AgentRun[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
const fetchRuns = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const { data, error: fetchError } = await supabase
|
||||||
|
.from('agent_runs')
|
||||||
|
.select('*')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(50)
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError
|
||||||
|
setRuns((data || []) as unknown as AgentRun[])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to load agent runs'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRuns()
|
||||||
|
|
||||||
|
// Set up real-time subscription
|
||||||
|
const supabase = createClient()
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`agent_runs:${projectId}`)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: '*',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'agent_runs',
|
||||||
|
filter: `project_id=eq.${projectId}`,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
if (payload.eventType === 'INSERT') {
|
||||||
|
setRuns((prev) => [payload.new as AgentRun, ...prev])
|
||||||
|
} else if (payload.eventType === 'UPDATE') {
|
||||||
|
setRuns((prev) =>
|
||||||
|
prev.map((run) =>
|
||||||
|
run.id === (payload.new as AgentRun).id
|
||||||
|
? (payload.new as AgentRun)
|
||||||
|
: run
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (payload.eventType === 'DELETE') {
|
||||||
|
setRuns((prev) =>
|
||||||
|
prev.filter((run) => run.id !== (payload.old as AgentRun).id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel)
|
||||||
|
}
|
||||||
|
}, [projectId, fetchRuns])
|
||||||
|
|
||||||
|
const approve = useCallback(async (runId: string) => {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('agent_runs')
|
||||||
|
.update({
|
||||||
|
approval_status: 'approved',
|
||||||
|
approved_by: user.id,
|
||||||
|
approved_at: new Date().toISOString(),
|
||||||
|
} as never)
|
||||||
|
.eq('id', runId)
|
||||||
|
|
||||||
|
if (updateError) throw updateError
|
||||||
|
|
||||||
|
// Trigger n8n webhook to execute the approved action
|
||||||
|
await fetch(`${process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL}/agent-approved`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ run_id: runId, project_id: projectId }),
|
||||||
|
})
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const reject = useCallback(async (runId: string) => {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('agent_runs')
|
||||||
|
.update({
|
||||||
|
approval_status: 'rejected',
|
||||||
|
approved_by: user.id,
|
||||||
|
approved_at: new Date().toISOString(),
|
||||||
|
status: 'cancelled',
|
||||||
|
} as never)
|
||||||
|
.eq('id', runId)
|
||||||
|
|
||||||
|
if (updateError) throw updateError
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pendingRuns = runs.filter(
|
||||||
|
(run) => run.approval_status === 'pending' && run.proposed_changes
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
runs,
|
||||||
|
pendingRuns,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh: fetchRuns,
|
||||||
|
approve,
|
||||||
|
reject,
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/hooks/use-project.ts
Normal file
132
src/hooks/use-project.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
getProject,
|
||||||
|
getBacklogItems,
|
||||||
|
getProjectHealth,
|
||||||
|
getRecommendations,
|
||||||
|
getProjectActivities,
|
||||||
|
updateProjectPhase,
|
||||||
|
loopBackToPhase,
|
||||||
|
updateBacklogItem,
|
||||||
|
dismissRecommendation,
|
||||||
|
type Project,
|
||||||
|
type ProjectPhase
|
||||||
|
} from '@/lib/supabase/projects'
|
||||||
|
import type {
|
||||||
|
DesignPhase,
|
||||||
|
PhaseStatus,
|
||||||
|
BacklogItem,
|
||||||
|
ProjectHealth,
|
||||||
|
AIRecommendation
|
||||||
|
} from '@/types/design-thinking'
|
||||||
|
import { DESIGN_PHASES } from '@/types/design-thinking'
|
||||||
|
|
||||||
|
interface UseProjectReturn {
|
||||||
|
project: Project | null
|
||||||
|
phases: ProjectPhase[]
|
||||||
|
phaseStatuses: Record<DesignPhase, PhaseStatus>
|
||||||
|
backlogItems: BacklogItem[]
|
||||||
|
health: ProjectHealth
|
||||||
|
recommendations: AIRecommendation[]
|
||||||
|
activities: Array<{ id: string; activity_type: string; message: string; created_at: string }>
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
advancePhase: (phase: DesignPhase) => Promise<void>
|
||||||
|
loopBack: (fromPhase: DesignPhase, toPhase: DesignPhase) => Promise<void>
|
||||||
|
updateBacklog: (itemId: string, updates: Partial<BacklogItem>) => Promise<void>
|
||||||
|
dismissRec: (recId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProject(projectId: string): UseProjectReturn {
|
||||||
|
const [project, setProject] = useState<Project | null>(null)
|
||||||
|
const [phases, setPhases] = useState<ProjectPhase[]>([])
|
||||||
|
const [backlogItems, setBacklogItems] = useState<BacklogItem[]>([])
|
||||||
|
const [health, setHealth] = useState<ProjectHealth>({
|
||||||
|
overall: 100,
|
||||||
|
velocity: 0,
|
||||||
|
blockers: 0,
|
||||||
|
overdue: 0,
|
||||||
|
completion_rate: 0
|
||||||
|
})
|
||||||
|
const [recommendations, setRecommendations] = useState<AIRecommendation[]>([])
|
||||||
|
const [activities, setActivities] = useState<Array<{ id: string; activity_type: string; message: string; created_at: string }>>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
// Derive phase statuses from phases array
|
||||||
|
const phaseStatuses: Record<DesignPhase, PhaseStatus> = DESIGN_PHASES.reduce((acc, phase) => {
|
||||||
|
const latestPhase = phases.find(p => p.phase === phase)
|
||||||
|
acc[phase] = latestPhase?.status || 'not_started'
|
||||||
|
return acc
|
||||||
|
}, {} as Record<DesignPhase, PhaseStatus>)
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const [projectData, backlog, healthData, recs, acts] = await Promise.all([
|
||||||
|
getProject(projectId),
|
||||||
|
getBacklogItems(projectId),
|
||||||
|
getProjectHealth(projectId),
|
||||||
|
getRecommendations(projectId),
|
||||||
|
getProjectActivities(projectId)
|
||||||
|
])
|
||||||
|
|
||||||
|
setProject(projectData.project)
|
||||||
|
setPhases(projectData.phases)
|
||||||
|
setBacklogItems(backlog)
|
||||||
|
setHealth(healthData)
|
||||||
|
setRecommendations(recs)
|
||||||
|
setActivities(acts)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to load project'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll()
|
||||||
|
}, [fetchAll])
|
||||||
|
|
||||||
|
const advancePhase = useCallback(async (phase: DesignPhase) => {
|
||||||
|
await updateProjectPhase(projectId, phase, 'completed')
|
||||||
|
await fetchAll()
|
||||||
|
}, [projectId, fetchAll])
|
||||||
|
|
||||||
|
const loopBack = useCallback(async (fromPhase: DesignPhase, toPhase: DesignPhase) => {
|
||||||
|
await loopBackToPhase(projectId, fromPhase, toPhase)
|
||||||
|
await fetchAll()
|
||||||
|
}, [projectId, fetchAll])
|
||||||
|
|
||||||
|
const updateBacklog = useCallback(async (itemId: string, updates: Partial<BacklogItem>) => {
|
||||||
|
await updateBacklogItem(itemId, updates)
|
||||||
|
await fetchAll()
|
||||||
|
}, [fetchAll])
|
||||||
|
|
||||||
|
const dismissRec = useCallback(async (recId: string) => {
|
||||||
|
await dismissRecommendation(recId)
|
||||||
|
setRecommendations(prev => prev.filter(r => r.id !== recId))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
phases,
|
||||||
|
phaseStatuses,
|
||||||
|
backlogItems,
|
||||||
|
health,
|
||||||
|
recommendations,
|
||||||
|
activities,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh: fetchAll,
|
||||||
|
advancePhase,
|
||||||
|
loopBack,
|
||||||
|
updateBacklog,
|
||||||
|
dismissRec
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/hooks/use-toast.ts
Normal file
23
src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { toast as sonnerToast } from 'sonner'
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
variant?: 'default' | 'destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const toast = ({ title, description, variant }: ToastProps) => {
|
||||||
|
if (variant === 'destructive') {
|
||||||
|
sonnerToast.error(title, {
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
sonnerToast.success(title, {
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toast }
|
||||||
|
}
|
||||||
197
src/lib/gitea/client.ts
Normal file
197
src/lib/gitea/client.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import type {
|
||||||
|
GiteaRepository,
|
||||||
|
GiteaWebhook,
|
||||||
|
GiteaPullRequest,
|
||||||
|
GiteaBranch,
|
||||||
|
CreateRepoOptions,
|
||||||
|
CreateWebhookOptions,
|
||||||
|
CreateCommitOptions,
|
||||||
|
CreatePROptions,
|
||||||
|
GiteaError,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const GITEA_URL = process.env.NEXT_PUBLIC_GITEA_URL || 'https://gitea.mylder.io'
|
||||||
|
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''
|
||||||
|
const GITEA_OWNER = 'admin'
|
||||||
|
|
||||||
|
class GiteaClient {
|
||||||
|
private baseUrl: string
|
||||||
|
private token: string
|
||||||
|
private owner: string
|
||||||
|
|
||||||
|
constructor(baseUrl: string = GITEA_URL, token: string = GITEA_TOKEN, owner: string = GITEA_OWNER) {
|
||||||
|
this.baseUrl = baseUrl
|
||||||
|
this.token = token
|
||||||
|
this.owner = owner
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}/api/v1${endpoint}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${this.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: GiteaError = await response.json().catch(() => ({
|
||||||
|
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
}))
|
||||||
|
throw new Error(error.message || `Gitea API error: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRepo(options: CreateRepoOptions): Promise<GiteaRepository> {
|
||||||
|
return this.request<GiteaRepository>('/user/repos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: options.name,
|
||||||
|
description: options.description || '',
|
||||||
|
private: options.private ?? false,
|
||||||
|
auto_init: options.auto_init ?? true,
|
||||||
|
default_branch: options.default_branch || 'main',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRepo(name: string): Promise<GiteaRepository | null> {
|
||||||
|
try {
|
||||||
|
return await this.request<GiteaRepository>(`/repos/${this.owner}/${name}`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRepo(name: string): Promise<void> {
|
||||||
|
await this.request(`/repos/${this.owner}/${name}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWebhook(options: CreateWebhookOptions): Promise<GiteaWebhook> {
|
||||||
|
return this.request<GiteaWebhook>(`/repos/${this.owner}/${options.repo}/hooks`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'gitea',
|
||||||
|
config: {
|
||||||
|
url: options.url,
|
||||||
|
content_type: 'json',
|
||||||
|
secret: options.secret || '',
|
||||||
|
},
|
||||||
|
events: options.events,
|
||||||
|
active: options.active ?? true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWebhooks(repo: string): Promise<GiteaWebhook[]> {
|
||||||
|
return this.request<GiteaWebhook[]>(`/repos/${this.owner}/${repo}/hooks`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWebhook(repo: string, hookId: number): Promise<void> {
|
||||||
|
await this.request(`/repos/${this.owner}/${repo}/hooks/${hookId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBranch(repo: string, branch: string): Promise<GiteaBranch | null> {
|
||||||
|
try {
|
||||||
|
return await this.request<GiteaBranch>(`/repos/${this.owner}/${repo}/branches/${branch}`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBranch(repo: string, branchName: string, baseBranch: string = 'main'): Promise<GiteaBranch> {
|
||||||
|
const base = await this.getBranch(repo, baseBranch)
|
||||||
|
if (!base) {
|
||||||
|
throw new Error(`Base branch ${baseBranch} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.request<GiteaBranch>(`/repos/${this.owner}/${repo}/branches`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
new_branch_name: branchName,
|
||||||
|
old_ref_name: baseBranch,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCommit(options: CreateCommitOptions): Promise<{ commit: { sha: string } }> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
options.files.map(async (file) => {
|
||||||
|
const existing = await this.getFileContent(options.repo, file.path, options.branch)
|
||||||
|
const method = existing ? 'PUT' : 'POST'
|
||||||
|
const endpoint = `/repos/${this.owner}/${options.repo}/contents/${file.path}`
|
||||||
|
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method,
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: options.message,
|
||||||
|
content: Buffer.from(file.content).toString('base64'),
|
||||||
|
branch: options.branch,
|
||||||
|
sha: existing?.sha,
|
||||||
|
author: options.author,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return results[0] as { commit: { sha: string } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileContent(repo: string, path: string, ref?: string): Promise<{ sha: string; content: string } | null> {
|
||||||
|
try {
|
||||||
|
const endpoint = `/repos/${this.owner}/${repo}/contents/${path}${ref ? `?ref=${ref}` : ''}`
|
||||||
|
return await this.request(endpoint)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPullRequest(options: CreatePROptions): Promise<GiteaPullRequest> {
|
||||||
|
return this.request<GiteaPullRequest>(`/repos/${this.owner}/${options.repo}/pulls`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: options.title,
|
||||||
|
body: options.body || '',
|
||||||
|
head: options.head,
|
||||||
|
base: options.base || 'main',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPullRequest(repo: string, number: number): Promise<GiteaPullRequest> {
|
||||||
|
return this.request<GiteaPullRequest>(`/repos/${this.owner}/${repo}/pulls/${number}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergePullRequest(repo: string, number: number, strategy: 'merge' | 'rebase' | 'squash' = 'squash'): Promise<void> {
|
||||||
|
await this.request(`/repos/${this.owner}/${repo}/pulls/${number}/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ do: strategy }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPullRequests(repo: string, state: 'open' | 'closed' | 'all' = 'open'): Promise<GiteaPullRequest[]> {
|
||||||
|
return this.request<GiteaPullRequest[]>(`/repos/${this.owner}/${repo}/pulls?state=${state}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getRepoUrl(name: string): string {
|
||||||
|
return `${this.baseUrl}/${this.owner}/${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
getCloneUrl(name: string): string {
|
||||||
|
return `${this.baseUrl}/${this.owner}/${name}.git`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gitea = new GiteaClient()
|
||||||
|
export { GiteaClient }
|
||||||
82
src/lib/gitea/types.ts
Normal file
82
src/lib/gitea/types.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export interface GiteaRepository {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
full_name: string
|
||||||
|
description: string
|
||||||
|
html_url: string
|
||||||
|
clone_url: string
|
||||||
|
ssh_url: string
|
||||||
|
default_branch: string
|
||||||
|
empty: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaWebhook {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
url: string
|
||||||
|
active: boolean
|
||||||
|
events: string[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaCommitFile {
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
operation?: 'create' | 'update' | 'delete'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaPullRequest {
|
||||||
|
id: number
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
html_url: string
|
||||||
|
state: 'open' | 'closed' | 'merged'
|
||||||
|
head: { ref: string }
|
||||||
|
base: { ref: string }
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaBranch {
|
||||||
|
name: string
|
||||||
|
commit: { id: string; message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRepoOptions {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
private?: boolean
|
||||||
|
auto_init?: boolean
|
||||||
|
default_branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWebhookOptions {
|
||||||
|
repo: string
|
||||||
|
url: string
|
||||||
|
events: string[]
|
||||||
|
secret?: string
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommitOptions {
|
||||||
|
repo: string
|
||||||
|
branch: string
|
||||||
|
message: string
|
||||||
|
files: GiteaCommitFile[]
|
||||||
|
author?: { name: string; email: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePROptions {
|
||||||
|
repo: string
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
head: string
|
||||||
|
base?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GiteaError = {
|
||||||
|
message: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { createBrowserClient } from '@supabase/ssr'
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
import type { Database } from '@/types/database'
|
import type { Database } from '@/types/database'
|
||||||
|
|
||||||
|
let browserClient: ReturnType<typeof createBrowserClient<Database>> | null = null
|
||||||
|
|
||||||
export function createClient() {
|
export function createClient() {
|
||||||
return createBrowserClient<Database>(
|
if (browserClient) {
|
||||||
|
return browserClient
|
||||||
|
}
|
||||||
|
|
||||||
|
browserClient = createBrowserClient<Database>(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return browserClient
|
||||||
}
|
}
|
||||||
|
|||||||
430
src/lib/supabase/projects.ts
Normal file
430
src/lib/supabase/projects.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import { deleteGiteaRepo } from '@/app/(dashboard)/projects/new/actions'
|
||||||
|
import type {
|
||||||
|
DesignPhase,
|
||||||
|
PhaseStatus,
|
||||||
|
BacklogItem,
|
||||||
|
ProjectHealth,
|
||||||
|
AIRecommendation
|
||||||
|
} from '@/types/design-thinking'
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
current_phase: DesignPhase
|
||||||
|
health_score: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPhase {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
phase: DesignPhase
|
||||||
|
status: PhaseStatus
|
||||||
|
started_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
notes: string | null
|
||||||
|
iteration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all projects for current user
|
||||||
|
export async function getProjects(): Promise<Project[]> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('*')
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return (data || []) as unknown as Project[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch single project with phases
|
||||||
|
export async function getProject(projectId: string): Promise<{ project: Project; phases: ProjectPhase[] }> {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const [projectResult, phasesResult] = await Promise.all([
|
||||||
|
supabase.from('projects').select('*').eq('id', projectId).single(),
|
||||||
|
supabase.from('project_phases').select('*').eq('project_id', projectId).order('iteration', { ascending: false })
|
||||||
|
])
|
||||||
|
|
||||||
|
if (projectResult.error) throw projectResult.error
|
||||||
|
if (phasesResult.error) throw phasesResult.error
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: projectResult.data as unknown as Project,
|
||||||
|
phases: (phasesResult.data || []) as unknown as ProjectPhase[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new project
|
||||||
|
export async function createProject(name: string, description?: string): Promise<Project> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
// Use raw SQL-like insert with explicit types
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.insert([{
|
||||||
|
user_id: user.id,
|
||||||
|
name,
|
||||||
|
description: description || null,
|
||||||
|
current_phase: 'empathize' as DesignPhase,
|
||||||
|
health_score: 100
|
||||||
|
}] as never)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
const project = data as unknown as Project
|
||||||
|
|
||||||
|
// Initialize phase tracking
|
||||||
|
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
||||||
|
await supabase.from('project_phases').insert(
|
||||||
|
phases.map((phase, index) => ({
|
||||||
|
project_id: project.id,
|
||||||
|
phase,
|
||||||
|
status: index === 0 ? 'in_progress' : 'not_started',
|
||||||
|
iteration: 1,
|
||||||
|
started_at: index === 0 ? new Date().toISOString() : null
|
||||||
|
})) as never
|
||||||
|
)
|
||||||
|
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project phase
|
||||||
|
export async function updateProjectPhase(projectId: string, phase: DesignPhase, status: PhaseStatus): Promise<void> {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// Update the phase record
|
||||||
|
const { error: phaseError } = await supabase
|
||||||
|
.from('project_phases')
|
||||||
|
.update({
|
||||||
|
status,
|
||||||
|
started_at: status === 'in_progress' ? new Date().toISOString() : undefined,
|
||||||
|
completed_at: status === 'completed' ? new Date().toISOString() : undefined
|
||||||
|
} as never)
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.eq('phase', phase)
|
||||||
|
|
||||||
|
if (phaseError) throw phaseError
|
||||||
|
|
||||||
|
// Update project's current phase if advancing
|
||||||
|
if (status === 'completed') {
|
||||||
|
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
||||||
|
const currentIndex = phases.indexOf(phase)
|
||||||
|
if (currentIndex < phases.length - 1) {
|
||||||
|
const nextPhase = phases[currentIndex + 1]
|
||||||
|
await supabase
|
||||||
|
.from('projects')
|
||||||
|
.update({ current_phase: nextPhase } as never)
|
||||||
|
.eq('id', projectId)
|
||||||
|
|
||||||
|
// Start the next phase
|
||||||
|
await supabase
|
||||||
|
.from('project_phases')
|
||||||
|
.update({ status: 'in_progress', started_at: new Date().toISOString() } as never)
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.eq('phase', nextPhase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop back to a previous phase (start new iteration)
|
||||||
|
export async function loopBackToPhase(projectId: string, fromPhase: DesignPhase, toPhase: DesignPhase): Promise<void> {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// Get current max iteration
|
||||||
|
const { data: currentPhases } = await supabase
|
||||||
|
.from('project_phases')
|
||||||
|
.select('iteration')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.order('iteration', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const phasesData = currentPhases as unknown as Array<{ iteration: number }> | null
|
||||||
|
const newIteration = (phasesData?.[0]?.iteration || 1) + 1
|
||||||
|
|
||||||
|
// Create new iteration phases from toPhase onwards
|
||||||
|
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
||||||
|
const startIndex = phases.indexOf(toPhase)
|
||||||
|
|
||||||
|
await supabase.from('project_phases').insert(
|
||||||
|
phases.slice(startIndex).map((phase, index) => ({
|
||||||
|
project_id: projectId,
|
||||||
|
phase,
|
||||||
|
status: index === 0 ? 'in_progress' : 'not_started',
|
||||||
|
iteration: newIteration,
|
||||||
|
started_at: index === 0 ? new Date().toISOString() : null
|
||||||
|
})) as never
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update project's current phase
|
||||||
|
await supabase
|
||||||
|
.from('projects')
|
||||||
|
.update({ current_phase: toPhase } as never)
|
||||||
|
.eq('id', projectId)
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await supabase.from('project_activities').insert([{
|
||||||
|
project_id: projectId,
|
||||||
|
activity_type: 'loop_back',
|
||||||
|
message: `Looped back from ${fromPhase} to ${toPhase} (iteration ${newIteration})`,
|
||||||
|
metadata: { from_phase: fromPhase, to_phase: toPhase, iteration: newIteration }
|
||||||
|
}] as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch backlog items for a project
|
||||||
|
export async function getBacklogItems(projectId: string): Promise<BacklogItem[]> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('backlog_items')
|
||||||
|
.select('*')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.order('priority_score', { ascending: false })
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return (data || []) as unknown as BacklogItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backlog item
|
||||||
|
export async function createBacklogItem(
|
||||||
|
projectId: string,
|
||||||
|
item: Omit<BacklogItem, 'id' | 'priority_score' | 'created_at' | 'updated_at'>
|
||||||
|
): Promise<BacklogItem> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('backlog_items')
|
||||||
|
.insert([{ ...item, project_id: projectId }] as never)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data as unknown as BacklogItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update backlog item
|
||||||
|
export async function updateBacklogItem(itemId: string, updates: Partial<BacklogItem>): Promise<BacklogItem> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('backlog_items')
|
||||||
|
.update(updates as never)
|
||||||
|
.eq('id', itemId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data as unknown as BacklogItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch project health
|
||||||
|
export async function getProjectHealth(projectId: string): Promise<ProjectHealth> {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// Get latest health snapshot
|
||||||
|
const { data: snapshot } = await supabase
|
||||||
|
.from('project_health_snapshots')
|
||||||
|
.select('*')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.order('snapshot_date', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const snapshotData = snapshot as unknown as {
|
||||||
|
overall_score: number
|
||||||
|
velocity: number
|
||||||
|
blockers: number
|
||||||
|
overdue: number
|
||||||
|
completion_rate: number
|
||||||
|
} | null
|
||||||
|
|
||||||
|
if (snapshotData) {
|
||||||
|
return {
|
||||||
|
overall: snapshotData.overall_score,
|
||||||
|
velocity: snapshotData.velocity,
|
||||||
|
blockers: snapshotData.blockers,
|
||||||
|
overdue: snapshotData.overdue,
|
||||||
|
completion_rate: snapshotData.completion_rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate on the fly if no snapshot
|
||||||
|
const { data: items } = await supabase
|
||||||
|
.from('backlog_items')
|
||||||
|
.select('status, due_date')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
|
||||||
|
const itemsData = items as unknown as Array<{ status: string; due_date: string | null }> | null
|
||||||
|
const total = itemsData?.length || 0
|
||||||
|
const done = itemsData?.filter(i => i.status === 'done').length || 0
|
||||||
|
const blocked = itemsData?.filter(i => i.status === 'blocked').length || 0
|
||||||
|
const overdue = itemsData?.filter(i =>
|
||||||
|
i.due_date && new Date(i.due_date) < new Date() && i.status !== 'done'
|
||||||
|
).length || 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
overall: Math.max(0, 100 - (blocked * 10) - (overdue * 5)),
|
||||||
|
velocity: done,
|
||||||
|
blockers: blocked,
|
||||||
|
overdue,
|
||||||
|
completion_rate: total > 0 ? Math.round((done / total) * 100) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch AI recommendations
|
||||||
|
export async function getRecommendations(projectId: string): Promise<AIRecommendation[]> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('ai_recommendations')
|
||||||
|
.select('*')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.eq('dismissed', false)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return (data || []) as unknown as AIRecommendation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss recommendation
|
||||||
|
export async function dismissRecommendation(recommendationId: string): Promise<void> {
|
||||||
|
const supabase = createClient()
|
||||||
|
await supabase
|
||||||
|
.from('ai_recommendations')
|
||||||
|
.update({ dismissed: true, dismissed_at: new Date().toISOString() } as never)
|
||||||
|
.eq('id', recommendationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch project activities
|
||||||
|
export async function getProjectActivities(projectId: string, limit = 20) {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('project_activities')
|
||||||
|
.select('*')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return (data || []) as unknown as Array<{
|
||||||
|
id: string
|
||||||
|
activity_type: string
|
||||||
|
message: string
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete project with cascade (includes Gitea repo deletion)
|
||||||
|
export async function deleteProject(projectId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Not authenticated' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, get the project to check ownership and get gitea_repo
|
||||||
|
const { data: projectData, error: fetchError } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('id, user_id, gitea_repo, name')
|
||||||
|
.eq('id', projectId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError || !projectData) {
|
||||||
|
return { success: false, error: 'Project not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = projectData as unknown as {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
gitea_repo: string | null
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (project.user_id !== user.id) {
|
||||||
|
return { success: false, error: 'Not authorized to delete this project' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract repo name from gitea_repo URL if it exists
|
||||||
|
// Format: https://gitea.mylder.io/admin/repo-name
|
||||||
|
if (project.gitea_repo) {
|
||||||
|
const repoMatch = project.gitea_repo.match(/\/([^/]+)$/)
|
||||||
|
const repoName = repoMatch ? repoMatch[1] : null
|
||||||
|
|
||||||
|
if (repoName) {
|
||||||
|
const giteaResult = await deleteGiteaRepo(repoName)
|
||||||
|
if (!giteaResult.success) {
|
||||||
|
console.error('Failed to delete Gitea repo:', giteaResult.error)
|
||||||
|
// Continue with database deletion even if Gitea fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete related data in order (due to foreign key constraints)
|
||||||
|
// Delete agent_runs
|
||||||
|
await supabase
|
||||||
|
.from('agent_runs')
|
||||||
|
.delete()
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
|
||||||
|
// Delete messages
|
||||||
|
await supabase
|
||||||
|
.from('messages')
|
||||||
|
.delete()
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
|
||||||
|
// Delete backlog items
|
||||||
|
await supabase
|
||||||
|
.from('backlog_items')
|
||||||
|
.delete()
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
|
||||||
|
// Delete project activities
|
||||||
|
await supabase
|
||||||
|
.from('project_activities')
|
||||||
|
.delete()
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
|
||||||
|
// Delete AI recommendations
|
||||||
|
await supabase
|
||||||
|
.from('ai_recommendations')
|
||||||
|
.delete()
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
|
||||||
|
// Delete project phases
|
||||||
|
await supabase
|
||||||
|
.from('project_phases')
|
||||||
|
.delete()
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
|
||||||
|
// Delete health snapshots
|
||||||
|
await supabase
|
||||||
|
.from('project_health_snapshots')
|
||||||
|
.delete()
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
|
||||||
|
// Finally, delete the project itself
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.delete()
|
||||||
|
.eq('id', projectId)
|
||||||
|
|
||||||
|
if (deleteError) {
|
||||||
|
return { success: false, error: deleteError.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete project'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,13 @@ export interface Database {
|
|||||||
slug: string
|
slug: string
|
||||||
description: string | null
|
description: string | null
|
||||||
gitea_repo: string | null
|
gitea_repo: string | null
|
||||||
|
gitea_webhook_id: number | null
|
||||||
|
automation_config: {
|
||||||
|
auto_review: boolean
|
||||||
|
auto_deploy: boolean
|
||||||
|
deployment_target: 'wws' | 'coolify' | 'both' | null
|
||||||
|
coolify_app_uuid: string | null
|
||||||
|
} | null
|
||||||
tech_stack: string[]
|
tech_stack: string[]
|
||||||
platform: string | null
|
platform: string | null
|
||||||
status: 'active' | 'archived' | 'paused'
|
status: 'active' | 'archived' | 'paused'
|
||||||
@@ -109,6 +116,13 @@ export interface Database {
|
|||||||
slug: string
|
slug: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
gitea_repo?: string | null
|
gitea_repo?: string | null
|
||||||
|
gitea_webhook_id?: number | null
|
||||||
|
automation_config?: {
|
||||||
|
auto_review?: boolean
|
||||||
|
auto_deploy?: boolean
|
||||||
|
deployment_target?: 'wws' | 'coolify' | 'both' | null
|
||||||
|
coolify_app_uuid?: string | null
|
||||||
|
} | null
|
||||||
tech_stack?: string[]
|
tech_stack?: string[]
|
||||||
platform?: string | null
|
platform?: string | null
|
||||||
status?: 'active' | 'archived' | 'paused'
|
status?: 'active' | 'archived' | 'paused'
|
||||||
@@ -123,6 +137,13 @@ export interface Database {
|
|||||||
slug?: string
|
slug?: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
gitea_repo?: string | null
|
gitea_repo?: string | null
|
||||||
|
gitea_webhook_id?: number | null
|
||||||
|
automation_config?: {
|
||||||
|
auto_review?: boolean
|
||||||
|
auto_deploy?: boolean
|
||||||
|
deployment_target?: 'wws' | 'coolify' | 'both' | null
|
||||||
|
coolify_app_uuid?: string | null
|
||||||
|
} | null
|
||||||
tech_stack?: string[]
|
tech_stack?: string[]
|
||||||
platform?: string | null
|
platform?: string | null
|
||||||
status?: 'active' | 'archived' | 'paused'
|
status?: 'active' | 'archived' | 'paused'
|
||||||
@@ -163,22 +184,48 @@ export interface Database {
|
|||||||
agent_runs: {
|
agent_runs: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string
|
id: string
|
||||||
message_id: string
|
message_id: string | null
|
||||||
project_id: string
|
project_id: string
|
||||||
|
user_id: string | null
|
||||||
command: string
|
command: string
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||||
result: Json | null
|
result: Json | null
|
||||||
|
proposed_changes: {
|
||||||
|
description: string
|
||||||
|
files: Array<{
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
operation: 'create' | 'update' | 'delete'
|
||||||
|
}>
|
||||||
|
estimated_impact?: string
|
||||||
|
} | null
|
||||||
|
approval_status: 'pending' | 'approved' | 'rejected' | 'auto_approved'
|
||||||
|
approved_by: string | null
|
||||||
|
approved_at: string | null
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string
|
id?: string
|
||||||
message_id: string
|
message_id?: string | null
|
||||||
project_id: string
|
project_id: string
|
||||||
|
user_id?: string
|
||||||
command: string
|
command: string
|
||||||
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||||
result?: Json | null
|
result?: Json | null
|
||||||
|
proposed_changes?: {
|
||||||
|
description: string
|
||||||
|
files: Array<{
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
operation: 'create' | 'update' | 'delete'
|
||||||
|
}>
|
||||||
|
estimated_impact?: string
|
||||||
|
} | null
|
||||||
|
approval_status?: 'pending' | 'approved' | 'rejected' | 'auto_approved'
|
||||||
|
approved_by?: string | null
|
||||||
|
approved_at?: string | null
|
||||||
started_at?: string | null
|
started_at?: string | null
|
||||||
completed_at?: string | null
|
completed_at?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -190,6 +237,18 @@ export interface Database {
|
|||||||
command?: string
|
command?: string
|
||||||
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||||
result?: Json | null
|
result?: Json | null
|
||||||
|
proposed_changes?: {
|
||||||
|
description: string
|
||||||
|
files: Array<{
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
operation: 'create' | 'update' | 'delete'
|
||||||
|
}>
|
||||||
|
estimated_impact?: string
|
||||||
|
} | null
|
||||||
|
approval_status?: 'pending' | 'approved' | 'rejected' | 'auto_approved'
|
||||||
|
approved_by?: string | null
|
||||||
|
approved_at?: string | null
|
||||||
started_at?: string | null
|
started_at?: string | null
|
||||||
completed_at?: string | null
|
completed_at?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
|||||||
123
src/types/design-thinking.ts
Normal file
123
src/types/design-thinking.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
export const DESIGN_PHASES = ['empathize', 'define', 'ideate', 'prototype', 'test'] as const
|
||||||
|
export type DesignPhase = typeof DESIGN_PHASES[number]
|
||||||
|
|
||||||
|
export type PhaseStatus = 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'needs_review'
|
||||||
|
|
||||||
|
export interface PhaseConfig {
|
||||||
|
id: DesignPhase
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
shortDescription: string
|
||||||
|
fullDescription: string
|
||||||
|
commands: string[]
|
||||||
|
color: string
|
||||||
|
questions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const designPhases: PhaseConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'empathize',
|
||||||
|
icon: '🎯',
|
||||||
|
label: 'Understand',
|
||||||
|
shortDescription: 'Learn about your users',
|
||||||
|
fullDescription: 'Research user needs, pain points, and behaviors',
|
||||||
|
commands: ['/research', '/ux', '/interview'],
|
||||||
|
color: 'purple',
|
||||||
|
questions: [
|
||||||
|
'Who are your users?',
|
||||||
|
'What problems do they face?',
|
||||||
|
'What do they need?'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'define',
|
||||||
|
icon: '📋',
|
||||||
|
label: 'Focus',
|
||||||
|
shortDescription: 'Define the problem',
|
||||||
|
fullDescription: 'Create clear problem statements and success criteria',
|
||||||
|
commands: ['/plan', '/roadmap', '/goal'],
|
||||||
|
color: 'blue',
|
||||||
|
questions: [
|
||||||
|
'What specific problem are we solving?',
|
||||||
|
'How will we measure success?',
|
||||||
|
'What are our constraints?'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ideate',
|
||||||
|
icon: '💡',
|
||||||
|
label: 'Explore',
|
||||||
|
shortDescription: 'Generate solutions',
|
||||||
|
fullDescription: 'Brainstorm ideas and evaluate options',
|
||||||
|
commands: ['/brainstorm', '/ideate', '/options'],
|
||||||
|
color: 'amber',
|
||||||
|
questions: [
|
||||||
|
'What are all possible solutions?',
|
||||||
|
'What are the trade-offs?',
|
||||||
|
'Which ideas should we explore?'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prototype',
|
||||||
|
icon: '🔨',
|
||||||
|
label: 'Build',
|
||||||
|
shortDescription: 'Create & deploy',
|
||||||
|
fullDescription: 'Build working prototypes and ship features',
|
||||||
|
commands: ['/build', '/deploy', '/ui'],
|
||||||
|
color: 'orange',
|
||||||
|
questions: [
|
||||||
|
'What should we build first?',
|
||||||
|
'How can we test quickly?',
|
||||||
|
'What is the minimum viable feature?'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'test',
|
||||||
|
icon: '✅',
|
||||||
|
label: 'Validate',
|
||||||
|
shortDescription: 'Test & review',
|
||||||
|
fullDescription: 'Validate with users and gather feedback',
|
||||||
|
commands: ['/test', '/review', '/feedback'],
|
||||||
|
color: 'emerald',
|
||||||
|
questions: [
|
||||||
|
'Does this solve the problem?',
|
||||||
|
'What feedback do users have?',
|
||||||
|
'What needs improvement?'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface BacklogItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
phase: DesignPhase
|
||||||
|
priority_score: number
|
||||||
|
user_value: number
|
||||||
|
time_criticality: number
|
||||||
|
risk_reduction: number
|
||||||
|
effort: number
|
||||||
|
status: 'backlog' | 'ready' | 'in_progress' | 'done' | 'blocked'
|
||||||
|
depends_on?: string[]
|
||||||
|
assigned_to?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHealth {
|
||||||
|
overall: number
|
||||||
|
velocity: number
|
||||||
|
blockers: number
|
||||||
|
overdue: number
|
||||||
|
completion_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIRecommendation {
|
||||||
|
id: string
|
||||||
|
type: 'action' | 'warning' | 'insight' | 'optimization'
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
priority: 'low' | 'medium' | 'high'
|
||||||
|
related_items?: string[]
|
||||||
|
action_command?: string
|
||||||
|
}
|
||||||
45
supabase/apply-migration.md
Normal file
45
supabase/apply-migration.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Applying the Design Thinking Schema Migration
|
||||||
|
|
||||||
|
## Option 1: Supabase Studio (Recommended)
|
||||||
|
1. Go to https://supabase.mylder.io
|
||||||
|
2. Login with dashboard credentials:
|
||||||
|
- User: `5f7DODtzYzoXKusR`
|
||||||
|
- Password: `VHmrbh9a6QVcXE2b2hMblhPoRsqsd2Gj`
|
||||||
|
3. Navigate to SQL Editor
|
||||||
|
4. Copy contents of `migrations/20251214_design_thinking_schema.sql`
|
||||||
|
5. Execute
|
||||||
|
|
||||||
|
## Option 2: Via psql on VPS
|
||||||
|
```bash
|
||||||
|
# SSH to VPS
|
||||||
|
ssh root@149.102.155.84
|
||||||
|
|
||||||
|
# Find the PostgreSQL container
|
||||||
|
docker ps | grep postgres
|
||||||
|
|
||||||
|
# Execute migration
|
||||||
|
docker exec -i <postgres_container> psql -U supabase -d postgres < /path/to/migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option 3: Via docker exec with heredoc
|
||||||
|
```bash
|
||||||
|
docker exec -i supabase-db psql -U supabase postgres <<'SQL'
|
||||||
|
-- Paste contents of 20251214_design_thinking_schema.sql here
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
After applying, verify tables exist:
|
||||||
|
```sql
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name;
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected tables:
|
||||||
|
- projects
|
||||||
|
- project_phases
|
||||||
|
- backlog_items
|
||||||
|
- ai_recommendations
|
||||||
|
- project_health_snapshots
|
||||||
|
- project_activities
|
||||||
278
supabase/migrations/20251214_design_thinking_schema.sql
Normal file
278
supabase/migrations/20251214_design_thinking_schema.sql
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
-- Design Thinking Schema for Mylder Platform
|
||||||
|
-- Migration: 20251214_design_thinking_schema.sql
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Design phases enum
|
||||||
|
CREATE TYPE design_phase AS ENUM ('empathize', 'define', 'ideate', 'prototype', 'test');
|
||||||
|
CREATE TYPE phase_status AS ENUM ('not_started', 'in_progress', 'completed', 'blocked', 'needs_review');
|
||||||
|
CREATE TYPE backlog_status AS ENUM ('backlog', 'ready', 'in_progress', 'done', 'blocked');
|
||||||
|
CREATE TYPE recommendation_type AS ENUM ('action', 'warning', 'insight', 'optimization');
|
||||||
|
CREATE TYPE recommendation_priority AS ENUM ('low', 'medium', 'high');
|
||||||
|
|
||||||
|
-- Projects table (extend or create)
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
current_phase design_phase DEFAULT 'empathize',
|
||||||
|
health_score INTEGER DEFAULT 100 CHECK (health_score >= 0 AND health_score <= 100),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Project phase tracking
|
||||||
|
CREATE TABLE project_phases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
phase design_phase NOT NULL,
|
||||||
|
status phase_status DEFAULT 'not_started',
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
notes TEXT,
|
||||||
|
iteration INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(project_id, phase, iteration)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Backlog items with WSJF prioritization
|
||||||
|
CREATE TABLE backlog_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
phase design_phase NOT NULL,
|
||||||
|
status backlog_status DEFAULT 'backlog',
|
||||||
|
|
||||||
|
-- WSJF scoring (1-10 scale)
|
||||||
|
user_value INTEGER DEFAULT 5 CHECK (user_value >= 1 AND user_value <= 10),
|
||||||
|
time_criticality INTEGER DEFAULT 5 CHECK (time_criticality >= 1 AND time_criticality <= 10),
|
||||||
|
risk_reduction INTEGER DEFAULT 5 CHECK (risk_reduction >= 1 AND risk_reduction <= 10),
|
||||||
|
effort INTEGER DEFAULT 5 CHECK (effort >= 1 AND effort <= 10),
|
||||||
|
|
||||||
|
-- Computed priority score: (user_value + time_criticality + risk_reduction) / effort
|
||||||
|
priority_score DECIMAL(4,2) GENERATED ALWAYS AS (
|
||||||
|
(user_value + time_criticality + risk_reduction)::DECIMAL / GREATEST(effort, 1)
|
||||||
|
) STORED,
|
||||||
|
|
||||||
|
-- Dependencies
|
||||||
|
depends_on UUID[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Assignment
|
||||||
|
assigned_to UUID REFERENCES auth.users(id),
|
||||||
|
due_date DATE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AI recommendations
|
||||||
|
CREATE TABLE ai_recommendations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
type recommendation_type NOT NULL,
|
||||||
|
priority recommendation_priority DEFAULT 'medium',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
action_command TEXT,
|
||||||
|
related_items UUID[] DEFAULT '{}',
|
||||||
|
dismissed BOOLEAN DEFAULT FALSE,
|
||||||
|
dismissed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Project health metrics (historical tracking)
|
||||||
|
CREATE TABLE project_health_snapshots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
overall_score INTEGER NOT NULL CHECK (overall_score >= 0 AND overall_score <= 100),
|
||||||
|
velocity INTEGER DEFAULT 0,
|
||||||
|
blockers INTEGER DEFAULT 0,
|
||||||
|
overdue INTEGER DEFAULT 0,
|
||||||
|
completion_rate INTEGER DEFAULT 0,
|
||||||
|
snapshot_date DATE DEFAULT CURRENT_DATE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(project_id, snapshot_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Activity feed for projects
|
||||||
|
CREATE TABLE project_activities (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
activity_type TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX idx_projects_user_id ON projects(user_id);
|
||||||
|
CREATE INDEX idx_project_phases_project_id ON project_phases(project_id);
|
||||||
|
CREATE INDEX idx_backlog_items_project_id ON backlog_items(project_id);
|
||||||
|
CREATE INDEX idx_backlog_items_status ON backlog_items(status);
|
||||||
|
CREATE INDEX idx_backlog_items_priority ON backlog_items(priority_score DESC);
|
||||||
|
CREATE INDEX idx_ai_recommendations_project_id ON ai_recommendations(project_id);
|
||||||
|
CREATE INDEX idx_ai_recommendations_dismissed ON ai_recommendations(dismissed) WHERE dismissed = FALSE;
|
||||||
|
CREATE INDEX idx_project_health_snapshots_project_id ON project_health_snapshots(project_id);
|
||||||
|
CREATE INDEX idx_project_activities_project_id ON project_activities(project_id);
|
||||||
|
CREATE INDEX idx_project_activities_created_at ON project_activities(created_at DESC);
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE project_phases ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE backlog_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE ai_recommendations ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE project_health_snapshots ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE project_activities ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- RLS Policies: Users can only access their own projects
|
||||||
|
CREATE POLICY "Users can view their own projects" ON projects
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can create their own projects" ON projects
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update their own projects" ON projects
|
||||||
|
FOR UPDATE USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can delete their own projects" ON projects
|
||||||
|
FOR DELETE USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- RLS for project_phases
|
||||||
|
CREATE POLICY "Users can manage phases of their projects" ON project_phases
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM projects WHERE projects.id = project_phases.project_id AND projects.user_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS for backlog_items
|
||||||
|
CREATE POLICY "Users can manage backlog of their projects" ON backlog_items
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM projects WHERE projects.id = backlog_items.project_id AND projects.user_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS for ai_recommendations
|
||||||
|
CREATE POLICY "Users can view recommendations for their projects" ON ai_recommendations
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM projects WHERE projects.id = ai_recommendations.project_id AND projects.user_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS for project_health_snapshots
|
||||||
|
CREATE POLICY "Users can view health of their projects" ON project_health_snapshots
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM projects WHERE projects.id = project_health_snapshots.project_id AND projects.user_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS for project_activities
|
||||||
|
CREATE POLICY "Users can view activities of their projects" ON project_activities
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM projects WHERE projects.id = project_activities.project_id AND projects.user_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Functions
|
||||||
|
|
||||||
|
-- Update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Triggers for updated_at
|
||||||
|
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER project_phases_updated_at BEFORE UPDATE ON project_phases
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER backlog_items_updated_at BEFORE UPDATE ON backlog_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
-- Function to calculate project health
|
||||||
|
CREATE OR REPLACE FUNCTION calculate_project_health(p_project_id UUID)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
total_items INTEGER;
|
||||||
|
done_items INTEGER;
|
||||||
|
blocked_items INTEGER;
|
||||||
|
overdue_items INTEGER;
|
||||||
|
health INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*),
|
||||||
|
COUNT(*) FILTER (WHERE status = 'done'),
|
||||||
|
COUNT(*) FILTER (WHERE status = 'blocked'),
|
||||||
|
COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status NOT IN ('done', 'blocked'))
|
||||||
|
INTO total_items, done_items, blocked_items, overdue_items
|
||||||
|
FROM backlog_items WHERE project_id = p_project_id;
|
||||||
|
|
||||||
|
IF total_items = 0 THEN
|
||||||
|
RETURN 100;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Health calculation: base 100, minus penalties
|
||||||
|
health := 100;
|
||||||
|
health := health - (blocked_items * 10); -- -10 per blocker
|
||||||
|
health := health - (overdue_items * 5); -- -5 per overdue
|
||||||
|
health := health + (done_items * 2); -- +2 per completed
|
||||||
|
|
||||||
|
RETURN GREATEST(0, LEAST(100, health));
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to record daily health snapshot
|
||||||
|
CREATE OR REPLACE FUNCTION record_health_snapshot(p_project_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_health INTEGER;
|
||||||
|
v_velocity INTEGER;
|
||||||
|
v_blockers INTEGER;
|
||||||
|
v_overdue INTEGER;
|
||||||
|
v_completion_rate INTEGER;
|
||||||
|
v_total INTEGER;
|
||||||
|
v_done INTEGER;
|
||||||
|
BEGIN
|
||||||
|
v_health := calculate_project_health(p_project_id);
|
||||||
|
|
||||||
|
SELECT COUNT(*) FILTER (WHERE status = 'blocked'),
|
||||||
|
COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status NOT IN ('done', 'blocked')),
|
||||||
|
COUNT(*),
|
||||||
|
COUNT(*) FILTER (WHERE status = 'done')
|
||||||
|
INTO v_blockers, v_overdue, v_total, v_done
|
||||||
|
FROM backlog_items WHERE project_id = p_project_id;
|
||||||
|
|
||||||
|
v_completion_rate := CASE WHEN v_total > 0 THEN (v_done * 100 / v_total) ELSE 0 END;
|
||||||
|
|
||||||
|
-- Velocity: items completed in last 7 days
|
||||||
|
SELECT COUNT(*) INTO v_velocity
|
||||||
|
FROM backlog_items
|
||||||
|
WHERE project_id = p_project_id
|
||||||
|
AND status = 'done'
|
||||||
|
AND updated_at >= NOW() - INTERVAL '7 days';
|
||||||
|
|
||||||
|
INSERT INTO project_health_snapshots (project_id, overall_score, velocity, blockers, overdue, completion_rate)
|
||||||
|
VALUES (p_project_id, v_health, v_velocity, v_blockers, v_overdue, v_completion_rate)
|
||||||
|
ON CONFLICT (project_id, snapshot_date)
|
||||||
|
DO UPDATE SET
|
||||||
|
overall_score = EXCLUDED.overall_score,
|
||||||
|
velocity = EXCLUDED.velocity,
|
||||||
|
blockers = EXCLUDED.blockers,
|
||||||
|
overdue = EXCLUDED.overdue,
|
||||||
|
completion_rate = EXCLUDED.completion_rate;
|
||||||
|
|
||||||
|
-- Update project health_score
|
||||||
|
UPDATE projects SET health_score = v_health WHERE id = p_project_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Comments for documentation
|
||||||
|
COMMENT ON TABLE projects IS 'User projects with design thinking phases';
|
||||||
|
COMMENT ON TABLE project_phases IS 'Track status of each design phase per project iteration';
|
||||||
|
COMMENT ON TABLE backlog_items IS 'Prioritized backlog with WSJF scoring';
|
||||||
|
COMMENT ON COLUMN backlog_items.priority_score IS 'WSJF: (user_value + time_criticality + risk_reduction) / effort';
|
||||||
|
COMMENT ON TABLE ai_recommendations IS 'AI-generated insights and action recommendations';
|
||||||
|
COMMENT ON TABLE project_health_snapshots IS 'Daily snapshots of project health metrics';
|
||||||
262
supabase/migrations/20251214_profiles_and_auth.sql
Normal file
262
supabase/migrations/20251214_profiles_and_auth.sql
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
-- Profiles and Auth Triggers for Mylder Platform
|
||||||
|
-- Migration: 20251214_profiles_and_auth.sql
|
||||||
|
|
||||||
|
-- Enable UUID extension (if not already enabled)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PROFILES TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
full_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for email lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_email ON profiles(email);
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- RLS Policies for profiles
|
||||||
|
CREATE POLICY "Users can view their own profile" ON profiles
|
||||||
|
FOR SELECT USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update their own profile" ON profiles
|
||||||
|
FOR UPDATE USING (auth.uid() = id);
|
||||||
|
|
||||||
|
-- Service role can manage all profiles (for admin operations)
|
||||||
|
CREATE POLICY "Service role can manage all profiles" ON profiles
|
||||||
|
FOR ALL USING (
|
||||||
|
auth.jwt() ->> 'role' = 'service_role'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TEAMS TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_teams_owner_id ON teams(owner_id);
|
||||||
|
|
||||||
|
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Team owners can manage their teams" ON teams
|
||||||
|
FOR ALL USING (auth.uid() = owner_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TEAM MEMBERS TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS team_members (
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (team_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id);
|
||||||
|
|
||||||
|
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Team members can view their memberships" ON team_members
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Team owners can manage memberships" ON team_members
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM teams WHERE teams.id = team_members.team_id AND teams.owner_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SUBSCRIPTIONS TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
stripe_subscription_id TEXT,
|
||||||
|
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'team')),
|
||||||
|
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'canceled', 'past_due', 'incomplete')),
|
||||||
|
current_period_start TIMESTAMPTZ,
|
||||||
|
current_period_end TIMESTAMPTZ,
|
||||||
|
cancel_at_period_end BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer_id ON subscriptions(stripe_customer_id);
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view their own subscriptions" ON subscriptions
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- MESSAGES TABLE (for chat history)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_project_id ON messages(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can manage messages in their projects" ON messages
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM projects WHERE projects.id = messages.project_id AND projects.user_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- AGENT RUNS TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
|
||||||
|
result JSONB,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_runs_project_id ON agent_runs(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
|
||||||
|
|
||||||
|
ALTER TABLE agent_runs ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view agent runs in their projects" ON agent_runs
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM projects WHERE projects.id = agent_runs.project_id AND projects.user_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ISSUES TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS issues (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
gitea_id INTEGER,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
state TEXT DEFAULT 'open' CHECK (state IN ('open', 'closed')),
|
||||||
|
labels TEXT[] DEFAULT '{}',
|
||||||
|
assignee TEXT,
|
||||||
|
milestone TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_project_id ON issues(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_state ON issues(state);
|
||||||
|
|
||||||
|
ALTER TABLE issues ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can manage issues in their projects" ON issues
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM projects WHERE projects.id = issues.project_id AND projects.user_id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TRIGGER: Auto-create profile on user signup
|
||||||
|
-- ============================================
|
||||||
|
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.profiles (id, email, full_name, avatar_url, role)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
NEW.email,
|
||||||
|
COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name', split_part(NEW.email, '@', 1)),
|
||||||
|
COALESCE(NEW.raw_user_meta_data->>'avatar_url', NEW.raw_user_meta_data->>'picture'),
|
||||||
|
'user'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Also create a default free subscription
|
||||||
|
INSERT INTO public.subscriptions (user_id, plan, status)
|
||||||
|
VALUES (NEW.id, 'free', 'active');
|
||||||
|
|
||||||
|
-- Create a default personal team
|
||||||
|
INSERT INTO public.teams (name, owner_id, plan)
|
||||||
|
VALUES (
|
||||||
|
COALESCE(NEW.raw_user_meta_data->>'full_name', split_part(NEW.email, '@', 1)) || '''s Team',
|
||||||
|
NEW.id,
|
||||||
|
'free'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add user as owner of their team
|
||||||
|
INSERT INTO public.team_members (team_id, user_id, role)
|
||||||
|
SELECT id, NEW.id, 'owner' FROM public.teams WHERE owner_id = NEW.id LIMIT 1;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Create the trigger on auth.users
|
||||||
|
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TRIGGER: Update updated_at timestamp
|
||||||
|
-- ============================================
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Add updated_at triggers
|
||||||
|
DROP TRIGGER IF EXISTS profiles_updated_at ON profiles;
|
||||||
|
CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS teams_updated_at ON teams;
|
||||||
|
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS subscriptions_updated_at ON subscriptions;
|
||||||
|
CREATE TRIGGER subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS issues_updated_at ON issues;
|
||||||
|
CREATE TRIGGER issues_updated_at BEFORE UPDATE ON issues
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- COMMENTS
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE profiles IS 'User profiles linked to auth.users';
|
||||||
|
COMMENT ON FUNCTION handle_new_user() IS 'Auto-creates profile, subscription, and personal team on user signup';
|
||||||
|
COMMENT ON TABLE teams IS 'Teams for multi-user collaboration';
|
||||||
|
COMMENT ON TABLE team_members IS 'Team membership with roles';
|
||||||
|
COMMENT ON TABLE subscriptions IS 'User subscription status for billing';
|
||||||
|
COMMENT ON TABLE messages IS 'Chat messages in project context';
|
||||||
|
COMMENT ON TABLE agent_runs IS 'AI agent execution history';
|
||||||
|
COMMENT ON TABLE issues IS 'Project issues synced with Gitea';
|
||||||
Reference in New Issue
Block a user