refactor(devops): SegmentedControl, design tokens, ConfirmDialog, spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-13 11:12:48 +01:00
parent e2f9f35362
commit ba99a4dd65
2 changed files with 74 additions and 64 deletions

View file

@ -3,6 +3,7 @@ import { ref } from 'vue'
import { useDevopsStore } from '@/stores/devops'
import Input from '@/components/ui/Input.vue'
import Button from '@/components/ui/Button.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import { toast } from 'vue-sonner'
const devopsStore = useDevopsStore()
@ -11,6 +12,7 @@ const adoOrg = ref(devopsStore.integration?.organization ?? '')
const adoProject = ref(devopsStore.integration?.project ?? '')
const adoPat = ref('')
const saving = ref(false)
const showDisconnectConfirm = ref(false)
async function save() {
if (!adoOrg.value || !adoProject.value || !adoPat.value) {
@ -34,7 +36,6 @@ async function save() {
}
async function remove() {
if (!confirm('Delete ADO integration?')) return
try {
await devopsStore.deleteIntegration()
adoOrg.value = ''
@ -88,10 +89,20 @@ async function remove() {
v-if="devopsStore.integration"
variant="destructive"
size="sm"
@click="remove"
@click="showDisconnectConfirm = true"
>
Disconnect
</Button>
</div>
</div>
<ConfirmDialog
v-model:open="showDisconnectConfirm"
title="Disconnect Azure DevOps"
description="This will remove the ADO integration and all synced work items. This action cannot be undone."
confirm-label="Disconnect"
cancel-label="Cancel"
variant="destructive"
@confirm="remove"
/>
</template>

View file

@ -10,6 +10,8 @@ import Button from '@/components/ui/Button.vue'
import Spinner from '@/components/ui/Spinner.vue'
import DataTable from '@/components/ui/DataTable.vue'
import type { TableColumn } from '@/components/ui/DataTable.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import Tooltip from '@/components/ui/Tooltip.vue'
import DevopsConnectForm from '@/components/devops/DevopsConnectForm.vue'
import { toast } from 'vue-sonner'
@ -19,6 +21,13 @@ type StateFilter = 'All' | 'Active' | 'Resolved' | 'Closed'
const stateFilter = ref<StateFilter>('All')
const cloningId = ref<string | null>(null)
const stateOptions = [
{ value: 'All', label: 'All' },
{ value: 'Active', label: 'Active' },
{ value: 'Resolved', label: 'Resolved' },
{ value: 'Closed', label: 'Closed' },
]
onMounted(async () => {
await devopsStore.fetchIntegration()
if (devopsStore.integration) {
@ -71,19 +80,18 @@ function priorityClass(p: number | null | undefined) {
const v = p ?? 3
if (v <= 1) return 'text-red-500 font-bold'
if (v <= 2) return 'text-amber-500 font-semibold'
return 'text-slate-400'
return 'text-muted-foreground'
}
function stateClass(s: string) {
if (s === 'Active' || s === 'Doing' || s === 'In Progress') return 'bg-blue-50 text-blue-600 border border-blue-100'
if (s === 'Resolved' || s === 'Done' || s === 'Closed') return 'bg-emerald-50 text-emerald-600 border border-emerald-100'
if (s === 'New') return 'bg-slate-50 text-slate-500 border border-slate-200'
return 'bg-slate-50 text-slate-500 border border-slate-200'
return 'bg-muted text-muted-foreground border border-border'
}
</script>
<template>
<div class="p-6 space-y-5">
<div class="p-6 space-y-8">
<div class="flex items-center justify-between gap-4 flex-wrap">
<h2 class="text-lg font-semibold text-foreground">Azure DevOps</h2>
<Button
@ -105,17 +113,17 @@ function stateClass(s: string) {
</template>
<template v-else-if="devopsStore.integration">
<div class="h-2 w-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-200" />
<span class="text-sm text-slate-700">
Connected to <strong class="text-slate-800">{{ devopsStore.integration.organization }}</strong>
<span class="text-slate-400 mx-1">·</span>
<span class="text-slate-500 text-xs">all assigned work items</span>
<span class="text-sm text-foreground">
Connected to <strong class="text-foreground">{{ devopsStore.integration.organization }}</strong>
<span class="text-muted-foreground mx-1">·</span>
<span class="text-muted-foreground text-xs">all assigned work items</span>
</span>
<span v-if="devopsStore.integration.last_synced_at" class="text-xs text-slate-400 ml-auto">
<span v-if="devopsStore.integration.last_synced_at" class="text-xs text-muted-foreground ml-auto">
Last synced: {{ new Date(devopsStore.integration.last_synced_at).toLocaleString() }}
</span>
</template>
<template v-else>
<div class="h-2 w-2 rounded-full bg-slate-300" />
<div class="h-2 w-2 rounded-full bg-muted-foreground/30" />
<span class="text-sm text-muted-foreground">Not connected</span>
</template>
<p v-if="devopsStore.integration?.last_sync_error" class="w-full text-xs text-destructive">
@ -128,37 +136,26 @@ function stateClass(s: string) {
<CardHeader>
<CardTitle class="text-sm">Connect Azure DevOps</CardTitle>
</CardHeader>
<CardContent>
<CardContent class="space-y-4">
<DevopsConnectForm />
</CardContent>
</Card>
<!-- Work items table -->
<div v-if="devopsStore.integration" class="space-y-3">
<div v-if="devopsStore.integration" class="space-y-4">
<div class="flex items-center justify-between gap-3 flex-wrap">
<h3 class="text-sm font-semibold text-slate-700">Work Items</h3>
<!-- State filter tabs -->
<div class="flex items-center rounded-xl border border-slate-200 overflow-hidden bg-white shadow-sm">
<button
v-for="state in (['All', 'Active', 'Resolved', 'Closed'] as const)"
:key="state"
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
stateFilter === state
? 'bg-orange-500 text-white'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-50',
]"
@click="stateFilter = state"
>
{{ state }}
</button>
</div>
<h3 class="text-sm font-semibold text-foreground">Work Items</h3>
<SegmentedControl
v-model="stateFilter"
:options="stateOptions"
aria-label="Filter by state"
/>
</div>
<div v-if="devopsStore.loading" class="flex items-center justify-center py-12">
<Spinner size="md" class="text-primary" />
</div>
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-200/80 overflow-hidden">
<div v-else class="bg-background rounded-xl shadow-sm border border-border overflow-hidden">
<DataTable
:columns="columns"
:rows="filteredWorkItems"
@ -166,20 +163,20 @@ function stateClass(s: string) {
>
<!-- # column -->
<template #cell-ado_id="{ value }">
<span class="text-xs font-mono text-slate-400">#{{ value }}</span>
<span class="text-xs font-mono text-muted-foreground">#{{ value }}</span>
</template>
<!-- Title column -->
<template #cell-title="{ row }">
<div class="min-w-0">
<p class="text-sm text-slate-800 truncate font-medium">{{ row.title }}</p>
<p class="text-xs text-slate-400 truncate">{{ row.type }}</p>
<p class="text-sm text-foreground truncate font-medium">{{ row.title }}</p>
<p class="text-xs text-muted-foreground truncate">{{ row.type }}</p>
</div>
</template>
<!-- Project column -->
<template #cell-team_project="{ value }">
<span class="text-xs text-slate-500 truncate block" :title="String(value ?? '')">
<span class="text-xs text-muted-foreground truncate block" :title="String(value ?? '')">
{{ value || '—' }}
</span>
</template>
@ -196,7 +193,7 @@ function stateClass(s: string) {
<!-- Created column -->
<template #cell-created_date="{ value }">
<span class="text-xs text-slate-400">
<span class="text-xs text-muted-foreground">
{{ value ? new Date(String(value)).toLocaleDateString() : '—' }}
</span>
</template>
@ -214,33 +211,35 @@ function stateClass(s: string) {
<!-- Actions column -->
<template #cell-id="{ row }">
<div class="flex items-center gap-1 justify-end">
<button
class="p-1.5 rounded-lg text-slate-400 hover:text-orange-500 hover:bg-orange-50 transition-colors"
:class="{ 'opacity-50': cloningId === String(row.id) }"
title="Clone to Tasks"
@click="cloneToTasks(String(row.id), $event)"
>
<svg v-if="cloningId !== String(row.id)" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<svg v-else class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</button>
<a
v-if="row.url"
:href="String(row.url)"
target="_blank"
rel="noopener"
class="p-1.5 rounded-lg text-slate-400 hover:text-blue-500 hover:bg-blue-50 transition-colors"
title="Open in Azure DevOps"
@click.stop
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<Tooltip content="Clone to Tasks">
<button
class="p-1.5 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
:class="{ 'opacity-50': cloningId === String(row.id) }"
@click="cloneToTasks(String(row.id), $event)"
>
<svg v-if="cloningId !== String(row.id)" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<svg v-else class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</button>
</Tooltip>
<Tooltip content="Open in Azure DevOps">
<a
v-if="row.url"
:href="String(row.url)"
target="_blank"
rel="noopener"
class="p-1.5 rounded-lg text-muted-foreground hover:text-blue-500 hover:bg-blue-50 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
@click.stop
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</Tooltip>
</div>
</template>
</DataTable>