Dashboard shadcn-vue sidebar + i18n + NavProjects conectado a KAPPA API
- Dashboard-01 block de shadcn-vue instalado (sidebar con tabs) - vue-i18n para traducciones ES/EN (detecta idioma del navegador) - NavProjects ahora usa initiative_name de KAPPA API - Dashboard stats conectados a API (HUs, sesiones, planeaciones) - Work items table con datos reales de KAPPA - Login: toggle password con icono de ojo - Toggle theme restaurado en SiteHeader - i18n con locale/en.json y locale/es.json -Nuevos componentes: NavMain, NavDocuments, NavSecondary en dashboard/ - NavUser原来的 - NavUser原来的
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"name": "kappa-hub",
|
"name": "kappa-hub",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/vue": "^1.16.0",
|
"@lucide/vue": "^1.16.0",
|
||||||
|
"@tabler/icons-vue": "^3.44.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^11.4.4",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -175,6 +177,14 @@
|
|||||||
|
|
||||||
"@internationalized/number": ["@internationalized/number@3.6.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ=="],
|
"@internationalized/number": ["@internationalized/number@3.6.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ=="],
|
||||||
|
|
||||||
|
"@intlify/core-base": ["@intlify/core-base@11.4.4", "", { "dependencies": { "@intlify/devtools-types": "11.4.4", "@intlify/message-compiler": "11.4.4", "@intlify/shared": "11.4.4" } }, "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg=="],
|
||||||
|
|
||||||
|
"@intlify/devtools-types": ["@intlify/devtools-types@11.4.4", "", { "dependencies": { "@intlify/core-base": "11.4.4", "@intlify/shared": "11.4.4" } }, "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q=="],
|
||||||
|
|
||||||
|
"@intlify/message-compiler": ["@intlify/message-compiler@11.4.4", "", { "dependencies": { "@intlify/shared": "11.4.4", "source-map-js": "^1.0.2" } }, "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w=="],
|
||||||
|
|
||||||
|
"@intlify/shared": ["@intlify/shared@11.4.4", "", {}, "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g=="],
|
||||||
|
|
||||||
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
@@ -255,6 +265,10 @@
|
|||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="],
|
"@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="],
|
||||||
|
|
||||||
|
"@tabler/icons": ["@tabler/icons@3.44.0", "", {}, "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA=="],
|
||||||
|
|
||||||
|
"@tabler/icons-vue": ["@tabler/icons-vue@3.44.0", "", { "dependencies": { "@tabler/icons": "3.44.0" }, "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-mABxdhq3SWo2ZI77w/t0reiOGNim/SEDSlfMT5PeiWA3cZwnZoQUYRiq/X6SgeTaA7LzCTX0IuvQWVf4RWOvsg=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
||||||
@@ -1041,6 +1055,8 @@
|
|||||||
|
|
||||||
"vue-eslint-parser": ["vue-eslint-parser@10.4.0", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg=="],
|
"vue-eslint-parser": ["vue-eslint-parser@10.4.0", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg=="],
|
||||||
|
|
||||||
|
"vue-i18n": ["vue-i18n@11.4.4", "", { "dependencies": { "@intlify/core-base": "11.4.4", "@intlify/devtools-types": "11.4.4", "@intlify/shared": "11.4.4", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A=="],
|
||||||
|
|
||||||
"vue-metamorph": ["vue-metamorph@3.3.4", "", { "dependencies": { "@babel/parser": "8.0.0-alpha.12", "ast-types-x": "1.18.0", "chalk": "^5.3.0", "cli-progress": "^3.12.0", "commander": "^14.0.0", "deep-diff": "^1.0.2", "fs-extra": "^11.2.0", "glob": "^11.0.0", "lodash-es": "^4.17.21", "magic-string": "^0.30.10", "micromatch": "^4.0.8", "node-html-parser": "^7.0.1", "postcss": "^8.4.38", "postcss-less": "^6.0.0", "postcss-sass": "^0.5.0", "postcss-scss": "^4.0.9", "postcss-styl": "^0.12.3", "recast-x": "1.0.5", "table": "^6.8.2", "vue-eslint-parser": "^10.1.0" }, "bin": { "vue-metamorph": "scripts/scaffold.js" } }, "sha512-WZ1xzHrmYh9UiZ7OC9eG1ASzgSybEB10jhop+k5KzMY9I1JmRKdreqUYzbV3hOnOMvLhyDn7y6f62mLE2jHFSg=="],
|
"vue-metamorph": ["vue-metamorph@3.3.4", "", { "dependencies": { "@babel/parser": "8.0.0-alpha.12", "ast-types-x": "1.18.0", "chalk": "^5.3.0", "cli-progress": "^3.12.0", "commander": "^14.0.0", "deep-diff": "^1.0.2", "fs-extra": "^11.2.0", "glob": "^11.0.0", "lodash-es": "^4.17.21", "magic-string": "^0.30.10", "micromatch": "^4.0.8", "node-html-parser": "^7.0.1", "postcss": "^8.4.38", "postcss-less": "^6.0.0", "postcss-sass": "^0.5.0", "postcss-scss": "^4.0.9", "postcss-styl": "^0.12.3", "recast-x": "1.0.5", "table": "^6.8.2", "vue-eslint-parser": "^10.1.0" }, "bin": { "vue-metamorph": "scripts/scaffold.js" } }, "sha512-WZ1xzHrmYh9UiZ7OC9eG1ASzgSybEB10jhop+k5KzMY9I1JmRKdreqUYzbV3hOnOMvLhyDn7y6f62mLE2jHFSg=="],
|
||||||
|
|
||||||
"vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
"vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/vue": "^1.16.0",
|
"@lucide/vue": "^1.16.0",
|
||||||
|
"@tabler/icons-vue": "^3.44.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^11.4.4",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+2
-61
@@ -2,27 +2,12 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import AppSidebar from '@/components/AppSidebar.vue'
|
|
||||||
import LoginView from '@/views/LoginView.vue'
|
import LoginView from '@/views/LoginView.vue'
|
||||||
import DashboardView from '@/views/DashboardView.vue'
|
import NewDashboardView from '@/views/NewDashboardView.vue'
|
||||||
import CalendarView from '@/views/CalendarView.vue'
|
|
||||||
import SchedulerView from '@/views/SchedulerView.vue'
|
|
||||||
import { isDark, toggleTheme } from '@/composables/useTheme'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
|
|
||||||
const activeTab = ref('dashboard')
|
|
||||||
|
|
||||||
const tabTitles: Record<string, string> = {
|
|
||||||
dashboard: 'Diagnóstico',
|
|
||||||
calendar: 'Calendario',
|
|
||||||
scheduler: 'Recetas',
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
projectsStore.fetchProjects()
|
projectsStore.fetchProjects()
|
||||||
@@ -32,49 +17,5 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoginView v-if="!auth.isAuthenticated" />
|
<LoginView v-if="!auth.isAuthenticated" />
|
||||||
|
<NewDashboardView v-else />
|
||||||
<SidebarProvider v-else :style="{
|
|
||||||
'--sidebar-width': '16rem',
|
|
||||||
'--header-height': '3rem',
|
|
||||||
}">
|
|
||||||
<AppSidebar v-model:active-tab="activeTab" />
|
|
||||||
|
|
||||||
<SidebarInset>
|
|
||||||
<header class="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
|
||||||
<div class="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
|
||||||
<SidebarTrigger class="-ml-1" />
|
|
||||||
<Separator
|
|
||||||
orientation="vertical"
|
|
||||||
class="mx-2 data-[orientation=vertical]:h-4"
|
|
||||||
/>
|
|
||||||
<h1 class="text-base font-medium">
|
|
||||||
{{ tabTitles[activeTab] }}
|
|
||||||
</h1>
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="icon" class="size-8" @click="toggleTheme()">
|
|
||||||
<svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-[18px]">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
|
||||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
|
||||||
<path d="M12 3l0 18" />
|
|
||||||
<path d="M12 9l4.65 -4.65" />
|
|
||||||
<path d="M12 14.3l7.37 -7.37" />
|
|
||||||
<path d="M12 19.6l8.85 -8.85" />
|
|
||||||
</svg>
|
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-[18px]">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
|
||||||
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" />
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Toggle theme</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex flex-1 flex-col p-4 pt-0 md:gap-6 md:py-6">
|
|
||||||
<DashboardView v-if="activeTab === 'dashboard'" />
|
|
||||||
<CalendarView v-else-if="activeTab === 'calendar'" />
|
|
||||||
<SchedulerView v-else-if="activeTab === 'scheduler'" />
|
|
||||||
</main>
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { Folder } from 'lucide-vue-next'
|
import { Folder } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
@@ -15,16 +14,20 @@ const projects = useProjectsStore()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Pacientes ({{ projects.count }})</SidebarGroupLabel>
|
<SidebarGroupLabel>Proyectos ({{ projects.count }})</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem v-for="p in projects.projects" :key="p.id">
|
<SidebarMenuItem v-for="p in projects.projects" :key="p.id">
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
:is-active="projects.selectedId === p.id"
|
:is-active="projects.selectedId === p.id"
|
||||||
:tooltip="p.name"
|
:tooltip="p.initiative_name || `Proyecto ${p.id}`"
|
||||||
|
class="h-auto py-2"
|
||||||
@click="projects.select(p.id)"
|
@click="projects.select(p.id)"
|
||||||
>
|
>
|
||||||
<Folder class="size-4" />
|
<Folder class="size-4 shrink-0" />
|
||||||
<span class="truncate">{{ p.name }}</span>
|
<div class="grid flex-1 text-left text-sm leading-tight gap-0.5">
|
||||||
|
<span class="truncate font-medium text-[13px]">{{ p.initiative_name || `Proyecto ${p.id}` }}</span>
|
||||||
|
<span class="truncate text-[11px] text-muted-foreground">Proyecto {{ p.id }}</span>
|
||||||
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
IconChartBar,
|
||||||
|
IconDashboard,
|
||||||
|
IconDatabase,
|
||||||
|
IconFileAi,
|
||||||
|
IconFileDescription,
|
||||||
|
IconFolder,
|
||||||
|
IconListDetails,
|
||||||
|
IconReport,
|
||||||
|
IconSettings,
|
||||||
|
IconUsers,
|
||||||
|
IconInnerShadowTop,
|
||||||
|
} from "@tabler/icons-vue"
|
||||||
|
|
||||||
|
import NavDocuments from "@/components/dashboard/NavDocuments.vue"
|
||||||
|
import NavMain from "@/components/dashboard/NavMain.vue"
|
||||||
|
import NavSecondary from "@/components/dashboard/NavSecondary.vue"
|
||||||
|
import NavUser from "@/components/NavUser.vue"
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Sidebar collapsible="offcanvas">
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
as-child
|
||||||
|
class="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||||
|
>
|
||||||
|
<a href="#">
|
||||||
|
<IconInnerShadowTop class="!size-5" />
|
||||||
|
<span class="text-base font-semibold">KAPPA Hub</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<NavMain />
|
||||||
|
<NavDocuments />
|
||||||
|
<NavSecondary :items="[]" class="mt-auto" />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartConfig } from "@/registry/new-york-v4/ui/chart"
|
||||||
|
|
||||||
|
// import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||||
|
import { VisArea, VisAxis, VisLine, VisXYContainer } from "@unovis/vue"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/registry/new-york-v4/ui/card"
|
||||||
|
import {
|
||||||
|
|
||||||
|
ChartContainer,
|
||||||
|
ChartCrosshair,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
componentToString,
|
||||||
|
} from "@/registry/new-york-v4/ui/chart"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/registry/new-york-v4/ui/select"
|
||||||
|
|
||||||
|
const description = "An interactive area chart"
|
||||||
|
|
||||||
|
const chartData = [
|
||||||
|
{ date: new Date("2024-04-01"), desktop: 222, mobile: 150 },
|
||||||
|
{ date: new Date("2024-04-02"), desktop: 97, mobile: 180 },
|
||||||
|
{ date: new Date("2024-04-03"), desktop: 167, mobile: 120 },
|
||||||
|
{ date: new Date("2024-04-04"), desktop: 242, mobile: 260 },
|
||||||
|
{ date: new Date("2024-04-05"), desktop: 373, mobile: 290 },
|
||||||
|
{ date: new Date("2024-04-06"), desktop: 301, mobile: 340 },
|
||||||
|
{ date: new Date("2024-04-07"), desktop: 245, mobile: 180 },
|
||||||
|
{ date: new Date("2024-04-08"), desktop: 409, mobile: 320 },
|
||||||
|
{ date: new Date("2024-04-09"), desktop: 59, mobile: 110 },
|
||||||
|
{ date: new Date("2024-04-10"), desktop: 261, mobile: 190 },
|
||||||
|
{ date: new Date("2024-04-11"), desktop: 327, mobile: 350 },
|
||||||
|
{ date: new Date("2024-04-12"), desktop: 292, mobile: 210 },
|
||||||
|
{ date: new Date("2024-04-13"), desktop: 342, mobile: 380 },
|
||||||
|
{ date: new Date("2024-04-14"), desktop: 137, mobile: 220 },
|
||||||
|
{ date: new Date("2024-04-15"), desktop: 120, mobile: 170 },
|
||||||
|
{ date: new Date("2024-04-16"), desktop: 138, mobile: 190 },
|
||||||
|
{ date: new Date("2024-04-17"), desktop: 446, mobile: 360 },
|
||||||
|
{ date: new Date("2024-04-18"), desktop: 364, mobile: 410 },
|
||||||
|
{ date: new Date("2024-04-19"), desktop: 243, mobile: 180 },
|
||||||
|
{ date: new Date("2024-04-20"), desktop: 89, mobile: 150 },
|
||||||
|
{ date: new Date("2024-04-21"), desktop: 137, mobile: 200 },
|
||||||
|
{ date: new Date("2024-04-22"), desktop: 224, mobile: 170 },
|
||||||
|
{ date: new Date("2024-04-23"), desktop: 138, mobile: 230 },
|
||||||
|
{ date: new Date("2024-04-24"), desktop: 387, mobile: 290 },
|
||||||
|
{ date: new Date("2024-04-25"), desktop: 215, mobile: 250 },
|
||||||
|
{ date: new Date("2024-04-26"), desktop: 75, mobile: 130 },
|
||||||
|
{ date: new Date("2024-04-27"), desktop: 383, mobile: 420 },
|
||||||
|
{ date: new Date("2024-04-28"), desktop: 122, mobile: 180 },
|
||||||
|
{ date: new Date("2024-04-29"), desktop: 315, mobile: 240 },
|
||||||
|
{ date: new Date("2024-04-30"), desktop: 454, mobile: 380 },
|
||||||
|
{ date: new Date("2024-05-01"), desktop: 165, mobile: 220 },
|
||||||
|
{ date: new Date("2024-05-02"), desktop: 293, mobile: 310 },
|
||||||
|
{ date: new Date("2024-05-03"), desktop: 247, mobile: 190 },
|
||||||
|
{ date: new Date("2024-05-04"), desktop: 385, mobile: 420 },
|
||||||
|
{ date: new Date("2024-05-05"), desktop: 481, mobile: 390 },
|
||||||
|
{ date: new Date("2024-05-06"), desktop: 498, mobile: 520 },
|
||||||
|
{ date: new Date("2024-05-07"), desktop: 388, mobile: 300 },
|
||||||
|
{ date: new Date("2024-05-08"), desktop: 149, mobile: 210 },
|
||||||
|
{ date: new Date("2024-05-09"), desktop: 227, mobile: 180 },
|
||||||
|
{ date: new Date("2024-05-10"), desktop: 293, mobile: 330 },
|
||||||
|
{ date: new Date("2024-05-11"), desktop: 335, mobile: 270 },
|
||||||
|
{ date: new Date("2024-05-12"), desktop: 197, mobile: 240 },
|
||||||
|
{ date: new Date("2024-05-13"), desktop: 197, mobile: 160 },
|
||||||
|
{ date: new Date("2024-05-14"), desktop: 448, mobile: 490 },
|
||||||
|
{ date: new Date("2024-05-15"), desktop: 473, mobile: 380 },
|
||||||
|
{ date: new Date("2024-05-16"), desktop: 338, mobile: 400 },
|
||||||
|
{ date: new Date("2024-05-17"), desktop: 499, mobile: 420 },
|
||||||
|
{ date: new Date("2024-05-18"), desktop: 315, mobile: 350 },
|
||||||
|
{ date: new Date("2024-05-19"), desktop: 235, mobile: 180 },
|
||||||
|
{ date: new Date("2024-05-20"), desktop: 177, mobile: 230 },
|
||||||
|
{ date: new Date("2024-05-21"), desktop: 82, mobile: 140 },
|
||||||
|
{ date: new Date("2024-05-22"), desktop: 81, mobile: 120 },
|
||||||
|
{ date: new Date("2024-05-23"), desktop: 252, mobile: 290 },
|
||||||
|
{ date: new Date("2024-05-24"), desktop: 294, mobile: 220 },
|
||||||
|
{ date: new Date("2024-05-25"), desktop: 201, mobile: 250 },
|
||||||
|
{ date: new Date("2024-05-26"), desktop: 213, mobile: 170 },
|
||||||
|
{ date: new Date("2024-05-27"), desktop: 420, mobile: 460 },
|
||||||
|
{ date: new Date("2024-05-28"), desktop: 233, mobile: 190 },
|
||||||
|
{ date: new Date("2024-05-29"), desktop: 78, mobile: 130 },
|
||||||
|
{ date: new Date("2024-05-30"), desktop: 340, mobile: 280 },
|
||||||
|
{ date: new Date("2024-05-31"), desktop: 178, mobile: 230 },
|
||||||
|
{ date: new Date("2024-06-01"), desktop: 178, mobile: 200 },
|
||||||
|
{ date: new Date("2024-06-02"), desktop: 470, mobile: 410 },
|
||||||
|
{ date: new Date("2024-06-03"), desktop: 103, mobile: 160 },
|
||||||
|
{ date: new Date("2024-06-04"), desktop: 439, mobile: 380 },
|
||||||
|
{ date: new Date("2024-06-05"), desktop: 88, mobile: 140 },
|
||||||
|
{ date: new Date("2024-06-06"), desktop: 294, mobile: 250 },
|
||||||
|
{ date: new Date("2024-06-07"), desktop: 323, mobile: 370 },
|
||||||
|
{ date: new Date("2024-06-08"), desktop: 385, mobile: 320 },
|
||||||
|
{ date: new Date("2024-06-09"), desktop: 438, mobile: 480 },
|
||||||
|
{ date: new Date("2024-06-10"), desktop: 155, mobile: 200 },
|
||||||
|
{ date: new Date("2024-06-11"), desktop: 92, mobile: 150 },
|
||||||
|
{ date: new Date("2024-06-12"), desktop: 492, mobile: 420 },
|
||||||
|
{ date: new Date("2024-06-13"), desktop: 81, mobile: 130 },
|
||||||
|
{ date: new Date("2024-06-14"), desktop: 426, mobile: 380 },
|
||||||
|
{ date: new Date("2024-06-15"), desktop: 307, mobile: 350 },
|
||||||
|
{ date: new Date("2024-06-16"), desktop: 371, mobile: 310 },
|
||||||
|
{ date: new Date("2024-06-17"), desktop: 475, mobile: 520 },
|
||||||
|
{ date: new Date("2024-06-18"), desktop: 107, mobile: 170 },
|
||||||
|
{ date: new Date("2024-06-19"), desktop: 341, mobile: 290 },
|
||||||
|
{ date: new Date("2024-06-20"), desktop: 408, mobile: 450 },
|
||||||
|
{ date: new Date("2024-06-21"), desktop: 169, mobile: 210 },
|
||||||
|
{ date: new Date("2024-06-22"), desktop: 317, mobile: 270 },
|
||||||
|
{ date: new Date("2024-06-23"), desktop: 480, mobile: 530 },
|
||||||
|
{ date: new Date("2024-06-24"), desktop: 132, mobile: 180 },
|
||||||
|
{ date: new Date("2024-06-25"), desktop: 141, mobile: 190 },
|
||||||
|
{ date: new Date("2024-06-26"), desktop: 434, mobile: 380 },
|
||||||
|
{ date: new Date("2024-06-27"), desktop: 448, mobile: 490 },
|
||||||
|
{ date: new Date("2024-06-28"), desktop: 149, mobile: 200 },
|
||||||
|
{ date: new Date("2024-06-29"), desktop: 103, mobile: 160 },
|
||||||
|
{ date: new Date("2024-06-30"), desktop: 446, mobile: 400 },
|
||||||
|
]
|
||||||
|
type Data = typeof chartData[number]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
// visitors: {
|
||||||
|
// label: 'Visitors',
|
||||||
|
// },
|
||||||
|
mobile: {
|
||||||
|
label: "Mobile",
|
||||||
|
color: "var(--primary)",
|
||||||
|
},
|
||||||
|
desktop: {
|
||||||
|
label: "Desktop",
|
||||||
|
color: "var(--primary)",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const svgDefs = `
|
||||||
|
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stop-color="var(--color-desktop)"
|
||||||
|
stop-opacity="0.8"
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stop-color="var(--color-desktop)"
|
||||||
|
stop-opacity="0.1"
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stop-color="var(--color-mobile)"
|
||||||
|
stop-opacity="0.8"
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stop-color="var(--color-mobile)"
|
||||||
|
stop-opacity="0.1"
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
`
|
||||||
|
|
||||||
|
const timeRange = ref("90d")
|
||||||
|
const filterRange = computed(() => {
|
||||||
|
return chartData.filter((item) => {
|
||||||
|
const date = new Date(item.date)
|
||||||
|
const referenceDate = new Date("2024-06-30")
|
||||||
|
let daysToSubtract = 90
|
||||||
|
if (timeRange.value === "30d") {
|
||||||
|
daysToSubtract = 30
|
||||||
|
}
|
||||||
|
else if (timeRange.value === "7d") {
|
||||||
|
daysToSubtract = 7
|
||||||
|
}
|
||||||
|
const startDate = new Date(referenceDate)
|
||||||
|
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||||
|
return date >= startDate
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="pt-0">
|
||||||
|
<CardHeader class="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
|
||||||
|
<div class="grid flex-1 gap-1">
|
||||||
|
<CardTitle>Area Chart - Interactive</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Showing total visitors for the last 3 months
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Select v-model="timeRange">
|
||||||
|
<SelectTrigger
|
||||||
|
class="hidden w-[160px] rounded-lg sm:ml-auto sm:flex"
|
||||||
|
aria-label="Select a value"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Last 3 months" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="rounded-xl">
|
||||||
|
<SelectItem value="90d" class="rounded-lg">
|
||||||
|
Last 3 months
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="30d" class="rounded-lg">
|
||||||
|
Last 30 days
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="7d" class="rounded-lg">
|
||||||
|
Last 7 days
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="px-2 pt-4 sm:px-6 sm:pt-6 pb-4">
|
||||||
|
<ChartContainer :config="chartConfig" class="aspect-auto h-[250px] w-full" :cursor="false">
|
||||||
|
<VisXYContainer
|
||||||
|
:data="filterRange"
|
||||||
|
:svg-defs="svgDefs"
|
||||||
|
:margin="{ left: -40 }"
|
||||||
|
:y-domain="[0, 1200]"
|
||||||
|
>
|
||||||
|
<VisArea
|
||||||
|
:x="(d: Data) => d.date"
|
||||||
|
:y="[(d: Data) => d.mobile, (d: Data) => d.desktop]"
|
||||||
|
:color="(d: Data, i: number) => ['url(#fillMobile)', 'url(#fillDesktop)'][i]"
|
||||||
|
:opacity="0.6"
|
||||||
|
/>
|
||||||
|
<VisLine
|
||||||
|
:x="(d: Data) => d.date"
|
||||||
|
:y="[(d: Data) => d.mobile, (d: Data) => d.mobile + d.desktop]"
|
||||||
|
:color="(d: Data, i: number) => [chartConfig.mobile.color, chartConfig.desktop.color][i]"
|
||||||
|
:line-width="1"
|
||||||
|
/>
|
||||||
|
<VisAxis
|
||||||
|
type="x"
|
||||||
|
:x="(d: Data) => d.date"
|
||||||
|
:tick-line="false"
|
||||||
|
:domain-line="false"
|
||||||
|
:grid-line="false"
|
||||||
|
:num-ticks="6"
|
||||||
|
:tick-format="(d: number, index: number) => {
|
||||||
|
const date = new Date(d)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<VisAxis
|
||||||
|
type="y"
|
||||||
|
:num-ticks="3"
|
||||||
|
:tick-line="false"
|
||||||
|
:domain-line="false"
|
||||||
|
/>
|
||||||
|
<ChartTooltip />
|
||||||
|
<ChartCrosshair
|
||||||
|
:template="componentToString(chartConfig, ChartTooltipContent, {
|
||||||
|
labelFormatter: (d) => {
|
||||||
|
return new Date(d).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})"
|
||||||
|
:color="(d: Data, i: number) => [chartConfig.mobile.color, chartConfig.desktop.color][i % 2]"
|
||||||
|
/>
|
||||||
|
</VisXYContainer>
|
||||||
|
|
||||||
|
<ChartLegendContent />
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { z } from "zod"
|
||||||
|
import DraggableRow from "./DraggableRow.vue"
|
||||||
|
import DragHandle from "./DragHandle.vue"
|
||||||
|
|
||||||
|
export const schema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
header: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
target: z.string(),
|
||||||
|
limit: z.string(),
|
||||||
|
reviewer: z.string(),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
SortingState,
|
||||||
|
VisibilityState,
|
||||||
|
} from "@tanstack/vue-table"
|
||||||
|
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
|
||||||
|
import {
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronLeft,
|
||||||
|
IconChevronRight,
|
||||||
|
IconChevronsLeft,
|
||||||
|
IconChevronsRight,
|
||||||
|
IconCircleCheckFilled,
|
||||||
|
IconDotsVertical,
|
||||||
|
IconLayoutColumns,
|
||||||
|
IconLoader,
|
||||||
|
IconPlus,
|
||||||
|
} from "@tabler/icons-vue"
|
||||||
|
import {
|
||||||
|
FlexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useVueTable,
|
||||||
|
} from "@tanstack/vue-table"
|
||||||
|
import { DragDropProvider } from "dnd-kit-vue"
|
||||||
|
import { Badge } from "@/registry/new-york-v4/ui/badge"
|
||||||
|
|
||||||
|
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||||
|
import { Checkbox } from "@/registry/new-york-v4/ui/checkbox"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/registry/new-york-v4/ui/dropdown-menu"
|
||||||
|
|
||||||
|
import { Label } from "@/registry/new-york-v4/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/registry/new-york-v4/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/registry/new-york-v4/ui/table"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/registry/new-york-v4/ui/tabs"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: TableData[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
id: number
|
||||||
|
header: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
target: string
|
||||||
|
limit: string
|
||||||
|
reviewer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorting = ref<SortingState>([])
|
||||||
|
const columnFilters = ref<ColumnFiltersState>([])
|
||||||
|
const columnVisibility = ref<VisibilityState>({})
|
||||||
|
const rowSelection = ref({})
|
||||||
|
|
||||||
|
const columns: ColumnDef<TableData>[] = [
|
||||||
|
{
|
||||||
|
id: "drag",
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => h(DragHandle),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => h(Checkbox, {
|
||||||
|
"modelValue": table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate"),
|
||||||
|
"onUpdate:modelValue": value => table.toggleAllPageRowsSelected(!!value),
|
||||||
|
"aria-label": "Select all",
|
||||||
|
}),
|
||||||
|
cell: ({ row }) => h(Checkbox, {
|
||||||
|
"modelValue": row.getIsSelected(),
|
||||||
|
"onUpdate:modelValue": value => row.toggleSelected(!!value),
|
||||||
|
"aria-label": "Select row",
|
||||||
|
}),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "header",
|
||||||
|
header: "Header",
|
||||||
|
cell: ({ row }) => h("div", String(row.getValue("header"))),
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "type",
|
||||||
|
header: "Section Type",
|
||||||
|
cell: ({ row }) => h(Badge, {
|
||||||
|
variant: "outline",
|
||||||
|
}, () => String(row.getValue("type"))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.getValue("status") as string
|
||||||
|
return h("div", { class: "flex items-center gap-2" }, [
|
||||||
|
status === "Done"
|
||||||
|
? h(IconCircleCheckFilled, { class: "h-4 w-4 text-emerald-500" })
|
||||||
|
: h(IconLoader, { class: "h-4 w-4 animate-spin text-muted-foreground" }),
|
||||||
|
h("span", {}, status),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "target",
|
||||||
|
header: () => h("div", { class: "flex items-center gap-1" }, [
|
||||||
|
"Target",
|
||||||
|
]),
|
||||||
|
cell: ({ row }) => h(Button, {
|
||||||
|
variant: "ghost",
|
||||||
|
size: "sm",
|
||||||
|
class: "h-auto p-1 text-xs font-mono",
|
||||||
|
}, () => [
|
||||||
|
h("span", { class: "ml-1 font-semibold" }, String(row.getValue("target"))),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "limit",
|
||||||
|
header: () => h("div", { class: "flex items-center gap-1" }, [
|
||||||
|
"Limit",
|
||||||
|
]),
|
||||||
|
cell: ({ row }) => h(Button, {
|
||||||
|
variant: "ghost",
|
||||||
|
size: "sm",
|
||||||
|
class: "h-auto p-1 text-xs font-mono",
|
||||||
|
}, () => [
|
||||||
|
h("span", { class: "ml-1 font-semibold" }, String(row.getValue("limit"))),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "reviewer",
|
||||||
|
header: "Reviewer",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const reviewer = row.getValue("reviewer") as string
|
||||||
|
const isAssigned = reviewer !== "Assign reviewer"
|
||||||
|
|
||||||
|
if (isAssigned) {
|
||||||
|
return h("span", {}, reviewer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(Select, {}, {
|
||||||
|
default: () => [
|
||||||
|
h(SelectTrigger, { class: "w-full" }, {
|
||||||
|
default: () => h(SelectValue, { placeholder: "Assign reviewer" }),
|
||||||
|
}),
|
||||||
|
h(SelectContent, {}, {
|
||||||
|
default: () => [
|
||||||
|
h(SelectItem, { value: "eddie" }, () => "Eddie Lake"),
|
||||||
|
h(SelectItem, { value: "jamik" }, () => "Jamik Tashpulatov"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: () => h(DropdownMenu, {}, {
|
||||||
|
default: () => [
|
||||||
|
h(DropdownMenuTrigger, { asChild: true }, {
|
||||||
|
default: () => h(Button, {
|
||||||
|
variant: "ghost",
|
||||||
|
class: "h-8 w-8 p-0",
|
||||||
|
}, {
|
||||||
|
default: () => [
|
||||||
|
h("span", { class: "sr-only" }, "Open menu"),
|
||||||
|
h(IconDotsVertical, { class: "h-4 w-4" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
h(DropdownMenuContent, { align: "end" }, {
|
||||||
|
default: () => [
|
||||||
|
h(DropdownMenuItem, {}, () => "Edit"),
|
||||||
|
h(DropdownMenuItem, {}, () => "Make a copy"),
|
||||||
|
h(DropdownMenuItem, {}, () => "Favorite"),
|
||||||
|
h(DropdownMenuSeparator, {}),
|
||||||
|
h(DropdownMenuItem, {}, () => "Delete"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return props.data
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: (updaterOrValue) => {
|
||||||
|
sorting.value = typeof updaterOrValue === "function"
|
||||||
|
? updaterOrValue(sorting.value)
|
||||||
|
: updaterOrValue
|
||||||
|
},
|
||||||
|
onColumnFiltersChange: (updaterOrValue) => {
|
||||||
|
columnFilters.value = typeof updaterOrValue === "function"
|
||||||
|
? updaterOrValue(columnFilters.value)
|
||||||
|
: updaterOrValue
|
||||||
|
},
|
||||||
|
onColumnVisibilityChange: (updaterOrValue) => {
|
||||||
|
columnVisibility.value = typeof updaterOrValue === "function"
|
||||||
|
? updaterOrValue(columnVisibility.value)
|
||||||
|
: updaterOrValue
|
||||||
|
},
|
||||||
|
onRowSelectionChange: (updaterOrValue) => {
|
||||||
|
rowSelection.value = typeof updaterOrValue === "function"
|
||||||
|
? updaterOrValue(rowSelection.value)
|
||||||
|
: updaterOrValue
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
get sorting() { return sorting.value },
|
||||||
|
get columnFilters() { return columnFilters.value },
|
||||||
|
get columnVisibility() { return columnVisibility.value },
|
||||||
|
get rowSelection() { return rowSelection.value },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Tabs
|
||||||
|
default-value="outline"
|
||||||
|
class="w-full flex-col justify-start gap-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between px-4 lg:px-6">
|
||||||
|
<Label for="view-selector" class="sr-only">
|
||||||
|
View
|
||||||
|
</Label>
|
||||||
|
<Select default-value="outline">
|
||||||
|
<SelectTrigger
|
||||||
|
id="view-selector"
|
||||||
|
class="flex w-fit @4xl/main:hidden"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select a view" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="outline">
|
||||||
|
Outline
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="past-performance">
|
||||||
|
Past Performance
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="key-personnel">
|
||||||
|
Key Personnel
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="focus-documents">
|
||||||
|
Focus Documents
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TabsList class="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
|
||||||
|
<TabsTrigger value="outline">
|
||||||
|
Outline
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="past-performance">
|
||||||
|
Past Performance <Badge variant="secondary">
|
||||||
|
3
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="key-personnel">
|
||||||
|
Key Personnel <Badge variant="secondary">
|
||||||
|
2
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="focus-documents">
|
||||||
|
Focus Documents
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<IconLayoutColumns />
|
||||||
|
<span class="hidden lg:inline">Customize Columns</span>
|
||||||
|
<span class="lg:hidden">Columns</span>
|
||||||
|
<IconChevronDown />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-56">
|
||||||
|
<template v-for="column in table.getAllColumns().filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())" :key="column.id">
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
class="capitalize"
|
||||||
|
:model-value="column.getIsVisible()"
|
||||||
|
@update:model-value="(value) => {
|
||||||
|
|
||||||
|
column.toggleVisibility(!!value)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ column.id }}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</template>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<IconPlus />
|
||||||
|
<span class="hidden lg:inline">Add Section</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TabsContent
|
||||||
|
value="outline"
|
||||||
|
class="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
|
||||||
|
>
|
||||||
|
<div class="overflow-hidden rounded-lg border">
|
||||||
|
<DragDropProvider :modifiers="[RestrictToVerticalAxis]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader class="bg-muted sticky top-0 z-10">
|
||||||
|
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||||
|
<TableHead v-for="header in headerGroup.headers" :key="header.id" :col-span="header.colSpan">
|
||||||
|
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody class="**:data-[slot=table-cell]:first:w-8">
|
||||||
|
<template v-if="table.getRowModel().rows.length">
|
||||||
|
<DraggableRow v-for="row in table.getRowModel().rows" :key="row.id" :row="row" :index="row.index" />
|
||||||
|
</template>
|
||||||
|
<TableRow v-else>
|
||||||
|
<TableCell
|
||||||
|
:col-span="columns.length"
|
||||||
|
class="h-24 text-center"
|
||||||
|
>
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DragDropProvider>
|
||||||
|
<!-- <DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
sensors={sensors}
|
||||||
|
id={sortableId}
|
||||||
|
> -->
|
||||||
|
<!-- </DndContext> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between px-4">
|
||||||
|
<div class="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||||
|
{{ table.getFilteredSelectedRowModel().rows.length }} of
|
||||||
|
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full items-center gap-8 lg:w-fit">
|
||||||
|
<div class="hidden items-center gap-2 lg:flex">
|
||||||
|
<Label for="rows-per-page" class="text-sm font-medium">
|
||||||
|
Rows per page
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="table.getState().pagination.pageSize"
|
||||||
|
@update:model-value="(value) => {
|
||||||
|
table.setPageSize(Number(value))
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<SelectTrigger id="rows-per-page" size="sm" class="w-20">
|
||||||
|
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
<SelectItem v-for="pageSize in [10, 20, 30, 40, 50]" :key="pageSize" :value="`${pageSize}`">
|
||||||
|
{{ pageSize }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-fit items-center justify-center text-sm font-medium">
|
||||||
|
Page {{ table.getState().pagination.pageIndex + 1 }} of
|
||||||
|
{{ table.getPageCount() }}
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-2 lg:ml-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
:disabled="!table.getCanPreviousPage()"
|
||||||
|
@click="table.setPageIndex(0)"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Go to first page</span>
|
||||||
|
<IconChevronsLeft />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="size-8"
|
||||||
|
size="icon"
|
||||||
|
:disabled="!table.getCanPreviousPage()"
|
||||||
|
@click="table.previousPage()"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Go to previous page</span>
|
||||||
|
<IconChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="size-8"
|
||||||
|
size="icon"
|
||||||
|
:disabled="!table.getCanNextPage()"
|
||||||
|
@click="table.nextPage()"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Go to next page</span>
|
||||||
|
<IconChevronRight />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="hidden size-8 lg:flex"
|
||||||
|
size="icon"
|
||||||
|
:disabled="!table.getCanNextPage()"
|
||||||
|
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Go to last page</span>
|
||||||
|
<IconChevronsRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="past-performance"
|
||||||
|
class="flex flex-col px-4 lg:px-6"
|
||||||
|
>
|
||||||
|
<div class="aspect-video w-full flex-1 rounded-lg border border-dashed" />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="key-personnel" class="flex flex-col px-4 lg:px-6">
|
||||||
|
<div class="aspect-video w-full flex-1 rounded-lg border border-dashed" />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="focus-documents"
|
||||||
|
class="flex flex-col px-4 lg:px-6"
|
||||||
|
>
|
||||||
|
<div class="aspect-video w-full flex-1 rounded-lg border border-dashed" />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { IconGripVertical } from "@tabler/icons-vue"
|
||||||
|
import { useSortableContext } from "dnd-kit-vue"
|
||||||
|
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||||
|
|
||||||
|
const { handleRef, sortable } = useSortableContext()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
:ref="handleRef"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-muted-foreground size-7 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<IconGripVertical class="text-muted-foreground size-3" />
|
||||||
|
<span class="sr-only">Drag to reorder</span>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Row } from "@tanstack/vue-table"
|
||||||
|
import type { z } from "zod"
|
||||||
|
import type { schema } from "./DataTable.vue"
|
||||||
|
import { FlexRender } from "@tanstack/vue-table"
|
||||||
|
import { useSortable } from "dnd-kit-vue"
|
||||||
|
import {
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
} from "@/registry/new-york-v4/ui/table"
|
||||||
|
|
||||||
|
const props = defineProps<{ row: Row<z.infer<typeof schema>>, index: number }>()
|
||||||
|
|
||||||
|
const { elementRef, isDragging } = useSortable({
|
||||||
|
id: props.row.original.id,
|
||||||
|
index: props.index,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableRow
|
||||||
|
:ref="elementRef"
|
||||||
|
:data-state="row.getIsSelected() && 'selected'"
|
||||||
|
:data-dragging="isDragging"
|
||||||
|
class="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
|
||||||
|
>
|
||||||
|
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||||
|
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from "vue"
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import {
|
||||||
|
IconDatabase,
|
||||||
|
IconFileDescription,
|
||||||
|
IconFileWord,
|
||||||
|
IconFolder,
|
||||||
|
IconReport,
|
||||||
|
} from "@tabler/icons-vue"
|
||||||
|
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const documentItems = [
|
||||||
|
{ name: 'nav.documents', icon: IconFolder, id: 'documents' },
|
||||||
|
{ name: 'nav.dataLibrary', icon: IconDatabase, id: 'data-library' },
|
||||||
|
{ name: 'nav.reports', icon: IconReport, id: 'reports' },
|
||||||
|
{ name: 'nav.wordAssistant', icon: IconFileWord, id: 'word-assistant' },
|
||||||
|
{ name: 'nav.templates', icon: IconFileDescription, id: 'templates' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
|
||||||
|
<SidebarGroupLabel>{{ t('nav.documents') }}</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem v-for="item in documentItems" :key="item.id">
|
||||||
|
<SidebarMenuButton as-child>
|
||||||
|
<a href="#">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
<span>{{ t(item.name) }}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from "vue"
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import {
|
||||||
|
IconCirclePlusFilled,
|
||||||
|
IconDashboard,
|
||||||
|
IconFolder,
|
||||||
|
IconListDetails,
|
||||||
|
IconChartBar,
|
||||||
|
IconUsers,
|
||||||
|
} from "@tabler/icons-vue"
|
||||||
|
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const mainNavItems = [
|
||||||
|
{ title: 'nav.dashboard', icon: IconDashboard, id: 'dashboard' },
|
||||||
|
{ title: 'nav.projects', icon: IconFolder, id: 'projects' },
|
||||||
|
{ title: 'nav.lifecycle', icon: IconListDetails, id: 'lifecycle' },
|
||||||
|
{ title: 'nav.analytics', icon: IconChartBar, id: 'analytics' },
|
||||||
|
{ title: 'nav.team', icon: IconUsers, id: 'team' },
|
||||||
|
]
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items?: { title: string; url: string; icon?: Component }[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupContent class="flex flex-col gap-2">
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem class="flex items-center gap-2">
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip="Quick Create"
|
||||||
|
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
||||||
|
>
|
||||||
|
<IconCirclePlusFilled />
|
||||||
|
<span>{{ t('nav.quickCreate') }}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem v-for="item in mainNavItems" :key="item.id">
|
||||||
|
<SidebarMenuButton :tooltip="t(item.title)">
|
||||||
|
<component :is="item.icon" v-if="item.icon" />
|
||||||
|
<span>{{ t(item.title) }}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from "vue"
|
||||||
|
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: NavItem[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.title"
|
||||||
|
>
|
||||||
|
<SidebarMenuButton as-child>
|
||||||
|
<a :href="item.url">
|
||||||
|
<component :is="item.icon" v-if="item.icon" />
|
||||||
|
{{ item.title }}
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
IconCreditCard,
|
||||||
|
IconDotsVertical,
|
||||||
|
IconLogout,
|
||||||
|
IconNotification,
|
||||||
|
IconUserCircle,
|
||||||
|
} from "@tabler/icons-vue"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/registry/new-york-v4/ui/avatar"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/registry/new-york-v4/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/registry/new-york-v4/ui/sidebar"
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
user: User
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Avatar class="h-8 w-8 rounded-lg grayscale">
|
||||||
|
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||||
|
<AvatarFallback class="rounded-lg">
|
||||||
|
CN
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-medium">{{ user.name }}</span>
|
||||||
|
<span class="text-muted-foreground truncate text-xs">
|
||||||
|
{{ user.email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IconDotsVertical class="ml-auto size-4" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
class="w-(--reka-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||||
|
:side="isMobile ? 'bottom' : 'right'"
|
||||||
|
:side-offset="4"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel class="p-0 font-normal">
|
||||||
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
|
<Avatar class="h-8 w-8 rounded-lg">
|
||||||
|
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||||
|
<AvatarFallback class="rounded-lg">
|
||||||
|
CN
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-medium">{{ user.name }}</span>
|
||||||
|
<span class="text-muted-foreground truncate text-xs">
|
||||||
|
{{ user.email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<IconUserCircle />
|
||||||
|
Account
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<IconCreditCard />
|
||||||
|
Billing
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<IconNotification />
|
||||||
|
Notifications
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<IconLogout />
|
||||||
|
Log out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-vue"
|
||||||
|
|
||||||
|
import { Badge } from "@/registry/new-york-v4/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/registry/new-york-v4/ui/card"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||||
|
<Card class="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Total Revenue</CardDescription>
|
||||||
|
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
$1,250.00
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<IconTrendingUp />
|
||||||
|
+12.5%
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter class="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
Trending up this month <IconTrendingUp class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Visitors for the last 6 months
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card class="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>New Customers</CardDescription>
|
||||||
|
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
1,234
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<IconTrendingDown />
|
||||||
|
-20%
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter class="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
Down 20% this period <IconTrendingDown class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Acquisition needs attention
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card class="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Active Accounts</CardDescription>
|
||||||
|
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
45,678
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<IconTrendingUp />
|
||||||
|
+12.5%
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter class="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
Strong user retention <IconTrendingUp class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Engagement exceed targets
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card class="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Growth Rate</CardDescription>
|
||||||
|
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
|
4.5%
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<IconTrendingUp />
|
||||||
|
+4.5%
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter class="flex-col items-start gap-1.5 text-sm">
|
||||||
|
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||||
|
Steady performance increase <IconTrendingUp class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Meets growth projections
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
|
import { isDark, toggleTheme } from "@/composables/useTheme"
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||||
|
<div class="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
|
<SidebarTrigger class="-ml-1" />
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
class="mx-2 data-[orientation=vertical]:h-4"
|
||||||
|
/>
|
||||||
|
<h1 class="text-base font-medium">
|
||||||
|
{{ t('siteHeader.title') }}
|
||||||
|
</h1>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" class="size-8" @click="toggleTheme()">
|
||||||
|
<svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-[18px]">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||||
|
<path d="M12 3l0 18" />
|
||||||
|
<path d="M12 9l4.65 -4.65" />
|
||||||
|
<path d="M12 14.3l7.37 -7.37" />
|
||||||
|
<path d="M12 19.6l8.85 -8.85" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-[18px]">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" />
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import en from './locales/en.json'
|
||||||
|
import es from './locales/es.json'
|
||||||
|
|
||||||
|
const messages = { en, es }
|
||||||
|
|
||||||
|
function getBrowserLocale(): string {
|
||||||
|
const nav = navigator as Navigator & { userLanguage?: string }
|
||||||
|
const locale = nav.language || nav.userLanguage || 'en'
|
||||||
|
return locale.split('-')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: getBrowserLocale(),
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"quickCreate": "Create project",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"projects": "Projects",
|
||||||
|
"lifecycle": "Lifecycle",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"team": "Team",
|
||||||
|
"documents": "Documents",
|
||||||
|
"dataLibrary": "Data Library",
|
||||||
|
"reports": "Reports",
|
||||||
|
"wordAssistant": "Word Assistant",
|
||||||
|
"templates": "Templates"
|
||||||
|
},
|
||||||
|
"siteHeader": {
|
||||||
|
"title": "Dashboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"quickCreate": "Crear proyecto",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"projects": "Projects",
|
||||||
|
"lifecycle": "Lifecycle",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"team": "Team",
|
||||||
|
"documents": "Documents",
|
||||||
|
"dataLibrary": "Data Library",
|
||||||
|
"reports": "Reports",
|
||||||
|
"wordAssistant": "Word Assistant",
|
||||||
|
"templates": "Templates"
|
||||||
|
},
|
||||||
|
"siteHeader": {
|
||||||
|
"title": "Dashboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { i18n } from './i18n'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
app.use(i18n)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -92,6 +92,31 @@ class KappaAPI {
|
|||||||
return this.request<unknown[]>('GET', '/users/all/')
|
return this.request<unknown[]>('GET', '/users/all/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserStories(initiativeId?: number): Promise<KappaUserStory[]> {
|
||||||
|
const path = initiativeId ? `/userstorys/?initiative=${initiativeId}` : '/userstorys/'
|
||||||
|
return this.request<KappaUserStory[]>('GET', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogbooks(initiativeId?: number): Promise<KappaLogbookEntry[]> {
|
||||||
|
const path = initiativeId ? `/logbooks/?initiative=${initiativeId}` : '/logbooks/'
|
||||||
|
return this.request<KappaLogbookEntry[]>('GET', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlannings(initiativeId?: number): Promise<KappaPlanningEntry[]> {
|
||||||
|
const path = initiativeId ? `/plannings/?initiative=${initiativeId}` : '/plannings/'
|
||||||
|
return this.request<KappaPlanningEntry[]>('GET', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBusinessRules(initiativeId?: number): Promise<KappaBusinessRule[]> {
|
||||||
|
const path = initiativeId ? `/business-rules/?initiative=${initiativeId}` : '/business-rules/'
|
||||||
|
return this.request<KappaBusinessRule[]>('GET', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRequirements(initiativeId?: number): Promise<KappaRequirement[]> {
|
||||||
|
const path = initiativeId ? `/functionalrequirements/?initiative=${initiativeId}` : '/functionalrequirements/'
|
||||||
|
return this.request<KappaRequirement[]>('GET', path)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Bitácoras (Logbooks) ────────────────────────────
|
// ─── Bitácoras (Logbooks) ────────────────────────────
|
||||||
|
|
||||||
async createLogbookMaster(data: KappaLogbookMaster): Promise<KappaLogbookMaster> {
|
async createLogbookMaster(data: KappaLogbookMaster): Promise<KappaLogbookMaster> {
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
projects.value = await kappa.getInitiatives()
|
const data = await kappa.getInitiatives()
|
||||||
|
console.log('[KAPPA] initiatives:', data)
|
||||||
|
projects.value = Array.isArray(data) ? data : (data.results ?? [])
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+48
-3
@@ -1,17 +1,31 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
import type { KappaUserStory } from '@/types/kappa'
|
import type { KappaUserStory, KappaLogbookEntry, KappaPlanningEntry } from '@/types/kappa'
|
||||||
|
|
||||||
export const useWorkItemsStore = defineStore('workitems', () => {
|
export const useWorkItemsStore = defineStore('workitems', () => {
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const userStories = ref<KappaUserStory[]>([])
|
||||||
|
const logbooks = ref<KappaLogbookEntry[]>([])
|
||||||
|
const plannings = ref<KappaPlanningEntry[]>([])
|
||||||
|
|
||||||
|
const totalHUs = computed(() => userStories.value.length)
|
||||||
|
const inProgressHUs = computed(() =>
|
||||||
|
userStories.value.filter(us =>
|
||||||
|
us.status && ['in_progress', 'doing', 'wip', 'active'].includes(us.status.toLowerCase())
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
const totalSessions = computed(() => logbooks.value.length)
|
||||||
|
|
||||||
async function createUserStory(story: KappaUserStory): Promise<KappaUserStory | null> {
|
async function createUserStory(story: KappaUserStory): Promise<KappaUserStory | null> {
|
||||||
creating.value = true
|
creating.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const result = await kappa.createUserStory(story)
|
const result = await kappa.createUserStory(story)
|
||||||
|
userStories.value.push(result)
|
||||||
return result
|
return result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
@@ -21,5 +35,36 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { creating, error, createUserStory }
|
async function fetchWorkItems(initiativeId?: number) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const [stories, logs, plans] = await Promise.all([
|
||||||
|
kappa.getUserStories(initiativeId),
|
||||||
|
kappa.getLogbooks(initiativeId),
|
||||||
|
kappa.getPlannings(initiativeId),
|
||||||
|
])
|
||||||
|
userStories.value = Array.isArray(stories) ? stories : (stories.results ?? [])
|
||||||
|
logbooks.value = Array.isArray(logs) ? logs : (logs.results ?? [])
|
||||||
|
plannings.value = Array.isArray(plans) ? plans : (plans.results ?? [])
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
creating,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
userStories,
|
||||||
|
logbooks,
|
||||||
|
plannings,
|
||||||
|
totalHUs,
|
||||||
|
inProgressHUs,
|
||||||
|
totalSessions,
|
||||||
|
createUserStory,
|
||||||
|
fetchWorkItems,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
+2
-1
@@ -26,7 +26,8 @@ export interface KappaUser {
|
|||||||
|
|
||||||
export interface KappaInitiative {
|
export interface KappaInitiative {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name?: string
|
||||||
|
initiative_name?: string
|
||||||
key?: string
|
key?: string
|
||||||
description?: string
|
description?: string
|
||||||
start_date?: string
|
start_date?: string
|
||||||
|
|||||||
+53
-29
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { Activity, CreditCard, DollarSign, FileText, Users } from 'lucide-vue-next'
|
import { useWorkItemsStore } from '@/stores/workitems'
|
||||||
|
import { Activity, CreditCard, FileText, Users } from 'lucide-vue-next'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +15,25 @@ import {
|
|||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
|
|
||||||
const projects = useProjectsStore()
|
const projects = useProjectsStore()
|
||||||
|
const workItems = useWorkItemsStore()
|
||||||
|
|
||||||
const project = computed(() => projects.selected)
|
const project = computed(() => projects.selected)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => projects.selectedId,
|
||||||
|
async (id) => {
|
||||||
|
if (id) {
|
||||||
|
await workItems.fetchWorkItems(id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const recentSessions = computed(() => {
|
||||||
|
return [...workItems.logbooks]
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
|
.slice(0, 5)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -37,7 +56,7 @@ const project = computed(() => projects.selected)
|
|||||||
<FileText class="h-4 w-4 text-muted-foreground" />
|
<FileText class="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">—</div>
|
<div class="text-2xl font-bold">{{ workItems.totalHUs }}</div>
|
||||||
<p class="text-xs text-muted-foreground">Total HUs del proyecto</p>
|
<p class="text-xs text-muted-foreground">Total HUs del proyecto</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -48,30 +67,30 @@ const project = computed(() => projects.selected)
|
|||||||
<Activity class="h-4 w-4 text-muted-foreground" />
|
<Activity class="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">—</div>
|
<div class="text-2xl font-bold">{{ workItems.inProgressHUs }}</div>
|
||||||
<p class="text-xs text-muted-foreground">HUs activas</p>
|
<p class="text-xs text-muted-foreground">HUs activas</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle class="text-sm font-medium">Riesgos detectados</CardTitle>
|
|
||||||
<CreditCard class="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="text-2xl font-bold">—</div>
|
|
||||||
<p class="text-xs text-muted-foreground">Desde transcripciones</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">Sesiones</CardTitle>
|
<CardTitle class="text-sm font-medium">Sesiones</CardTitle>
|
||||||
<Users class="h-4 w-4 text-muted-foreground" />
|
<Users class="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">—</div>
|
<div class="text-2xl font-bold">{{ workItems.totalSessions }}</div>
|
||||||
<p class="text-xs text-muted-foreground">Transcripciones procesadas</p>
|
<p class="text-xs text-muted-foreground">Bitácoras registradas</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium">Planeaciones</CardTitle>
|
||||||
|
<CreditCard class="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-2xl font-bold">{{ workItems.plannings.length }}</div>
|
||||||
|
<p class="text-xs text-muted-foreground">Entradas de planeación</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,18 +124,19 @@ const project = computed(() => projects.selected)
|
|||||||
<CardTitle class="text-sm font-medium">Actividad reciente</CardTitle>
|
<CardTitle class="text-sm font-medium">Actividad reciente</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="space-y-4">
|
<div v-if="recentSessions.length > 0" class="space-y-4">
|
||||||
<div v-for="i in 3" :key="i" class="flex items-center gap-4">
|
<div v-for="session in recentSessions" :key="session.id" class="flex items-center gap-4">
|
||||||
<div class="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
<div class="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||||
<div class="flex-1 space-y-1">
|
<div class="flex-1 space-y-1">
|
||||||
<p class="text-sm font-medium leading-none">Sesión {{ 4 - i }}</p>
|
<p class="text-sm font-medium leading-none truncate">{{ session.description?.slice(0, 50) || 'Sin descripción' }}</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
{{ new Date(2026, 4, 15 - i * 4).toLocaleDateString('es-CO', { month: 'long', day: 'numeric' }) }}
|
{{ new Date(session.date).toLocaleDateString('es-CO', { month: 'long', day: 'numeric', year: 'numeric' }) }}
|
||||||
|
<span v-if="session.hours"> · {{ session.hours }}h</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground/50 mt-4 italic">Datos de KAPPA pendientes de carga</p>
|
<p v-else class="text-xs text-muted-foreground/50 italic">Sin actividad reciente</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,10 +144,11 @@ const project = computed(() => projects.selected)
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-row items-center justify-between">
|
<CardHeader class="flex flex-row items-center justify-between">
|
||||||
<CardTitle class="text-sm font-medium">Work Items</CardTitle>
|
<CardTitle class="text-sm font-medium">Work Items</CardTitle>
|
||||||
<Badge variant="outline" class="text-[10px]">Próximamente</Badge>
|
<Badge v-if="workItems.loading" variant="outline" class="text-[10px]">Cargando...</Badge>
|
||||||
|
<Badge v-else variant="outline" class="text-[10px]">{{ workItems.userStories.length }} HUs</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table v-if="workItems.userStories.length > 0">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Código</TableHead>
|
<TableHead>Código</TableHead>
|
||||||
@@ -137,16 +158,19 @@ const project = computed(() => projects.selected)
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="i in 5" :key="i">
|
<TableRow v-for="story in workItems.userStories.slice(0, 10)" :key="story.id">
|
||||||
<TableCell class="font-mono text-xs text-muted-foreground">HU-{{ String(i).padStart(3, '0') }}</TableCell>
|
<TableCell class="font-mono text-xs text-muted-foreground">{{ story.code || `HU-${story.id}` }}</TableCell>
|
||||||
<TableCell class="text-muted-foreground/50 italic">Pendiente de carga</TableCell>
|
<TableCell class="text-sm truncate max-w-[200px]">{{ story.title }}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" class="text-[10px]">backlog</Badge>
|
<Badge variant="outline" class="text-[10px] capitalize">{{ story.status || 'backlog' }}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right text-muted-foreground/50">—</TableCell>
|
<TableCell class="text-right text-muted-foreground/70">{{ story.priority || '—' }}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<p v-else class="text-xs text-muted-foreground/50 italic text-center py-4">
|
||||||
|
Sin historias de usuario
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+19
-1
@@ -4,10 +4,12 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Eye, EyeOff } from 'lucide-vue-next'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!email.value || !password.value) return
|
if (!email.value || !password.value) return
|
||||||
@@ -34,7 +36,23 @@ async function handleLogin() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Contraseña</label>
|
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Contraseña</label>
|
||||||
<Input v-model="password" type="password" placeholder="••••••••" autocomplete="current-password" />
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
v-model="password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
>
|
||||||
|
<EyeOff v-if="showPassword" class="size-4" />
|
||||||
|
<Eye v-else class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="auth.error" class="text-xs text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
<div v-if="auth.error" class="text-xs text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
|
import AppSidebar from "@/components/dashboard/AppSidebar.vue"
|
||||||
|
import SiteHeader from "@/components/dashboard/SiteHeader.vue"
|
||||||
|
import DashboardView from "@/views/DashboardView.vue"
|
||||||
|
|
||||||
|
const sidebarStyle = {
|
||||||
|
'--sidebar-width': '16rem',
|
||||||
|
'--header-height': '3rem',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarProvider :style="sidebarStyle">
|
||||||
|
<AppSidebar variant="inset" />
|
||||||
|
<SidebarInset>
|
||||||
|
<SiteHeader />
|
||||||
|
<div class="flex flex-1 flex-col">
|
||||||
|
<div class="@container/main flex flex-1 flex-col gap-2">
|
||||||
|
<div class="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||||
|
<DashboardView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// vite.config.ts
|
||||||
|
import { defineConfig } from "file:///Users/ricardogonzalez/Library/Mobile%20Documents/com%7Eapple%7ECloudDocs/AI/Teloprax/02_productos/kappa-hub/node_modules/vite/dist/node/index.js";
|
||||||
|
import vue from "file:///Users/ricardogonzalez/Library/Mobile%20Documents/com%7Eapple%7ECloudDocs/AI/Teloprax/02_productos/kappa-hub/node_modules/@vitejs/plugin-vue/dist/index.mjs";
|
||||||
|
import tailwindcss from "file:///Users/ricardogonzalez/Library/Mobile%20Documents/com%7Eapple%7ECloudDocs/AI/Teloprax/02_productos/kappa-hub/node_modules/@tailwindcss/vite/dist/index.mjs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
var __vite_injected_original_dirname = "/Users/ricardogonzalez/Library/Mobile Documents/com~apple~CloudDocs/AI/Teloprax/02_productos/kappa-hub";
|
||||||
|
var vite_config_default = defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__vite_injected_original_dirname, "src")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "https://kappa.lambdaanalytics.co",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export {
|
||||||
|
vite_config_default as default
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvcmljYXJkb2dvbnphbGV6L0xpYnJhcnkvTW9iaWxlIERvY3VtZW50cy9jb21+YXBwbGV+Q2xvdWREb2NzL0FJL1RlbG9wcmF4LzAyX3Byb2R1Y3Rvcy9rYXBwYS1odWJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9yaWNhcmRvZ29uemFsZXovTGlicmFyeS9Nb2JpbGUgRG9jdW1lbnRzL2NvbX5hcHBsZX5DbG91ZERvY3MvQUkvVGVsb3ByYXgvMDJfcHJvZHVjdG9zL2thcHBhLWh1Yi92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvcmljYXJkb2dvbnphbGV6L0xpYnJhcnkvTW9iaWxlJTIwRG9jdW1lbnRzL2NvbSU3RWFwcGxlJTdFQ2xvdWREb2NzL0FJL1RlbG9wcmF4LzAyX3Byb2R1Y3Rvcy9rYXBwYS1odWIvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHZ1ZSBmcm9tICdAdml0ZWpzL3BsdWdpbi12dWUnXG5pbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSAnQHRhaWx3aW5kY3NzL3ZpdGUnXG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSAncGF0aCdcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgcGx1Z2luczogW3Z1ZSgpLCB0YWlsd2luZGNzcygpXSxcbiAgcmVzb2x2ZToge1xuICAgIGFsaWFzOiB7XG4gICAgICAnQCc6IHJlc29sdmUoX19kaXJuYW1lLCAnc3JjJyksXG4gICAgfSxcbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcHJveHk6IHtcbiAgICAgICcvYXBpJzoge1xuICAgICAgICB0YXJnZXQ6ICdodHRwczovL2thcHBhLmxhbWJkYWFuYWx5dGljcy5jbycsXG4gICAgICAgIGNoYW5nZU9yaWdpbjogdHJ1ZSxcbiAgICAgICAgc2VjdXJlOiBmYWxzZSxcbiAgICAgIH0sXG4gICAgfSxcbiAgfSxcbn0pXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQTBlLFNBQVMsb0JBQW9CO0FBQ3ZnQixPQUFPLFNBQVM7QUFDaEIsT0FBTyxpQkFBaUI7QUFDeEIsU0FBUyxlQUFlO0FBSHhCLElBQU0sbUNBQW1DO0FBS3pDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxJQUFJLEdBQUcsWUFBWSxDQUFDO0FBQUEsRUFDOUIsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxRQUFRLGtDQUFXLEtBQUs7QUFBQSxJQUMvQjtBQUFBLEVBQ0Y7QUFBQSxFQUNBLFFBQVE7QUFBQSxJQUNOLE9BQU87QUFBQSxNQUNMLFFBQVE7QUFBQSxRQUNOLFFBQVE7QUFBQSxRQUNSLGNBQWM7QUFBQSxRQUNkLFFBQVE7QUFBQSxNQUNWO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=
|
||||||
Reference in New Issue
Block a user