File size: 6,152 Bytes
9dfccd9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/http'
import { useUIStore } from '@/stores/uiStore'
import { cn } from '@/lib/utils'
import { LoadingSkeleton } from '@/components/common/LoadingSkeleton'

interface DataSource {
  id:           string
  type:         'jira' | 'confluence' | 'github' | 'slack'
  name:         string
  url:          string
  enabled:      boolean
  last_sync:    string | null
  sync_status:  'idle' | 'syncing' | 'error'
  error_msg:    string | null
}

async function fetchSources(): Promise<{ sources: DataSource[] }> {
  const res = await apiFetch('/api/admin/data-sources')
  return res.json()
}

async function toggleSource(id: string, enabled: boolean): Promise<void> {
  await apiFetch(`/api/admin/data-sources/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body:   JSON.stringify({ enabled }),
  })
}

const TYPE_ICONS: Record<DataSource['type'], string> = {
  jira:       '🔷',
  confluence: '📘',
  github:     '🐙',
  slack:      '💬',
}

const SYNC_STATUS_STYLES: Record<DataSource['sync_status'], string> = {
  idle:    'text-stone-400',
  syncing: 'text-blue-500',
  error:   'text-red-500',
}

export function DataSourceManager() {
  const qc       = useQueryClient()
  const addToast = useUIStore((s) => s.addToast)
  const [adding, setAdding] = useState(false)

  const { data, isLoading } = useQuery({
    queryKey:  ['admin-data-sources'],
    queryFn:   fetchSources,
    staleTime: 60_000,
  })

  const toggle = useMutation({
    mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => toggleSource(id, enabled),
    onSuccess:  () => qc.invalidateQueries({ queryKey: ['admin-data-sources'] }),
    onError:    () => addToast({ type: 'error', message: 'Failed to update data source' }),
  })

  return (
    <div className="flex flex-col gap-5">
      <div className="flex items-center justify-between">
        <p className="text-sm font-medium text-stone-500">Connected Data Sources</p>
        <button
          onClick={() => setAdding(true)}
          className="rounded-lg bg-brand px-3 py-1.5 text-xs font-medium text-white hover:bg-brand/90"
        >
          + Add source
        </button>
      </div>

      {isLoading ? (
        <div className="rounded-xl border border-surface-subtle p-5">
          <LoadingSkeleton rows={4} />
        </div>
      ) : (data?.sources ?? []).length === 0 ? (
        <div className="rounded-xl border border-dashed border-surface-subtle py-16 text-center">
          <p className="text-sm text-stone-400">No data sources configured.</p>
          <button
            onClick={() => setAdding(true)}
            className="mt-3 text-sm font-medium text-brand hover:underline"
          >
            Add your first source →
          </button>
        </div>
      ) : (
        <div className="flex flex-col gap-3">
          {(data?.sources ?? []).map((src) => (
            <div
              key={src.id}
              className={cn(
                'rounded-xl border p-4 transition-colors',
                src.enabled
                  ? 'border-surface-subtle'
                  : 'border-dashed border-stone-200 opacity-60 dark:border-stone-700',
              )}
            >
              <div className="flex items-start justify-between gap-4">
                <div className="flex items-start gap-3">
                  <span className="mt-0.5 text-xl" aria-hidden>{TYPE_ICONS[src.type]}</span>
                  <div>
                    <p className="font-medium">{src.name}</p>
                    <p className="text-xs text-stone-400">{src.url}</p>
                    {src.sync_status === 'error' && src.error_msg && (
                      <p className="mt-1 text-xs text-red-500">{src.error_msg}</p>
                    )}
                    <p className={cn('mt-1 text-xs capitalize', SYNC_STATUS_STYLES[src.sync_status])}>
                      {src.sync_status === 'syncing'
                        ? 'Syncing…'
                        : src.last_sync
                          ? `Last sync: ${new Date(src.last_sync).toLocaleDateString()}`
                          : 'Never synced'}
                    </p>
                  </div>
                </div>

                {/* Toggle */}
                <button
                  role="switch"
                  aria-checked={src.enabled}
                  onClick={() => toggle.mutate({ id: src.id, enabled: !src.enabled })}
                  className={cn(
                    'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
                    src.enabled ? 'bg-brand' : 'bg-stone-200 dark:bg-stone-700',
                  )}
                >
                  <span
                    className={cn(
                      'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform',
                      src.enabled ? 'translate-x-5' : 'translate-x-0',
                    )}
                  />
                </button>
              </div>
            </div>
          ))}
        </div>
      )}

      {/* Add source placeholder modal hint */}
      {adding && (
        <div className="rounded-xl border border-brand/30 bg-amber-50 p-5 dark:bg-amber-950/20">
          <p className="text-sm font-medium text-stone-700 dark:text-stone-300">
            Data source wizard — configure integration credentials in <code className="rounded bg-stone-100 px-1 py-0.5 text-xs dark:bg-stone-800">.env</code> and register via the API.
          </p>
          <p className="mt-1 text-xs text-stone-500">
            See <code className="rounded bg-stone-100 px-1 py-0.5 dark:bg-stone-800">POST /api/admin/data-sources</code> in the API docs.
          </p>
          <button
            onClick={() => setAdding(false)}
            className="mt-3 text-xs font-medium text-stone-500 hover:text-stone-700"
          >
            Dismiss
          </button>
        </div>
      )}
    </div>
  )
}