This commit is contained in:
vadim committed 2026-09-24 15:08:46 +03:00
commit fcec12e5ba
78 files changed
+9569

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
const menu = [
{ id: 1, title: 'home', to: '/' },
{ id: 2, title: 'issues', to: '/issues' },
{ id: 3, title: 'summary', to: '/summary' },
{ id: 4, title: 'reports', to: '/reports' },
]
</script>
<template>
<v-list class="mt-1" width="200" density="compact">
<v-list-item v-for="item in menu" :key="item.id" :to="item.to">
<v-list-item-title class="d-flex align-center ga-4">
<v-icon icon="mdi-information-outline" />
<div>{{ item.title }}</div>
</v-list-item-title>
</v-list-item>
</v-list>
</template>
<style scoped lang="scss"></style>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { itemsList } from '@/api/user.api.ts'
import type { User } from '@/api/user.api.ts'
interface Props {
value?: User | null
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), { compact: true })
const emit = defineEmits<{ changed: [value: string] }>()
const PAGE_SIZE = 20
const users = ref<User[]>([])
const page = ref(0)
const hasMore = ref(true)
function initials(user: Pick<User, 'name' | 'email'>) {
return (user.name || user.email || '?')[0]!.toUpperCase()
}
async function loadMore({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
if (!hasMore.value) {
done('empty')
return
}
try {
const nextPage = page.value + 1
const res = await itemsList({ page: nextPage, limit: PAGE_SIZE })
users.value.push(...res.list)
page.value = nextPage
hasMore.value = nextPage < res.meta.totalPages
done(hasMore.value ? 'ok' : 'empty')
} catch {
done('error')
}
}
const currentInitials = computed(() => (props.value ? initials(props.value) : '?'))
const select = (value: unknown) => {
if (Array.isArray(value) && typeof value[0] === 'string') {
emit('changed', value[0])
}
}
</script>
<template>
<div class="">
<v-menu>
<template v-slot:activator="{ props: activatorProps }">
<div class="d-flex align-center ga-2">
<v-btn rounded="circle" density="compact" icon variant="text" v-bind="activatorProps">
<v-avatar
:image="value?.image ?? undefined"
:text="currentInitials"
rounded="circle"
density="compact"
color="primary"
border
/>
</v-btn>
<div v-if="!compact">{{ value?.name || value?.email || 'Unassigned' }}</div>
</div>
</template>
<v-infinite-scroll height="16rem" @load="loadMore">
<v-list
border
width="12rem"
class="mt-1"
select-strategy="single-leaf"
density="compact"
@update:selected="select"
>
<v-list-item value="">
<div class="d-flex align-center ga-2">
<v-avatar density="compact" border rounded="circle" icon="mdi-account" />
<div>Unassigned</div>
</div>
</v-list-item>
<v-list-item v-for="user in users" :key="user.id" :value="user.id">
<div class="d-flex align-center ga-2">
<v-avatar
:image="user.image ?? undefined"
:text="initials(user)"
rounded="circle"
density="compact"
border
color="primary"
/>
<div>{{ user.name || user.email }}</div>
</div>
</v-list-item>
</v-list>
<template #empty />
</v-infinite-scroll>
</v-menu>
</div>
</template>
<style scoped lang="scss"></style>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Props {
title: string
}
const icons = ['mdi-chevron-right', 'mdi-chevron-down']
const isOpen = ref(true)
const { title } = defineProps<Props>()
const icon = computed(() => {
return isOpen.value ? icons[1] : icons[0]
})
const open = () => {
isOpen.value = !isOpen.value
}
</script>
<template>
<div class="d-flex flex-column ga-1">
<div class="d-flex align-center ga-1">
<v-btn density="compact" size="small" :icon="icon" @click="open" />
<div @click="open" class="font-weight-bold text-capitalize text-title-medium cursor-pointer">{{ title }}</div>
</div>
<div v-if="isOpen" class="ml-6 mt-2">
<slot />
</div>
</div>
</template>
<style scoped lang="scss"></style>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
value?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{ changed: [value: string] }>()
const editing = ref(false)
const draft = ref(props.value ?? '')
const startEdit = () => {
draft.value = props.value ?? ''
editing.value = true
}
const save = () => {
emit('changed', draft.value)
editing.value = false
}
const cancel = () => {
draft.value = props.value ?? ''
editing.value = false
}
</script>
<template>
<div class="issue-content">
<template v-if="!editing">
<v-sheet class="pa-2 cursor-pointer content-sheet" @click="startEdit">
{{ value ?? 'click to add content' }}
</v-sheet>
</template>
<template v-else>
<v-textarea class="mb-1" hide-details v-model="draft" variant="outlined" auto-grow rows="4" />
<div class="d-flex justify-end ga-1">
<v-btn density="compact" variant="text" text="Cancel" @click="cancel" />
<v-btn density="compact" color="primary" variant="tonal" text="Save" @click="save" />
</div>
</template>
</div>
</template>
<style scoped lang="scss">
.content-sheet:hover {
background-color: rgba(var(--v-theme-on-surface), 0.06);
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useIssueStore } from '@/stores/issue.ts'
import { useProjectStore } from '@/stores/project.ts'
import type { Issue } from '@/api/issue.api.ts'
const issueStore = useIssueStore()
const projectStore = useProjectStore()
const emit = defineEmits<{ created: [issue: Issue] }>()
const showForm = ref(false)
const newIssue = reactive({
key: 'type',
title: '',
})
const onCreate = async () => {
const projectId = projectStore.current?.id
if (!projectId) return
const created = await issueStore.create({ ...newIssue, projectId })
if (!created) return
emit('created', created)
newIssue.title = ''
showForm.value = false
}
</script>
<template>
<v-list-item v-if="showForm" class="">
<template #prepend>
<v-menu v-if="true">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" icon="mdi-square" />
</template>
<v-list select-strategy="single-leaf">
<v-list-item v-for="(el, idx) in ['aaa', 'bbb']" :key="idx" :value="el">
<v-list-item-title>{{ el }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<template #title>
<div class="d-flex ga-2 align-center">
<v-text-field
v-model="newIssue.title"
variant="filled"
placeholder="Describe your issue"
autofocus
@keyup.enter="onCreate"
/>
<v-btn density="compact" variant="tonal" text="Create" @click="onCreate" />
<v-btn density="compact" variant="text" icon="mdi-close" @click="showForm = false" />
</div>
</template>
</v-list-item>
<v-list-item class="d-flex justify-center" v-else @click="showForm = !showForm">
<div class="d-flex ga-2 align-center">
<v-icon icon="mdi-plus" />
<div>add issue</div>
</div>
<!-- <v-btn density="compact" variant="plain" prepend-icon="mdi-plus"> create </v-btn>-->
</v-list-item>
</template>
<style scoped lang="scss"></style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useIssueStore } from '@/stores/issue.js'
import { useStatusStore } from '@/stores/status.ts'
import ExpansionPanel from '@/components/ExpansionPanel.vue'
import IssueContent from '@/components/IssueContent.vue'
import type { Issue } from '@/api/issue.api.ts'
import StatusMenu from '@/components/StatusMenu.vue'
import AssigneeMenu from '@/components/AssigneeMenu.vue'
import ReporterInfo from '@/components/ReporterInfo.vue'
interface Props {
ckey: string
}
const emit = defineEmits<{
statusChanged: [issue: Issue]
assigneeChanged: [issue: Issue]
}>()
const issueStore = useIssueStore()
const statusStore = useStatusStore()
const issue = ref<Issue>()
const { ckey } = defineProps<Props>()
const statusName = computed(() => {
return statusStore.statuses?.find((el) => el.alias === issue.value?.status)?.name ?? issue.value?.status
})
const createdAtFormatted = computed(() => {
if (!issue.value?.createdAt) return ''
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(issue.value.createdAt),
)
})
async function fetch() {
const res = await issueStore.getByKey(ckey)
if (!res) {
return
}
issue.value = res
}
watchEffect(async () => fetch())
const onContentChanged = async (content: string) => {
if (!issue.value) return
const updated = await issueStore.update(issue.value.id, { content })
if (updated) issue.value = updated
}
const onStatusChanged = async (status: Issue['status']) => {
if (!issue.value) return
const updated = await issueStore.update(issue.value.id, { status })
if (updated) {
issue.value = updated
emit('statusChanged', updated)
}
}
const onAssigneeChanged = async (assignee: string) => {
if (!issue.value) return
const updated = await issueStore.update(issue.value.id, { assignee })
if (updated) {
issue.value = updated
emit('assigneeChanged', updated)
}
}
defineExpose({
applyUpdate: (updated: Issue) => {
if (issue.value && issue.value.id === updated.id) {
issue.value = updated
}
},
})
</script>
<template>
<div v-if="issue" class="d-flex flex-column ga-4 pa-4">
<div>{{ ckey }}</div>
<div class="text-uppercase text-title-large">{{ issue.title }}</div>
<v-card elevation="0" border>
<v-card-text class="u-grid">
<div class="text-medium-emphasis">Status:</div>
<status-menu :value="issue.status" @changed="onStatusChanged" />
<div class="text-medium-emphasis">Reporter:</div>
<reporter-info :user="issue.reporter" />
<div class="text-medium-emphasis">Assignee:</div>
<assignee-menu :value="issue.assignee" :compact="false" @changed="onAssigneeChanged" />
<div class="text-medium-emphasis">Created At:</div>
<div>{{ createdAtFormatted }}</div>
</v-card-text>
</v-card>
<expansion-panel title="Details">
<issue-content :value="issue.content" @changed="onContentChanged" />
</expansion-panel>
<expansion-panel title="Activity">
<v-btn-group divided border density="compact">
<v-btn>comments</v-btn>
<v-btn>history</v-btn>
</v-btn-group>
</expansion-panel>
</div>
</template>
<style scoped lang="scss">
.u-grid {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
column-gap: 1rem;
row-gap: 0.75rem;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref } from 'vue'
const text = ref<string | null>(null)
async function onSearch() {
console.log('search')
}
</script>
<template>
<div class="u-grid2 ga-2">
<v-text-field
v-model="text"
clearable
placeholder="Search name..."
@keydown.enter.prevent="onSearch"
@click:clear="onSearch"
/>
<v-select variant="outlined" clearable :items="['abc', 'def']"></v-select>
<!-- <v-btn>filter</v-btn>-->
<!-- <v-btn variant="plain">clear filters</v-btn>-->
</div>
</template>
<style scoped lang="scss">
.u-grid2 {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useIssueStore } from '@/stores/issue.ts'
import { useStatusStore } from '@/stores/status.ts'
const issueStore = useIssueStore()
const statusStore = useStatusStore()
const items = computed(() => {
return (issueStore.stats?.byStatus ?? []).map((stat) => ({
...stat,
color: statusStore.statuses?.find((s) => s.alias === stat.status)?.color,
}))
})
</script>
<template>
<div class="d-flex ga-1">
<v-chip
v-for="item in items"
:key="item.status"
:color="item.color"
:title="item.label"
variant="flat"
label
density="compact"
>
{{ item.count }}
</v-chip>
</div>
</template>
<style scoped lang="scss"></style>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import PriorityMenu from '@/components/PriorityMenu.vue'
import StatusMenu from '@/components/StatusMenu.vue'
import { useIssueStore } from '@/stores/issue.ts'
import type { Issue } from '@/api/issue.api.ts'
import AssigneeMenu from '@/components/AssigneeMenu.vue'
interface Props {
issue: Issue
selected?: boolean
}
const { issue, selected } = defineProps<Props>()
const emit = defineEmits<{
statusChanged: [issue: Issue]
priorityChanged: [issue: Issue]
assigneeChanged: [issue: Issue]
toggleSelect: [id: number]
}>()
const issueStore = useIssueStore()
const onStatusChanged = async (status: Issue['status']) => {
const updated = await issueStore.update(issue.id, { status })
if (updated) emit('statusChanged', updated)
}
const onPriorityChanged = async (priority: Issue['priority']) => {
const updated = await issueStore.update(issue.id, { priority })
if (updated) emit('priorityChanged', updated)
}
const onAssigneeChanged = async (assignee: string) => {
const updated = await issueStore.update(issue.id, { assignee })
if (updated) emit('assigneeChanged', updated)
}
</script>
<template>
<v-list-item class="bg-on-surface-variant" :value="issue.key">
<template #title>
<div class="d-flex ga-4">
<div>{{ issue.key }}</div>
<div class="d-flex ga-1 align-center">
<v-icon
v-if="[3, 4].includes(issue.priority) && ['OPEN', 'IN_PROGRESS'].includes(issue.status)"
icon="mdi-alert-box"
color="error"
></v-icon>
<div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.title }}</div>
</div>
<!-- <div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.content }}</div>-->
</div>
</template>
<template #prepend>
<div class="d-flex ga-1 align-center mr-2" @click.stop>
<v-checkbox
class="issue-checkbox"
density="compact"
:model-value="selected"
@update:model-value="emit('toggleSelect', issue.id)"
/>
<v-icon icon="mdi-square" />
</div>
</template>
<template #append>
<div class="d-flex ga-4 align-center">
<status-menu :value="issue.status" @changed="onStatusChanged" />
<priority-menu :value="issue.priority" @changed="onPriorityChanged" />
<assignee-menu :value="issue.assignee" @changed="onAssigneeChanged" />
</div>
</template>
</v-list-item>
</template>
<style scoped lang="scss"></style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { authClient } from '@/lib/auth-client.js'
const passwordData = reactive({
newPassword: '',
currentPassword: '',
})
const changePassword = async () => {
await authClient.changePassword({
newPassword: passwordData.newPassword,
currentPassword: passwordData.currentPassword,
revokeOtherSessions: true,
})
}
</script>
<template>
<v-sheet>
<div class="text-uppercase">Change password</div>
<div class="d-flex ga-2">
<v-text-field placeholder="current password" v-model="passwordData.currentPassword" />
<v-text-field placeholder="new password" v-model="passwordData.newPassword" />
<v-icon-btn variant="tonal" icon="mdi-check" @click="changePassword" />
</div>
</v-sheet>
</template>
<style scoped lang="scss"></style>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Issue } from '@/api/issue.api.ts'
enum Icons {
highest = 'mdi-chevron-double-up',
high = 'mdi-chevron-up',
eq = 'mdi-equal',
low = 'mdi-chevron-down',
lowest = 'mdi-chevron-double-down',
}
interface Props {
value?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{ changed: [value: Issue['priority']] }>()
const priorityList = ref([
{ icon: Icons.highest, value: 4 },
{ icon: Icons.high, value: 3 },
{ icon: Icons.eq, value: 2 },
{ icon: Icons.low, value: 1 },
{ icon: Icons.lowest, value: 0 },
])
const currentIcon = computed(() => priorityList.value.find((el) => el.value === props.value)?.icon ?? Icons.eq)
const availablePriorities = computed(() => priorityList.value.filter((el) => el.value !== props.value))
function priorityColor(value?: number) {
if (value === 3 || value === 4) return 'error'
if (value === 2) return 'success'
return 'primary'
}
const currentColor = computed(() => priorityColor(props.value))
const select = (value: unknown) => {
if (Array.isArray(value)) {
emit('changed', value[0])
}
}
</script>
<template>
<div class="">
<v-menu>
<template v-slot:activator="{ props: activatorProps }">
<v-btn density="compact" :icon="currentIcon" :color="currentColor" variant="text" v-bind="activatorProps" />
</template>
<v-list border class="mt-1" select-strategy="single-leaf" density="compact" @update:selected="select">
<v-list-item nav v-for="el in availablePriorities" :key="el.value" :value="el.value">
<div class="d-flex justify-center align-center">
<v-icon size="small" :icon="el.icon" :color="priorityColor(el.value)" />
</div>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<style scoped lang="scss"></style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useProjectStore } from '@/stores/project.ts'
const projectStore = useProjectStore()
const { projects, current } = storeToRefs(projectStore)
const currentProjectName = computed(() => current.value?.name ?? 'Select project')
const selected = computed(() => (current.value ? [current.value.id] : []))
function select(value: unknown) {
console.log(value)
const id = Array.isArray(value) ? value[0] : value
const project = projects.value.find((el) => el.id === id)
if (!project) {
throw new Error(`Project ${id} not found`)
}
projectStore.select(project)
}
onMounted(async () => {
await projectStore.list({ page: 1, limit: 100 })
await projectStore.restoreCurrent()
if (!current.value && projects.value[0]) {
projectStore.select(projects.value[0])
}
})
</script>
<template>
<div v-if="projects.length" class="">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn border density="comfortable" variant="flat" :text="currentProjectName" v-bind="props" />
</template>
<v-list
width="12rem"
class="mt-1"
border
select-strategy="single-leaf"
density="compact"
:selected="selected"
mandatory
@update:selected="select"
>
<v-list-item slim v-for="project in projects" :key="project.id" :value="project.id">
<v-list-item-title>
<v-chip size="small" color="primary" variant="flat" density="compact">{{ project.key }}</v-chip>
{{ project.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<style scoped lang="scss"></style>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { User } from '@/api/user.api.ts'
import { computed } from 'vue'
interface Props {
user?: User | null
}
const { user } = defineProps<Props>()
function initials(user: Pick<User, 'name' | 'email'>) {
return (user.name || user.email || '?')[0]!.toUpperCase()
}
const currentInitials = computed(() => (user ? initials(user) : '?'))
</script>
<template>
<div class="">
<div class="d-flex align-center ga-2">
<v-btn rounded="circle" density="compact" icon variant="text">
<v-avatar
:image="user?.image ?? undefined"
:text="currentInitials"
color="primary"
rounded="circle"
density="compact"
border
/>
</v-btn>
<div>{{ user?.name || user?.email }}</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { authClient } from '@/lib/auth-client.ts'
import { ref, watchEffect } from 'vue'
interface Session {
id: string
createdAt: Date
updatedAt: Date
userId: string
expiresAt: Date
token: string
ipAddress?: string | null | undefined
userAgent?: string | null | undefined
}
const sessions = ref<Session[]>([])
const listSessions = async () => {
const result = await authClient.listSessions()
if (result.error) {
return
}
sessions.value = result.data
}
const revokeSession = async (token: string) => {
await authClient.revokeSession({
token: token,
})
}
const revokeOtherSessions = async () => {
await authClient.revokeOtherSessions()
}
const revokeAllSessions = async () => {
await authClient.revokeSessions()
}
watchEffect(async () => {
await listSessions()
})
</script>
<template>
<div>
<div class="text-uppercase">Active sessions</div>
<div class="mb-2">
<v-btn prepend-icon="mdi-skull" @click="revokeOtherSessions()">revoke other sessions</v-btn>
</div>
<v-list border density="compact">
<v-list-item class="hover:bg-primary-300" v-for="session in sessions" :key="session.id">
<div class="text-truncate">{{ session.userAgent }}</div>
<template #append>
<v-icon-btn size="small" @click="revokeSession(session.token)" color="error" icon="mdi-trash-can" />
</template>
</v-list-item>
</v-list>
</div>
</template>
<style scoped lang="scss"></style>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useStatusStore } from '@/stores/status.ts'
import { computed } from 'vue'
import type { Issue } from '@/api/issue.api.ts'
interface Props {
value?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{ changed: [value: Issue['status']] }>()
const statusStore = useStatusStore()
const currentStatusName = computed(() => {
return statusStore.statuses?.find((el) => el.alias === props.value)?.name ?? props.value
})
const currentStatusColor = computed(() => {
return statusStore.statuses?.find((el) => el.alias === props.value)?.color ?? 'white'
})
const select = (value: unknown) => {
if (Array.isArray(value)) {
emit('changed', value[0])
}
}
</script>
<template>
<div class="">
<v-menu>
<template v-slot:activator="{ props: activatorProps }">
<v-btn
slim
:color="currentStatusColor"
density="compact"
variant="flat"
v-bind="activatorProps"
:text="currentStatusName"
/>
</template>
<v-list border width="12rem" class="" select-strategy="single-leaf" density="compact" @update:selected="select">
<v-list-item slim v-for="(el, idx) in statusStore.statuses" :key="idx" :value="el.alias">
<v-list-item-title>{{ el.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<style scoped lang="scss"></style>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
user?: {
readonly id: string
readonly createdAt: Date
readonly updatedAt: Date
readonly email: string
readonly emailVerified: boolean
readonly name: string
readonly image?: string | null | undefined
}
}
const { user } = defineProps<Props>()
const avatar = computed(() => {
return user?.image ?? undefined
})
</script>
<template>
<div>
<div v-if="user" class="d-flex ga-4">
<v-avatar size="x-large" color="info" :image="avatar" />
<div>
<div>name: {{ user.name }}</div>
<div>email: {{ user.email }}</div>
<div>created at: {{ user.createdAt }}</div>
</div>
</div>
<v-skeleton-loader v-else type="avatar, list-item-three-line" />
</div>
</template>
<style scoped lang="scss"></style>