@alliance-droid/status-feedback-system 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/dist/components/FeedbackButton.svelte +72 -0
- package/dist/components/FeedbackButton.svelte.d.ts +10 -0
- package/dist/components/FeedbackForm.svelte +151 -0
- package/dist/components/FeedbackForm.svelte.d.ts +11 -0
- package/dist/components/StatusBanner.svelte +41 -0
- package/dist/components/StatusBanner.svelte.d.ts +8 -0
- package/dist/components/StatusPage.svelte +156 -0
- package/dist/components/StatusPage.svelte.d.ts +12 -0
- package/dist/components/admin/AdminDashboard.svelte +274 -0
- package/dist/components/admin/AdminDashboard.svelte.d.ts +9 -0
- package/dist/components/admin/ApiKeyManager.svelte +68 -0
- package/dist/components/admin/ApiKeyManager.svelte.d.ts +7 -0
- package/dist/components/admin/FeedbackList.svelte +160 -0
- package/dist/components/admin/FeedbackList.svelte.d.ts +17 -0
- package/dist/components/admin/IncidentForm.svelte +135 -0
- package/dist/components/admin/IncidentForm.svelte.d.ts +14 -0
- package/dist/components/admin/IncidentManager.svelte +153 -0
- package/dist/components/admin/IncidentManager.svelte.d.ts +13 -0
- package/dist/components/admin/ProjectForm.svelte +106 -0
- package/dist/components/admin/ProjectForm.svelte.d.ts +13 -0
- package/dist/components/admin/ProjectList.svelte +57 -0
- package/dist/components/admin/ProjectList.svelte.d.ts +10 -0
- package/dist/components/admin/StatusControl.svelte +62 -0
- package/dist/components/admin/StatusControl.svelte.d.ts +8 -0
- package/dist/components/admin/SystemManager.svelte +119 -0
- package/dist/components/admin/SystemManager.svelte.d.ts +13 -0
- package/dist/components/admin/index.d.ts +9 -0
- package/dist/components/admin/index.js +9 -0
- package/dist/components/shared/IncidentTimeline.svelte +55 -0
- package/dist/components/shared/IncidentTimeline.svelte.d.ts +7 -0
- package/dist/components/shared/StatusIndicator.svelte +50 -0
- package/dist/components/shared/StatusIndicator.svelte.d.ts +9 -0
- package/dist/components/shared/SystemCard.svelte +30 -0
- package/dist/components/shared/SystemCard.svelte.d.ts +9 -0
- package/dist/components/shared/UptimeBar.svelte +85 -0
- package/dist/components/shared/UptimeBar.svelte.d.ts +8 -0
- package/dist/components/shared/index.d.ts +4 -0
- package/dist/components/shared/index.js +4 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +24 -0
- package/dist/server/db.d.ts +17 -0
- package/dist/server/db.js +68 -0
- package/dist/server/feedback.d.ts +27 -0
- package/dist/server/feedback.js +124 -0
- package/dist/server/handlers.d.ts +41 -0
- package/dist/server/handlers.js +251 -0
- package/dist/server/incidents.d.ts +27 -0
- package/dist/server/incidents.js +180 -0
- package/dist/server/index.d.ts +101 -0
- package/dist/server/index.js +71 -0
- package/dist/server/projects.d.ts +45 -0
- package/dist/server/projects.js +156 -0
- package/dist/server/schema.d.ts +9 -0
- package/dist/server/schema.js +94 -0
- package/dist/server/systems.d.ts +20 -0
- package/dist/server/systems.js +146 -0
- package/dist/server/types.d.ts +4 -0
- package/dist/server/types.js +4 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.js +4 -0
- package/package.json +71 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Project, System, Incident, Feedback, FeedbackStatus, SystemStatus, IncidentStatus, IncidentSeverity } from '../../types.js';
|
|
3
|
+
import ProjectList from './ProjectList.svelte';
|
|
4
|
+
import ProjectForm from './ProjectForm.svelte';
|
|
5
|
+
import ApiKeyManager from './ApiKeyManager.svelte';
|
|
6
|
+
import SystemManager from './SystemManager.svelte';
|
|
7
|
+
import StatusControl from './StatusControl.svelte';
|
|
8
|
+
import IncidentManager from './IncidentManager.svelte';
|
|
9
|
+
import IncidentForm from './IncidentForm.svelte';
|
|
10
|
+
import FeedbackList from './FeedbackList.svelte';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
projects: Project[];
|
|
14
|
+
apiEndpoint: string;
|
|
15
|
+
onRefresh?: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { projects, apiEndpoint, onRefresh }: Props = $props();
|
|
19
|
+
|
|
20
|
+
type Tab = 'overview' | 'systems' | 'incidents' | 'feedback';
|
|
21
|
+
|
|
22
|
+
let selectedProject = $state<Project | null>(null);
|
|
23
|
+
let activeTab = $state<Tab>('overview');
|
|
24
|
+
let showProjectForm = $state(false);
|
|
25
|
+
let showIncidentForm = $state(false);
|
|
26
|
+
|
|
27
|
+
// Data for selected project
|
|
28
|
+
let systems = $state<System[]>([]);
|
|
29
|
+
let incidents = $state<Incident[]>([]);
|
|
30
|
+
let feedbackItems = $state<Feedback[]>([]);
|
|
31
|
+
let feedbackTotal = $state(0);
|
|
32
|
+
let feedbackPage = $state(1);
|
|
33
|
+
let feedbackFilterStatus = $state<FeedbackStatus | ''>('');
|
|
34
|
+
let loading = $state(false);
|
|
35
|
+
|
|
36
|
+
const tabs: { id: Tab; label: string; icon: string }[] = [
|
|
37
|
+
{ id: 'overview', label: 'Overview', icon: 'fa-gauge' },
|
|
38
|
+
{ id: 'systems', label: 'Systems', icon: 'fa-server' },
|
|
39
|
+
{ id: 'incidents', label: 'Incidents', icon: 'fa-triangle-exclamation' },
|
|
40
|
+
{ id: 'feedback', label: 'Feedback', icon: 'fa-comment' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// ── API Helpers ──
|
|
44
|
+
|
|
45
|
+
async function apiCall(path: string, options: RequestInit = {}) {
|
|
46
|
+
if (!selectedProject) throw new Error('No project selected');
|
|
47
|
+
const res = await fetch(`${apiEndpoint}/${path}`, {
|
|
48
|
+
...options,
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': 'application/json',
|
|
51
|
+
'x-api-key': selectedProject.apiKey,
|
|
52
|
+
...options.headers,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
if (!data.success) throw new Error(data.error || 'API error');
|
|
57
|
+
return data.data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadProjectData() {
|
|
61
|
+
if (!selectedProject) return;
|
|
62
|
+
loading = true;
|
|
63
|
+
try {
|
|
64
|
+
systems = await apiCall('systems');
|
|
65
|
+
incidents = await apiCall('incidents?limit=50&includeResolved=true');
|
|
66
|
+
await loadFeedback();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error('Failed to load project data:', err);
|
|
69
|
+
} finally {
|
|
70
|
+
loading = false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function loadFeedback() {
|
|
75
|
+
const params = new URLSearchParams({
|
|
76
|
+
limit: '20',
|
|
77
|
+
offset: String((feedbackPage - 1) * 20),
|
|
78
|
+
});
|
|
79
|
+
if (feedbackFilterStatus) params.set('status', feedbackFilterStatus);
|
|
80
|
+
const result = await apiCall(`feedback?${params}`);
|
|
81
|
+
feedbackItems = result.items;
|
|
82
|
+
feedbackTotal = result.total;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function selectProject(project: Project) {
|
|
86
|
+
selectedProject = project;
|
|
87
|
+
activeTab = 'overview';
|
|
88
|
+
loadProjectData();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Handlers ──
|
|
92
|
+
|
|
93
|
+
async function handleCreateProject(data: { name: string; description: string; allowedOrigins: string[] }) {
|
|
94
|
+
// This needs the consuming app to provide a server action
|
|
95
|
+
// For now, emit to onRefresh
|
|
96
|
+
showProjectForm = false;
|
|
97
|
+
await onRefresh?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function handleAddSystem(data: { name: string; description: string }) {
|
|
101
|
+
// Systems are managed via the API route handlers
|
|
102
|
+
await apiCall('systems', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
body: JSON.stringify(data),
|
|
105
|
+
}).catch(() => {
|
|
106
|
+
// Fallback: reload from server
|
|
107
|
+
});
|
|
108
|
+
await loadProjectData();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleUpdateStatus(systemId: string, status: SystemStatus) {
|
|
112
|
+
await apiCall(`systems/${systemId}/status`, {
|
|
113
|
+
method: 'PATCH',
|
|
114
|
+
body: JSON.stringify({ status }),
|
|
115
|
+
});
|
|
116
|
+
systems = systems.map((s) => (s.id === systemId ? { ...s, status } : s));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function handleDeleteSystem(systemId: string) {
|
|
120
|
+
await apiCall(`systems/${systemId}`, { method: 'DELETE' }).catch(() => {});
|
|
121
|
+
await loadProjectData();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function handleCreateIncident(data: { title: string; severity: IncidentSeverity; affectedSystems: string[]; message: string }) {
|
|
125
|
+
await apiCall('incidents', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
body: JSON.stringify(data),
|
|
128
|
+
}).catch(() => {});
|
|
129
|
+
showIncidentForm = false;
|
|
130
|
+
await loadProjectData();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function handleAddIncidentUpdate(incidentId: string, data: { status: IncidentStatus; message: string }) {
|
|
134
|
+
await apiCall(`incidents/${incidentId}/updates`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
body: JSON.stringify(data),
|
|
137
|
+
}).catch(() => {});
|
|
138
|
+
await loadProjectData();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function handleDeleteIncident(incidentId: string) {
|
|
142
|
+
await apiCall(`incidents/${incidentId}`, { method: 'DELETE' }).catch(() => {});
|
|
143
|
+
await loadProjectData();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function handleFeedbackStatusChange(feedbackId: string, status: FeedbackStatus) {
|
|
147
|
+
await apiCall(`feedback/${feedbackId}/status`, {
|
|
148
|
+
method: 'PATCH',
|
|
149
|
+
body: JSON.stringify({ status }),
|
|
150
|
+
});
|
|
151
|
+
feedbackItems = feedbackItems.map((f) => (f.id === feedbackId ? { ...f, status } : f));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function handleDeleteFeedback(feedbackId: string) {
|
|
155
|
+
if (!confirm('Delete this feedback?')) return;
|
|
156
|
+
await apiCall(`feedback/${feedbackId}`, { method: 'DELETE' }).catch(() => {});
|
|
157
|
+
await loadFeedback();
|
|
158
|
+
}
|
|
159
|
+
</script>
|
|
160
|
+
|
|
161
|
+
<div class="flex min-h-screen">
|
|
162
|
+
<!-- Sidebar -->
|
|
163
|
+
<div class="w-64 border-r border-border bg-background p-4">
|
|
164
|
+
<ProjectList
|
|
165
|
+
{projects}
|
|
166
|
+
selectedId={selectedProject?.id}
|
|
167
|
+
onSelect={selectProject}
|
|
168
|
+
onCreate={() => (showProjectForm = true)}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- Main Content -->
|
|
173
|
+
<div class="flex-1 p-6">
|
|
174
|
+
{#if showProjectForm}
|
|
175
|
+
<div class="mx-auto max-w-lg">
|
|
176
|
+
<ProjectForm
|
|
177
|
+
onSave={handleCreateProject}
|
|
178
|
+
onCancel={() => (showProjectForm = false)}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
{:else if !selectedProject}
|
|
182
|
+
<div class="flex h-full items-center justify-center">
|
|
183
|
+
<div class="text-center">
|
|
184
|
+
<i class="fa-solid fa-arrow-left mb-2 text-2xl text-foreground-tertiary"></i>
|
|
185
|
+
<p class="text-foreground-secondary">Select a project to manage</p>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
{:else}
|
|
189
|
+
<!-- Project Header -->
|
|
190
|
+
<div class="mb-6">
|
|
191
|
+
<h2 class="text-xl font-semibold text-foreground">{selectedProject.name}</h2>
|
|
192
|
+
{#if selectedProject.description}
|
|
193
|
+
<p class="mt-1 text-sm text-foreground-tertiary">{selectedProject.description}</p>
|
|
194
|
+
{/if}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<!-- API Key -->
|
|
198
|
+
<div class="mb-6">
|
|
199
|
+
<ApiKeyManager apiKey={selectedProject.apiKey} />
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<!-- Tabs -->
|
|
203
|
+
<div class="mb-6 flex gap-1 border-b border-border">
|
|
204
|
+
{#each tabs as tab}
|
|
205
|
+
<button
|
|
206
|
+
onclick={() => (activeTab = tab.id)}
|
|
207
|
+
class="border-b-2 px-4 py-2 text-sm font-semibold transition-colors {activeTab === tab.id
|
|
208
|
+
? 'border-primary text-primary'
|
|
209
|
+
: 'border-transparent text-foreground-tertiary hover:text-foreground-secondary'}"
|
|
210
|
+
>
|
|
211
|
+
<i class="fa-solid {tab.icon} mr-1.5"></i>
|
|
212
|
+
{tab.label}
|
|
213
|
+
</button>
|
|
214
|
+
{/each}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{#if loading}
|
|
218
|
+
<div class="flex items-center justify-center py-12">
|
|
219
|
+
<i class="fa-solid fa-spinner fa-spin text-xl text-foreground-tertiary"></i>
|
|
220
|
+
</div>
|
|
221
|
+
{:else if activeTab === 'overview'}
|
|
222
|
+
<div class="space-y-6">
|
|
223
|
+
<StatusControl {systems} onUpdateStatus={handleUpdateStatus} />
|
|
224
|
+
<IncidentManager
|
|
225
|
+
incidents={incidents.filter((i) => i.status !== 'resolved').slice(0, 5)}
|
|
226
|
+
onAddUpdate={handleAddIncidentUpdate}
|
|
227
|
+
onDelete={handleDeleteIncident}
|
|
228
|
+
onCreate={() => (showIncidentForm = true)}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
{:else if activeTab === 'systems'}
|
|
232
|
+
<SystemManager
|
|
233
|
+
{systems}
|
|
234
|
+
onAdd={handleAddSystem}
|
|
235
|
+
onUpdateStatus={handleUpdateStatus}
|
|
236
|
+
onDelete={handleDeleteSystem}
|
|
237
|
+
/>
|
|
238
|
+
{:else if activeTab === 'incidents'}
|
|
239
|
+
{#if showIncidentForm}
|
|
240
|
+
<IncidentForm
|
|
241
|
+
{systems}
|
|
242
|
+
onSave={handleCreateIncident}
|
|
243
|
+
onCancel={() => (showIncidentForm = false)}
|
|
244
|
+
/>
|
|
245
|
+
{:else}
|
|
246
|
+
<IncidentManager
|
|
247
|
+
{incidents}
|
|
248
|
+
onAddUpdate={handleAddIncidentUpdate}
|
|
249
|
+
onDelete={handleDeleteIncident}
|
|
250
|
+
onCreate={() => (showIncidentForm = true)}
|
|
251
|
+
/>
|
|
252
|
+
{/if}
|
|
253
|
+
{:else if activeTab === 'feedback'}
|
|
254
|
+
<FeedbackList
|
|
255
|
+
items={feedbackItems}
|
|
256
|
+
total={feedbackTotal}
|
|
257
|
+
page={feedbackPage}
|
|
258
|
+
onStatusChange={handleFeedbackStatusChange}
|
|
259
|
+
onDelete={handleDeleteFeedback}
|
|
260
|
+
filterStatus={feedbackFilterStatus || undefined}
|
|
261
|
+
onFilterStatus={(s) => {
|
|
262
|
+
feedbackFilterStatus = s;
|
|
263
|
+
feedbackPage = 1;
|
|
264
|
+
loadFeedback();
|
|
265
|
+
}}
|
|
266
|
+
onPageChange={(p) => {
|
|
267
|
+
feedbackPage = p;
|
|
268
|
+
loadFeedback();
|
|
269
|
+
}}
|
|
270
|
+
/>
|
|
271
|
+
{/if}
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Project } from '../../types.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
projects: Project[];
|
|
4
|
+
apiEndpoint: string;
|
|
5
|
+
onRefresh?: () => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
declare const AdminDashboard: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type AdminDashboard = ReturnType<typeof AdminDashboard>;
|
|
9
|
+
export default AdminDashboard;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
onRegenerate?: () => Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
let { apiKey, onRegenerate }: Props = $props();
|
|
8
|
+
|
|
9
|
+
let revealed = $state(false);
|
|
10
|
+
let copied = $state(false);
|
|
11
|
+
let regenerating = $state(false);
|
|
12
|
+
|
|
13
|
+
let maskedKey = $derived(
|
|
14
|
+
revealed ? apiKey : apiKey.slice(0, 6) + '•'.repeat(Math.max(0, apiKey.length - 10)) + apiKey.slice(-4)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
async function copyKey() {
|
|
18
|
+
await navigator.clipboard.writeText(apiKey);
|
|
19
|
+
copied = true;
|
|
20
|
+
setTimeout(() => (copied = false), 2000);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function handleRegenerate() {
|
|
24
|
+
if (!confirm('Regenerate API key? The old key will stop working immediately.')) return;
|
|
25
|
+
regenerating = true;
|
|
26
|
+
try {
|
|
27
|
+
await onRegenerate?.();
|
|
28
|
+
} finally {
|
|
29
|
+
regenerating = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="rounded-lg border border-border bg-surface p-4">
|
|
35
|
+
<div class="mb-2 flex items-center gap-2">
|
|
36
|
+
<i class="fa-solid fa-key text-sm text-foreground-tertiary"></i>
|
|
37
|
+
<span class="text-sm font-semibold text-foreground">API Key</span>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="flex items-center gap-2">
|
|
40
|
+
<code class="flex-1 rounded bg-background px-3 py-2 text-xs text-foreground-secondary">
|
|
41
|
+
{maskedKey}
|
|
42
|
+
</code>
|
|
43
|
+
<button
|
|
44
|
+
onclick={() => (revealed = !revealed)}
|
|
45
|
+
class="rounded-md border border-border px-2.5 py-2 text-xs text-foreground-secondary transition-colors hover:bg-surface-hover"
|
|
46
|
+
aria-label={revealed ? 'Hide API key' : 'Reveal API key'}
|
|
47
|
+
>
|
|
48
|
+
<i class="fa-solid {revealed ? 'fa-eye-slash' : 'fa-eye'}"></i>
|
|
49
|
+
</button>
|
|
50
|
+
<button
|
|
51
|
+
onclick={copyKey}
|
|
52
|
+
class="rounded-md border border-border px-2.5 py-2 text-xs text-foreground-secondary transition-colors hover:bg-surface-hover"
|
|
53
|
+
aria-label="Copy API key"
|
|
54
|
+
>
|
|
55
|
+
<i class="fa-solid {copied ? 'fa-check' : 'fa-copy'}"></i>
|
|
56
|
+
</button>
|
|
57
|
+
{#if onRegenerate}
|
|
58
|
+
<button
|
|
59
|
+
onclick={handleRegenerate}
|
|
60
|
+
disabled={regenerating}
|
|
61
|
+
class="rounded-md border border-border px-2.5 py-2 text-xs text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
|
62
|
+
aria-label="Regenerate API key"
|
|
63
|
+
>
|
|
64
|
+
<i class="fa-solid {regenerating ? 'fa-spinner fa-spin' : 'fa-rotate'}"></i>
|
|
65
|
+
</button>
|
|
66
|
+
{/if}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Feedback, FeedbackStatus } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
items: Feedback[];
|
|
6
|
+
total: number;
|
|
7
|
+
page?: number;
|
|
8
|
+
pageSize?: number;
|
|
9
|
+
onStatusChange?: (feedbackId: string, status: FeedbackStatus) => Promise<void>;
|
|
10
|
+
onDelete?: (feedbackId: string) => Promise<void>;
|
|
11
|
+
onPageChange?: (page: number) => void;
|
|
12
|
+
filterCategory?: string;
|
|
13
|
+
filterStatus?: FeedbackStatus;
|
|
14
|
+
onFilterCategory?: (category: string) => void;
|
|
15
|
+
onFilterStatus?: (status: FeedbackStatus | '') => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
items,
|
|
20
|
+
total,
|
|
21
|
+
page = 1,
|
|
22
|
+
pageSize = 20,
|
|
23
|
+
onStatusChange,
|
|
24
|
+
onDelete,
|
|
25
|
+
onPageChange,
|
|
26
|
+
filterCategory,
|
|
27
|
+
filterStatus,
|
|
28
|
+
onFilterCategory,
|
|
29
|
+
onFilterStatus,
|
|
30
|
+
}: Props = $props();
|
|
31
|
+
|
|
32
|
+
let totalPages = $derived(Math.ceil(total / pageSize));
|
|
33
|
+
|
|
34
|
+
const statusOptions: FeedbackStatus[] = ['new', 'reviewed', 'resolved', 'archived'];
|
|
35
|
+
const statusColors: Record<FeedbackStatus, string> = {
|
|
36
|
+
new: 'text-blue-500 bg-blue-500/10',
|
|
37
|
+
reviewed: 'text-yellow-500 bg-yellow-500/10',
|
|
38
|
+
resolved: 'text-green-500 bg-green-500/10',
|
|
39
|
+
archived: 'text-foreground-tertiary bg-surface-hover',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function formatDate(timestamp: number): string {
|
|
43
|
+
return new Date(timestamp).toLocaleString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function truncate(text: string, max: number): string {
|
|
47
|
+
return text.length > max ? text.slice(0, max) + '…' : text;
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<div class="space-y-4">
|
|
52
|
+
<div class="flex items-center justify-between">
|
|
53
|
+
<h3 class="text-base font-semibold text-foreground">
|
|
54
|
+
<i class="fa-solid fa-comment mr-1.5 text-foreground-tertiary"></i>
|
|
55
|
+
Feedback
|
|
56
|
+
<span class="ml-1 text-sm text-foreground-tertiary">({total})</span>
|
|
57
|
+
</h3>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Filters -->
|
|
61
|
+
<div class="flex flex-wrap gap-2">
|
|
62
|
+
<button
|
|
63
|
+
onclick={() => onFilterStatus?.('')}
|
|
64
|
+
class="rounded-md px-2.5 py-1 text-xs transition-colors {!filterStatus
|
|
65
|
+
? 'bg-primary text-primary-foreground'
|
|
66
|
+
: 'border border-border text-foreground-secondary hover:bg-surface-hover'}"
|
|
67
|
+
>
|
|
68
|
+
All
|
|
69
|
+
</button>
|
|
70
|
+
{#each statusOptions as s}
|
|
71
|
+
<button
|
|
72
|
+
onclick={() => onFilterStatus?.(s)}
|
|
73
|
+
class="rounded-md px-2.5 py-1 text-xs capitalize transition-colors {filterStatus === s
|
|
74
|
+
? 'bg-primary text-primary-foreground'
|
|
75
|
+
: 'border border-border text-foreground-secondary hover:bg-surface-hover'}"
|
|
76
|
+
>
|
|
77
|
+
{s}
|
|
78
|
+
</button>
|
|
79
|
+
{/each}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Items -->
|
|
83
|
+
{#if items.length === 0}
|
|
84
|
+
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
|
85
|
+
<p class="text-sm text-foreground-secondary">No feedback found</p>
|
|
86
|
+
</div>
|
|
87
|
+
{:else}
|
|
88
|
+
<div class="space-y-2">
|
|
89
|
+
{#each items as fb}
|
|
90
|
+
<div class="rounded-lg border border-border bg-surface p-3">
|
|
91
|
+
<div class="flex items-start justify-between gap-2">
|
|
92
|
+
<div class="flex-1">
|
|
93
|
+
<div class="flex items-center gap-2 mb-1">
|
|
94
|
+
{#if fb.category}
|
|
95
|
+
<span class="rounded bg-surface-hover px-1.5 py-0.5 text-xs text-foreground-secondary">
|
|
96
|
+
{fb.category}
|
|
97
|
+
</span>
|
|
98
|
+
{/if}
|
|
99
|
+
<span class="rounded px-1.5 py-0.5 text-xs capitalize {statusColors[fb.status]}">
|
|
100
|
+
{fb.status}
|
|
101
|
+
</span>
|
|
102
|
+
<span class="text-xs text-foreground-tertiary">{formatDate(fb.createdAt)}</span>
|
|
103
|
+
</div>
|
|
104
|
+
<p class="text-sm text-foreground">{truncate(fb.message, 200)}</p>
|
|
105
|
+
{#if fb.email}
|
|
106
|
+
<p class="mt-1 text-xs text-foreground-tertiary">
|
|
107
|
+
<i class="fa-solid fa-envelope mr-1"></i>
|
|
108
|
+
{fb.email}
|
|
109
|
+
</p>
|
|
110
|
+
{/if}
|
|
111
|
+
</div>
|
|
112
|
+
<div class="flex items-center gap-1">
|
|
113
|
+
<select
|
|
114
|
+
value={fb.status}
|
|
115
|
+
onchange={(e) => onStatusChange?.(fb.id, (e.target as HTMLSelectElement).value as FeedbackStatus)}
|
|
116
|
+
class="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
|
|
117
|
+
>
|
|
118
|
+
{#each statusOptions as s}
|
|
119
|
+
<option value={s}>{s}</option>
|
|
120
|
+
{/each}
|
|
121
|
+
</select>
|
|
122
|
+
<button
|
|
123
|
+
onclick={() => onDelete?.(fb.id)}
|
|
124
|
+
class="rounded p-1 text-xs text-foreground-tertiary transition-colors hover:text-red-500"
|
|
125
|
+
aria-label="Delete feedback"
|
|
126
|
+
>
|
|
127
|
+
<i class="fa-solid fa-trash"></i>
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
{/each}
|
|
133
|
+
</div>
|
|
134
|
+
{/if}
|
|
135
|
+
|
|
136
|
+
<!-- Pagination -->
|
|
137
|
+
{#if totalPages > 1}
|
|
138
|
+
<div class="flex items-center justify-center gap-2">
|
|
139
|
+
<button
|
|
140
|
+
onclick={() => onPageChange?.(page - 1)}
|
|
141
|
+
disabled={page <= 1}
|
|
142
|
+
class="rounded-md border border-border px-3 py-1.5 text-xs text-foreground-secondary disabled:opacity-50"
|
|
143
|
+
aria-label="Previous page"
|
|
144
|
+
>
|
|
145
|
+
<i class="fa-solid fa-chevron-left"></i>
|
|
146
|
+
</button>
|
|
147
|
+
<span class="text-xs text-foreground-tertiary">
|
|
148
|
+
Page {page} of {totalPages}
|
|
149
|
+
</span>
|
|
150
|
+
<button
|
|
151
|
+
onclick={() => onPageChange?.(page + 1)}
|
|
152
|
+
disabled={page >= totalPages}
|
|
153
|
+
class="rounded-md border border-border px-3 py-1.5 text-xs text-foreground-secondary disabled:opacity-50"
|
|
154
|
+
aria-label="Next page"
|
|
155
|
+
>
|
|
156
|
+
<i class="fa-solid fa-chevron-right"></i>
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
{/if}
|
|
160
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Feedback, FeedbackStatus } from '../../types.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
items: Feedback[];
|
|
4
|
+
total: number;
|
|
5
|
+
page?: number;
|
|
6
|
+
pageSize?: number;
|
|
7
|
+
onStatusChange?: (feedbackId: string, status: FeedbackStatus) => Promise<void>;
|
|
8
|
+
onDelete?: (feedbackId: string) => Promise<void>;
|
|
9
|
+
onPageChange?: (page: number) => void;
|
|
10
|
+
filterCategory?: string;
|
|
11
|
+
filterStatus?: FeedbackStatus;
|
|
12
|
+
onFilterCategory?: (category: string) => void;
|
|
13
|
+
onFilterStatus?: (status: FeedbackStatus | '') => void;
|
|
14
|
+
}
|
|
15
|
+
declare const FeedbackList: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type FeedbackList = ReturnType<typeof FeedbackList>;
|
|
17
|
+
export default FeedbackList;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { System, IncidentSeverity } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
systems: System[];
|
|
6
|
+
onSave?: (data: {
|
|
7
|
+
title: string;
|
|
8
|
+
severity: IncidentSeverity;
|
|
9
|
+
affectedSystems: string[];
|
|
10
|
+
message: string;
|
|
11
|
+
}) => Promise<void>;
|
|
12
|
+
onCancel?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { systems, onSave, onCancel }: Props = $props();
|
|
16
|
+
|
|
17
|
+
let title = $state('');
|
|
18
|
+
let severity = $state<IncidentSeverity>('minor');
|
|
19
|
+
let message = $state('');
|
|
20
|
+
let selectedSystems = $state<string[]>([]);
|
|
21
|
+
let saving = $state(false);
|
|
22
|
+
|
|
23
|
+
const severities: IncidentSeverity[] = ['minor', 'major', 'critical'];
|
|
24
|
+
|
|
25
|
+
function toggleSystem(id: string) {
|
|
26
|
+
if (selectedSystems.includes(id)) {
|
|
27
|
+
selectedSystems = selectedSystems.filter((s) => s !== id);
|
|
28
|
+
} else {
|
|
29
|
+
selectedSystems = [...selectedSystems, id];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function handleSubmit(e: Event) {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
if (!title.trim() || !message.trim()) return;
|
|
36
|
+
saving = true;
|
|
37
|
+
try {
|
|
38
|
+
await onSave?.({
|
|
39
|
+
title: title.trim(),
|
|
40
|
+
severity,
|
|
41
|
+
affectedSystems: selectedSystems,
|
|
42
|
+
message: message.trim(),
|
|
43
|
+
});
|
|
44
|
+
} finally {
|
|
45
|
+
saving = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<form onsubmit={handleSubmit} class="space-y-4">
|
|
51
|
+
<h3 class="text-base font-semibold text-foreground">Report Incident</h3>
|
|
52
|
+
|
|
53
|
+
<div>
|
|
54
|
+
<label for="sf-inc-title" class="mb-1 block text-sm font-semibold text-foreground">
|
|
55
|
+
Title <span class="text-red-500">*</span>
|
|
56
|
+
</label>
|
|
57
|
+
<input
|
|
58
|
+
id="sf-inc-title"
|
|
59
|
+
type="text"
|
|
60
|
+
bind:value={title}
|
|
61
|
+
required
|
|
62
|
+
placeholder="Brief description of the issue"
|
|
63
|
+
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div>
|
|
68
|
+
<label class="mb-1 block text-sm font-semibold text-foreground">Severity</label>
|
|
69
|
+
<div class="flex gap-2">
|
|
70
|
+
{#each severities as s}
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onclick={() => (severity = s)}
|
|
74
|
+
class="rounded-md px-3 py-1.5 text-xs font-semibold capitalize transition-colors {severity === s
|
|
75
|
+
? 'bg-primary text-primary-foreground'
|
|
76
|
+
: 'border border-border text-foreground-secondary hover:bg-surface-hover'}"
|
|
77
|
+
>
|
|
78
|
+
{s}
|
|
79
|
+
</button>
|
|
80
|
+
{/each}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{#if systems.length > 0}
|
|
85
|
+
<div>
|
|
86
|
+
<label class="mb-1 block text-sm font-semibold text-foreground">Affected Systems</label>
|
|
87
|
+
<div class="flex flex-wrap gap-2">
|
|
88
|
+
{#each systems as system}
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onclick={() => toggleSystem(system.id)}
|
|
92
|
+
class="rounded-md px-2.5 py-1 text-xs transition-colors {selectedSystems.includes(system.id)
|
|
93
|
+
? 'bg-red-500/10 border border-red-500/30 text-red-500'
|
|
94
|
+
: 'border border-border text-foreground-secondary hover:bg-surface-hover'}"
|
|
95
|
+
>
|
|
96
|
+
{system.name}
|
|
97
|
+
</button>
|
|
98
|
+
{/each}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
{/if}
|
|
102
|
+
|
|
103
|
+
<div>
|
|
104
|
+
<label for="sf-inc-msg" class="mb-1 block text-sm font-semibold text-foreground">
|
|
105
|
+
Initial Update <span class="text-red-500">*</span>
|
|
106
|
+
</label>
|
|
107
|
+
<textarea
|
|
108
|
+
id="sf-inc-msg"
|
|
109
|
+
bind:value={message}
|
|
110
|
+
rows="3"
|
|
111
|
+
required
|
|
112
|
+
placeholder="What's happening and what are we doing about it?"
|
|
113
|
+
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground"
|
|
114
|
+
></textarea>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div class="flex gap-2">
|
|
118
|
+
<button
|
|
119
|
+
type="submit"
|
|
120
|
+
disabled={saving || !title.trim() || !message.trim()}
|
|
121
|
+
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:opacity-50"
|
|
122
|
+
>
|
|
123
|
+
{saving ? 'Creating...' : 'Create Incident'}
|
|
124
|
+
</button>
|
|
125
|
+
{#if onCancel}
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onclick={onCancel}
|
|
129
|
+
class="rounded-md border border-border px-4 py-2 text-sm text-foreground-secondary transition-colors hover:bg-surface-hover"
|
|
130
|
+
>
|
|
131
|
+
Cancel
|
|
132
|
+
</button>
|
|
133
|
+
{/if}
|
|
134
|
+
</div>
|
|
135
|
+
</form>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { System, IncidentSeverity } from '../../types.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
systems: System[];
|
|
4
|
+
onSave?: (data: {
|
|
5
|
+
title: string;
|
|
6
|
+
severity: IncidentSeverity;
|
|
7
|
+
affectedSystems: string[];
|
|
8
|
+
message: string;
|
|
9
|
+
}) => Promise<void>;
|
|
10
|
+
onCancel?: () => void;
|
|
11
|
+
}
|
|
12
|
+
declare const IncidentForm: import("svelte").Component<Props, {}, "">;
|
|
13
|
+
type IncidentForm = ReturnType<typeof IncidentForm>;
|
|
14
|
+
export default IncidentForm;
|