1 parent
fcc438e5fe
commit
72b9d0d407
2 files changed
+152
-4
No files matched your search
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ArcElement, Chart as ChartJS, type ChartData, type ChartOptions, Tooltip } from 'chart.js'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
import type { IssueStat } from '@/api/issue.api.ts'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip)
|
||||
|
||||
// Statuses whose color is not a theme color (e.g. "default") fall back to a neutral gray
|
||||
const FALLBACK_COLOR = '#9E9E9E'
|
||||
interface Props {
|
||||
stats?: IssueStat | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [status: string]
|
||||
}>()
|
||||
|
||||
const theme = useTheme()
|
||||
const statusStore = useStatusStore()
|
||||
|
||||
function themeColor(name: string, fallback = FALLBACK_COLOR): string {
|
||||
const color = theme.current.value.colors[name]
|
||||
return typeof color === 'string' ? color : fallback
|
||||
}
|
||||
|
||||
const { statuses } = storeToRefs(statusStore)
|
||||
|
||||
// Keep the status order from the API so a status always lands on the same segment
|
||||
const items = computed(() =>
|
||||
(statuses.value ?? []).map((status) => ({
|
||||
alias: status.alias,
|
||||
label: status.name,
|
||||
count: props.stats?.byStatus.find((el) => el.status === status.alias)?.count ?? 0,
|
||||
color: themeColor(status.color),
|
||||
})),
|
||||
)
|
||||
|
||||
const total = computed(() => props.stats?.total ?? 0)
|
||||
|
||||
const chartData = computed<ChartData<'doughnut'>>(() => ({
|
||||
labels: items.value.map((el) => el.label),
|
||||
datasets: [
|
||||
{
|
||||
data: items.value.map((el) => el.count),
|
||||
backgroundColor: items.value.map((el) => el.color),
|
||||
// 2px surface-colored gap between segments
|
||||
borderColor: themeColor('surface', '#FFFFFF'),
|
||||
borderWidth: 2,
|
||||
hoverOffset: 4,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '72%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ` ${ctx.parsed} (${Math.round((ctx.parsed / (total.value || 1)) * 100)}%)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
onHover: (event, elements) => {
|
||||
const target = event.native?.target as HTMLElement | undefined
|
||||
if (target) target.style.cursor = elements.length ? 'pointer' : 'default'
|
||||
},
|
||||
onClick: (_event, elements) => {
|
||||
const item = elements[0] && items.value[elements[0].index]
|
||||
if (item) emit('select', item.alias)
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="status-chart">
|
||||
<div class="status-chart__canvas">
|
||||
<doughnut :data="chartData" :options="chartOptions" />
|
||||
<div class="status-chart__total">
|
||||
<div class="text-headline-medium">{{ total }}</div>
|
||||
<div class="text-label-medium">issues</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="d-flex flex-column ga-2">
|
||||
<li v-for="item in items" :key="item.alias" class="status-chart__legend-item" @click="emit('select', item.alias)">
|
||||
<span class="status-chart__swatch" :style="{ background: item.color }" />
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="text-label-medium">{{ item.count }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-chart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
&__canvas {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Overlay sits on top of the canvas but must not swallow hover/click on the segments
|
||||
&__total {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__legend-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ import type { Project } from '@/api/project.api.ts'
|
||||
import DashboardCountCard from '@/components/DashboardCountCard.vue'
|
||||
import LabelText from '@/components/LabelText.vue'
|
||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
||||
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
||||
|
||||
const ACTIVE_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS']
|
||||
|
||||
@@ -210,13 +211,17 @@ function onClickCard(status?: string) {
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card border>
|
||||
<v-card-text>text</v-card-text>
|
||||
<v-card-title>Issues by status</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<issue-status-chart :stats="stats" @select="onClickCard" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card border>
|
||||
<v-card-text>text</v-card-text>
|
||||
</v-card>
|
||||
<!-- <v-card border>-->
|
||||
<!-- <v-card-text>text</v-card-text>-->
|
||||
<!-- </v-card>-->
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user