lula2 0.0.5 → 0.0.6
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 +291 -8
- package/dist/_app/env.js +1 -0
- package/dist/_app/immutable/assets/0.DtiRW3lO.css +1 -0
- package/dist/_app/immutable/assets/DynamicControlEditor.BkVTzFZ-.css +1 -0
- package/dist/_app/immutable/chunks/7x_q-1ab.js +1 -0
- package/dist/_app/immutable/chunks/B19gt6-g.js +2 -0
- package/dist/_app/immutable/chunks/BR-0Dorr.js +1 -0
- package/dist/_app/immutable/chunks/B_3ksxz5.js +2 -0
- package/dist/_app/immutable/chunks/Bg_R1qWi.js +3 -0
- package/dist/_app/immutable/chunks/D3aNP_lg.js +1 -0
- package/dist/_app/immutable/chunks/D4Q_ObIy.js +1 -0
- package/dist/_app/immutable/chunks/DsnmJJEf.js +1 -0
- package/dist/_app/immutable/chunks/XY2j_owG.js +66 -0
- package/dist/_app/immutable/chunks/rzN25oDf.js +1 -0
- package/dist/_app/immutable/entry/app.r0uOd9qg.js +2 -0
- package/dist/_app/immutable/entry/start.DvoqR0rc.js +1 -0
- package/dist/_app/immutable/nodes/0.Ct6FAss_.js +1 -0
- package/dist/_app/immutable/nodes/1.DLoKuy8Q.js +1 -0
- package/dist/_app/immutable/nodes/2.IRkwSmiB.js +1 -0
- package/dist/_app/immutable/nodes/3.BrTg-ZHv.js +1 -0
- package/dist/_app/immutable/nodes/4.Blq-4WQS.js +9 -0
- package/dist/_app/version.json +1 -0
- package/dist/cli/commands/crawl.js +128 -0
- package/dist/cli/commands/ui.js +2769 -0
- package/dist/cli/commands/version.js +30 -0
- package/dist/cli/server/index.js +2713 -0
- package/dist/cli/server/server.js +2702 -0
- package/dist/cli/server/serverState.js +1199 -0
- package/dist/cli/server/spreadsheetRoutes.js +788 -0
- package/dist/cli/server/types.js +0 -0
- package/dist/cli/server/websocketServer.js +2625 -0
- package/dist/cli/utils/debug.js +24 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +38 -0
- package/dist/index.js +2924 -37
- package/dist/lula.png +0 -0
- package/dist/lula2 +2 -0
- package/package.json +120 -72
- package/src/app.css +192 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +13 -0
- package/src/lib/actions/fadeWhenScrollable.ts +39 -0
- package/src/lib/actions/modal.ts +230 -0
- package/src/lib/actions/tooltip.ts +82 -0
- package/src/lib/components/control-sets/ControlSetInfo.svelte +20 -0
- package/src/lib/components/control-sets/ControlSetSelector.svelte +46 -0
- package/src/lib/components/control-sets/index.ts +5 -0
- package/src/lib/components/controls/ControlDetailsPanel.svelte +235 -0
- package/src/lib/components/controls/ControlsList.svelte +608 -0
- package/src/lib/components/controls/DynamicControlEditor.svelte +298 -0
- package/src/lib/components/controls/MappingCard.svelte +105 -0
- package/src/lib/components/controls/MappingForm.svelte +188 -0
- package/src/lib/components/controls/index.ts +9 -0
- package/src/lib/components/controls/renderers/EditableFieldRenderer.svelte +103 -0
- package/src/lib/components/controls/renderers/FieldRenderer.svelte +49 -0
- package/src/lib/components/controls/renderers/index.ts +5 -0
- package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +130 -0
- package/src/lib/components/controls/tabs/ImplementationTab.svelte +127 -0
- package/src/lib/components/controls/tabs/MappingsTab.svelte +182 -0
- package/src/lib/components/controls/tabs/OverviewTab.svelte +151 -0
- package/src/lib/components/controls/tabs/TimelineTab.svelte +41 -0
- package/src/lib/components/controls/tabs/index.ts +8 -0
- package/src/lib/components/controls/utils/ProcessedTextRenderer.svelte +63 -0
- package/src/lib/components/controls/utils/textProcessor.ts +164 -0
- package/src/lib/components/forms/DynamicControlForm.svelte +340 -0
- package/src/lib/components/forms/DynamicField.svelte +494 -0
- package/src/lib/components/forms/FormField.svelte +107 -0
- package/src/lib/components/forms/index.ts +6 -0
- package/src/lib/components/setup/ExistingControlSets.svelte +284 -0
- package/src/lib/components/setup/SpreadsheetImport.svelte +968 -0
- package/src/lib/components/setup/index.ts +5 -0
- package/src/lib/components/ui/Dropdown.svelte +107 -0
- package/src/lib/components/ui/EmptyState.svelte +80 -0
- package/src/lib/components/ui/FeatureToggle.svelte +50 -0
- package/src/lib/components/ui/SearchBar.svelte +73 -0
- package/src/lib/components/ui/StatusBadge.svelte +79 -0
- package/src/lib/components/ui/TabNavigation.svelte +48 -0
- package/src/lib/components/ui/Tooltip.svelte +120 -0
- package/src/lib/components/ui/index.ts +10 -0
- package/src/lib/components/version-control/DiffViewer.svelte +292 -0
- package/src/lib/components/version-control/TimelineItem.svelte +107 -0
- package/src/lib/components/version-control/YamlDiffViewer.svelte +428 -0
- package/src/lib/components/version-control/index.ts +6 -0
- package/src/lib/form-types.ts +57 -0
- package/src/lib/formatUtils.ts +17 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/types.ts +180 -0
- package/src/lib/websocket.ts +359 -0
- package/src/routes/+layout.svelte +236 -0
- package/src/routes/+page.svelte +38 -0
- package/src/routes/control/[id]/+page.svelte +112 -0
- package/src/routes/setup/+page.svelte +241 -0
- package/src/stores/compliance.ts +95 -0
- package/src/styles/highlightjs.css +20 -0
- package/src/styles/modal.css +58 -0
- package/src/styles/tables.css +111 -0
- package/src/styles/tooltip.css +65 -0
- package/dist/controls/index.d.ts +0 -18
- package/dist/controls/index.d.ts.map +0 -1
- package/dist/controls/index.js +0 -18
- package/dist/crawl.d.ts +0 -62
- package/dist/crawl.d.ts.map +0 -1
- package/dist/crawl.js +0 -172
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/src/controls/index.ts +0 -19
- package/src/crawl.ts +0 -227
- package/src/index.ts +0 -46
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { CloudUpload, Draggable } from 'carbon-icons-svelte';
|
|
3
|
+
import { createEventDispatcher } from 'svelte';
|
|
4
|
+
|
|
5
|
+
const dispatch = createEventDispatcher();
|
|
6
|
+
|
|
7
|
+
// File data
|
|
8
|
+
let fileData: File | null = null;
|
|
9
|
+
let fileName = '';
|
|
10
|
+
let selectedSheet = '';
|
|
11
|
+
let sheets: string[] = [];
|
|
12
|
+
let fields: string[] = [];
|
|
13
|
+
let sampleData: any[] = [];
|
|
14
|
+
let controlCount = 0;
|
|
15
|
+
let rowPreviews: { row: number; preview: string }[] = [];
|
|
16
|
+
// Make availableFields reactive to both fields and fieldConfigs changes
|
|
17
|
+
$: availableFields = fields.filter((f) => fields.includes(f));
|
|
18
|
+
|
|
19
|
+
// Field configuration for tabs
|
|
20
|
+
type TabAssignment = 'overview' | 'implementation' | 'custom' | null;
|
|
21
|
+
let fieldConfigs = new Map<
|
|
22
|
+
string,
|
|
23
|
+
{
|
|
24
|
+
originalName: string;
|
|
25
|
+
tab: TabAssignment;
|
|
26
|
+
displayOrder: number;
|
|
27
|
+
fieldType: 'text' | 'textarea' | 'select' | 'date' | 'number' | 'boolean';
|
|
28
|
+
required: boolean;
|
|
29
|
+
}
|
|
30
|
+
>();
|
|
31
|
+
|
|
32
|
+
// Options
|
|
33
|
+
let headerRow = 1;
|
|
34
|
+
let controlIdField = ''; // Start empty to force selection
|
|
35
|
+
let controlSetName = '';
|
|
36
|
+
let controlSetDescription = '';
|
|
37
|
+
|
|
38
|
+
// UI State
|
|
39
|
+
let isLoading = false;
|
|
40
|
+
let errorMessage = '';
|
|
41
|
+
let successMessage = '';
|
|
42
|
+
let showFieldMapping = false;
|
|
43
|
+
let dragActive = false;
|
|
44
|
+
|
|
45
|
+
// Drag and drop state
|
|
46
|
+
let draggedField: string | null = null;
|
|
47
|
+
let dragOverTab: TabAssignment | null = null;
|
|
48
|
+
let dragOverField: string | null = null;
|
|
49
|
+
|
|
50
|
+
// Reset all form state
|
|
51
|
+
function resetFormState() {
|
|
52
|
+
// Reset field data
|
|
53
|
+
fields = [];
|
|
54
|
+
sampleData = [];
|
|
55
|
+
controlCount = 0;
|
|
56
|
+
fieldConfigs.clear();
|
|
57
|
+
fieldConfigs = new Map(); // Force reactivity
|
|
58
|
+
|
|
59
|
+
// Reset selections
|
|
60
|
+
controlIdField = '';
|
|
61
|
+
|
|
62
|
+
// Reset UI state
|
|
63
|
+
errorMessage = '';
|
|
64
|
+
successMessage = '';
|
|
65
|
+
draggedField = null;
|
|
66
|
+
dragOverTab = null;
|
|
67
|
+
dragOverField = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleDragOver(e: DragEvent) {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
dragActive = true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handleDragLeave() {
|
|
76
|
+
dragActive = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleDrop(e: DragEvent) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
dragActive = false;
|
|
82
|
+
|
|
83
|
+
const files = e.dataTransfer?.files;
|
|
84
|
+
if (files && files.length > 0) {
|
|
85
|
+
handleFile(files[0]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function handleFileSelect(e: Event) {
|
|
90
|
+
const target = e.target as HTMLInputElement;
|
|
91
|
+
if (target.files && target.files.length > 0) {
|
|
92
|
+
handleFile(target.files[0]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function handleFile(file: File) {
|
|
97
|
+
// Reset all form state when loading a new file
|
|
98
|
+
resetFormState();
|
|
99
|
+
|
|
100
|
+
fileName = file.name;
|
|
101
|
+
fileData = file;
|
|
102
|
+
errorMessage = '';
|
|
103
|
+
isLoading = true;
|
|
104
|
+
|
|
105
|
+
// Auto-populate control set name from filename (remove extension)
|
|
106
|
+
controlSetName = fileName.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
|
107
|
+
controlSetDescription = `Imported from ${fileName}`;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Send file to backend for parsing
|
|
111
|
+
const formData = new FormData();
|
|
112
|
+
formData.append('file', file);
|
|
113
|
+
|
|
114
|
+
const response = await fetch('/api/parse-excel', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body: formData
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const error = await response.json();
|
|
121
|
+
throw new Error(error.error || 'Failed to parse file');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const result = await response.json();
|
|
125
|
+
|
|
126
|
+
sheets = result.sheets || [];
|
|
127
|
+
selectedSheet = result.selectedSheet || sheets[0];
|
|
128
|
+
rowPreviews = result.rowPreviews || [];
|
|
129
|
+
|
|
130
|
+
// Set default header row
|
|
131
|
+
if (rowPreviews.length > 0 && headerRow === 1) {
|
|
132
|
+
headerRow = rowPreviews[0].row;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Load fields from the selected sheet
|
|
136
|
+
await loadSheetData();
|
|
137
|
+
showFieldMapping = true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
errorMessage = 'Error reading file: ' + (error as Error).message;
|
|
140
|
+
} finally {
|
|
141
|
+
isLoading = false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function loadSheetData() {
|
|
146
|
+
if (!fileData || !selectedSheet) return;
|
|
147
|
+
|
|
148
|
+
isLoading = true;
|
|
149
|
+
|
|
150
|
+
// Clear previous field configurations when changing sheets
|
|
151
|
+
fieldConfigs.clear();
|
|
152
|
+
fieldConfigs = new Map(); // Force reactivity
|
|
153
|
+
controlIdField = ''; // Reset control ID field selection
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const formData = new FormData();
|
|
157
|
+
formData.append('file', fileData);
|
|
158
|
+
formData.append('sheetName', selectedSheet);
|
|
159
|
+
formData.append('headerRow', headerRow.toString());
|
|
160
|
+
|
|
161
|
+
const response = await fetch('/api/parse-excel-sheet', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
body: formData
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
const error = await response.json();
|
|
168
|
+
throw new Error(error.error || 'Failed to parse sheet');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = await response.json();
|
|
172
|
+
|
|
173
|
+
fields = result.fields || [];
|
|
174
|
+
sampleData = result.sampleData || [];
|
|
175
|
+
controlCount = result.controlCount || 0;
|
|
176
|
+
|
|
177
|
+
// Initialize field configurations with smart defaults
|
|
178
|
+
fields.forEach((field, index) => {
|
|
179
|
+
const lowerField = field.toLowerCase();
|
|
180
|
+
let tab: TabAssignment = 'custom';
|
|
181
|
+
let fieldType: 'text' | 'textarea' | 'select' | 'date' | 'number' | 'boolean' = 'text';
|
|
182
|
+
|
|
183
|
+
// Smart tab assignment based on field name
|
|
184
|
+
if (
|
|
185
|
+
lowerField.includes('implementation') ||
|
|
186
|
+
lowerField.includes('status') ||
|
|
187
|
+
lowerField.includes('narrative') ||
|
|
188
|
+
lowerField.includes('guidance')
|
|
189
|
+
) {
|
|
190
|
+
tab = 'implementation';
|
|
191
|
+
} else if (
|
|
192
|
+
lowerField.includes('id') ||
|
|
193
|
+
lowerField.includes('title') ||
|
|
194
|
+
lowerField.includes('family') ||
|
|
195
|
+
lowerField.includes('cci') ||
|
|
196
|
+
lowerField.includes('control') ||
|
|
197
|
+
lowerField.includes('acronym')
|
|
198
|
+
) {
|
|
199
|
+
tab = 'overview';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Smart field type detection
|
|
203
|
+
if (
|
|
204
|
+
lowerField.includes('description') ||
|
|
205
|
+
lowerField.includes('narrative') ||
|
|
206
|
+
lowerField.includes('guidance') ||
|
|
207
|
+
lowerField.includes('statement')
|
|
208
|
+
) {
|
|
209
|
+
fieldType = 'textarea';
|
|
210
|
+
} else if (
|
|
211
|
+
lowerField.includes('status') ||
|
|
212
|
+
lowerField.includes('type') ||
|
|
213
|
+
lowerField.includes('designation')
|
|
214
|
+
) {
|
|
215
|
+
fieldType = 'select';
|
|
216
|
+
} else if (lowerField.includes('date')) {
|
|
217
|
+
fieldType = 'date';
|
|
218
|
+
} else if (lowerField.includes('count') || lowerField.includes('number')) {
|
|
219
|
+
fieldType = 'number';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fieldConfigs.set(field, {
|
|
223
|
+
originalName: field,
|
|
224
|
+
tab,
|
|
225
|
+
displayOrder: index,
|
|
226
|
+
fieldType,
|
|
227
|
+
required: lowerField.includes('id') || lowerField.includes('title')
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Trigger reactivity for fieldConfigs
|
|
232
|
+
fieldConfigs = fieldConfigs;
|
|
233
|
+
|
|
234
|
+
// Reset controlIdField if it doesn't exist in the new sheet
|
|
235
|
+
if (controlIdField && !fields.includes(controlIdField)) {
|
|
236
|
+
controlIdField = '';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Default controlIdField to "AP Acronym" if it exists, has short values, and is unique
|
|
240
|
+
if (!controlIdField && fields.includes('AP Acronym')) {
|
|
241
|
+
const hasShortValues =
|
|
242
|
+
!sampleData.length ||
|
|
243
|
+
sampleData.every((row) => !row['AP Acronym'] || String(row['AP Acronym']).length < 25);
|
|
244
|
+
const nonEmptyValues = sampleData
|
|
245
|
+
.map((row) => row['AP Acronym'])
|
|
246
|
+
.filter((v) => v != null && v !== '' && String(v).trim() !== '');
|
|
247
|
+
const uniqueValues = new Set(nonEmptyValues);
|
|
248
|
+
const hasUniqueValues =
|
|
249
|
+
!nonEmptyValues.length || uniqueValues.size === nonEmptyValues.length;
|
|
250
|
+
const hasNonEmptyValues = nonEmptyValues.length > 0;
|
|
251
|
+
if (hasShortValues && hasUniqueValues && hasNonEmptyValues) {
|
|
252
|
+
controlIdField = 'AP Acronym';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
errorMessage = 'Error loading sheet data: ' + (error as Error).message;
|
|
257
|
+
} finally {
|
|
258
|
+
isLoading = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cleanFieldName(fieldName: string): string {
|
|
263
|
+
if (!fieldName) return fieldName;
|
|
264
|
+
|
|
265
|
+
// Always clean and apply kebab-case
|
|
266
|
+
let cleaned = fieldName.trim().replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
267
|
+
|
|
268
|
+
return toKebabCase(cleaned);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function toKebabCase(str: string): string {
|
|
272
|
+
return str
|
|
273
|
+
.replace(/\W+/g, ' ')
|
|
274
|
+
.split(/ |\s/)
|
|
275
|
+
.map((word) => word.toLowerCase())
|
|
276
|
+
.join('-');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Drag and drop handlers for field assignment
|
|
280
|
+
function handleFieldDragStart(e: DragEvent, field: string) {
|
|
281
|
+
draggedField = field;
|
|
282
|
+
if (e.dataTransfer) {
|
|
283
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
284
|
+
e.dataTransfer.setData('text/plain', field);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function handleFieldDragEnd() {
|
|
289
|
+
draggedField = null;
|
|
290
|
+
dragOverTab = null;
|
|
291
|
+
dragOverField = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function handleTabDragOver(e: DragEvent, tab: TabAssignment) {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
dragOverTab = tab;
|
|
297
|
+
if (e.dataTransfer) {
|
|
298
|
+
e.dataTransfer.dropEffect = 'move';
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function handleTabDragLeave() {
|
|
303
|
+
dragOverTab = null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function handleTabDrop(e: DragEvent, tab: TabAssignment, targetIndex?: number) {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
if (draggedField && fieldConfigs.has(draggedField)) {
|
|
309
|
+
const config = fieldConfigs.get(draggedField)!;
|
|
310
|
+
config.tab = tab;
|
|
311
|
+
|
|
312
|
+
// If dropping at a specific position, update display orders
|
|
313
|
+
if (targetIndex !== undefined && tab !== null) {
|
|
314
|
+
// Get all fields in this tab
|
|
315
|
+
const tabFields = Array.from(fieldConfigs.entries())
|
|
316
|
+
.filter(([_, cfg]) => cfg.tab === tab)
|
|
317
|
+
.sort((a, b) => a[1].displayOrder - b[1].displayOrder);
|
|
318
|
+
|
|
319
|
+
// Remove the dragged field from the list if it was already in this tab
|
|
320
|
+
const filteredFields = tabFields.filter(([field]) => field !== draggedField);
|
|
321
|
+
|
|
322
|
+
// Insert at the target position
|
|
323
|
+
filteredFields.splice(targetIndex, 0, [draggedField, config]);
|
|
324
|
+
|
|
325
|
+
// Update display orders for all fields in this tab
|
|
326
|
+
filteredFields.forEach(([field, cfg], index) => {
|
|
327
|
+
cfg.displayOrder = index;
|
|
328
|
+
fieldConfigs.set(field, cfg);
|
|
329
|
+
});
|
|
330
|
+
} else if (tab !== null) {
|
|
331
|
+
// If no specific position, add to end
|
|
332
|
+
const maxOrder = Math.max(
|
|
333
|
+
0,
|
|
334
|
+
...Array.from(fieldConfigs.values())
|
|
335
|
+
.filter((cfg) => cfg.tab === tab)
|
|
336
|
+
.map((cfg) => cfg.displayOrder)
|
|
337
|
+
);
|
|
338
|
+
config.displayOrder = maxOrder + 1;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
fieldConfigs.set(draggedField, config);
|
|
342
|
+
fieldConfigs = fieldConfigs; // Trigger reactivity
|
|
343
|
+
}
|
|
344
|
+
draggedField = null;
|
|
345
|
+
dragOverTab = null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function handleFieldDragOver(e: DragEvent, field: string) {
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
e.stopPropagation();
|
|
351
|
+
dragOverField = field;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function handleFieldDragLeave() {
|
|
355
|
+
dragOverField = null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function handleFieldDrop(e: DragEvent, targetField: string, tab: TabAssignment) {
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
e.stopPropagation();
|
|
361
|
+
|
|
362
|
+
if (draggedField && draggedField !== targetField) {
|
|
363
|
+
// Find the index of the target field
|
|
364
|
+
const tabFields = Array.from(fieldConfigs.entries())
|
|
365
|
+
.filter(([_, cfg]) => cfg.tab === tab)
|
|
366
|
+
.sort((a, b) => a[1].displayOrder - b[1].displayOrder);
|
|
367
|
+
|
|
368
|
+
const targetIndex = tabFields.findIndex(([field]) => field === targetField);
|
|
369
|
+
if (targetIndex !== -1) {
|
|
370
|
+
handleTabDrop(e, tab, targetIndex);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
dragOverField = null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function importSpreadsheet() {
|
|
377
|
+
if (!fileData || !fileName) return;
|
|
378
|
+
|
|
379
|
+
// Validate required fields
|
|
380
|
+
if (!controlIdField) {
|
|
381
|
+
errorMessage = 'Please select a Control ID field before importing';
|
|
382
|
+
successMessage = ''; // Clear any previous success message
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!controlSetName || controlSetName.trim() === '') {
|
|
387
|
+
errorMessage = 'Please enter a Control Set Name before importing';
|
|
388
|
+
successMessage = ''; // Clear any previous success message
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
isLoading = true;
|
|
393
|
+
errorMessage = '';
|
|
394
|
+
successMessage = '';
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const formData = new FormData();
|
|
398
|
+
|
|
399
|
+
// Add the file
|
|
400
|
+
formData.append('file', fileData, fileName);
|
|
401
|
+
|
|
402
|
+
// Add configuration
|
|
403
|
+
formData.append('controlIdField', controlIdField);
|
|
404
|
+
formData.append('startRow', headerRow.toString());
|
|
405
|
+
formData.append('namingConvention', 'kebab-case');
|
|
406
|
+
formData.append('skipEmpty', 'true');
|
|
407
|
+
formData.append('skipEmptyRows', 'true');
|
|
408
|
+
formData.append('controlSetName', controlSetName || fileName.replace(/\.[^.]+$/, ''));
|
|
409
|
+
formData.append(
|
|
410
|
+
'controlSetDescription',
|
|
411
|
+
controlSetDescription || `Imported from ${fileName}`
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// Add field schema configuration - include all fields that are assigned to a tab
|
|
415
|
+
const fieldSchema = Array.from(fieldConfigs.entries())
|
|
416
|
+
.filter(([field, config]) => config.tab !== null)
|
|
417
|
+
.map(([field, config]) => ({
|
|
418
|
+
fieldName: cleanFieldName(field),
|
|
419
|
+
...config
|
|
420
|
+
}));
|
|
421
|
+
formData.append('fieldSchema', JSON.stringify(fieldSchema));
|
|
422
|
+
|
|
423
|
+
const response = await fetch('/api/import-spreadsheet', {
|
|
424
|
+
method: 'POST',
|
|
425
|
+
body: formData
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
if (!response.ok) {
|
|
429
|
+
const error = await response.json();
|
|
430
|
+
throw new Error(error.error || 'Import failed');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const result = await response.json();
|
|
434
|
+
successMessage = `Successfully imported ${result.controlCount} controls into ${result.families.length} families`;
|
|
435
|
+
|
|
436
|
+
// Dispatch event to parent
|
|
437
|
+
dispatch('created', { path: result.outputDir });
|
|
438
|
+
} catch (error) {
|
|
439
|
+
errorMessage = 'Error importing spreadsheet: ' + (error as Error).message;
|
|
440
|
+
} finally {
|
|
441
|
+
isLoading = false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
</script>
|
|
445
|
+
|
|
446
|
+
<div class="space-y-6">
|
|
447
|
+
<!-- File Upload Area -->
|
|
448
|
+
<div
|
|
449
|
+
on:dragover={handleDragOver}
|
|
450
|
+
on:dragleave={handleDragLeave}
|
|
451
|
+
on:drop={handleDrop}
|
|
452
|
+
role="button"
|
|
453
|
+
tabindex="0"
|
|
454
|
+
class="relative"
|
|
455
|
+
>
|
|
456
|
+
<label
|
|
457
|
+
class="flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300 {dragActive
|
|
458
|
+
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
|
459
|
+
: 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600'}"
|
|
460
|
+
>
|
|
461
|
+
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
|
462
|
+
<CloudUpload class="w-8 h-8 mb-4 text-gray-500 dark:text-gray-400" />
|
|
463
|
+
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400">
|
|
464
|
+
<span class="font-semibold">Click to upload</span> or drag and drop
|
|
465
|
+
</p>
|
|
466
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">XLSX, XLS or CSV files</p>
|
|
467
|
+
</div>
|
|
468
|
+
<input on:change={handleFileSelect} type="file" class="hidden" accept=".xlsx,.xls,.csv" />
|
|
469
|
+
</label>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
{#if fileName}
|
|
473
|
+
<div
|
|
474
|
+
class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-gray-800 dark:text-blue-400"
|
|
475
|
+
>
|
|
476
|
+
<div class="flex items-center">
|
|
477
|
+
<svg
|
|
478
|
+
class="flex-shrink-0 inline w-4 h-4 mr-3"
|
|
479
|
+
aria-hidden="true"
|
|
480
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
481
|
+
fill="currentColor"
|
|
482
|
+
viewBox="0 0 20 20"
|
|
483
|
+
>
|
|
484
|
+
<path
|
|
485
|
+
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"
|
|
486
|
+
/>
|
|
487
|
+
</svg>
|
|
488
|
+
<div>
|
|
489
|
+
<span class="font-medium">File loaded:</span>
|
|
490
|
+
{fileName}
|
|
491
|
+
<div class="mt-1">
|
|
492
|
+
<span class="font-medium">Sheets:</span>
|
|
493
|
+
{sheets.length} |
|
|
494
|
+
<span class="font-medium">Fields:</span>
|
|
495
|
+
{fields.length} |
|
|
496
|
+
<span class="font-medium">Controls found:</span>
|
|
497
|
+
{controlCount}
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
{/if}
|
|
503
|
+
|
|
504
|
+
{#if errorMessage}
|
|
505
|
+
<div class="p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400">
|
|
506
|
+
<div class="flex items-center">
|
|
507
|
+
<svg
|
|
508
|
+
class="flex-shrink-0 inline w-4 h-4 mr-3"
|
|
509
|
+
aria-hidden="true"
|
|
510
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
511
|
+
fill="currentColor"
|
|
512
|
+
viewBox="0 0 20 20"
|
|
513
|
+
>
|
|
514
|
+
<path
|
|
515
|
+
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"
|
|
516
|
+
/>
|
|
517
|
+
</svg>
|
|
518
|
+
<span>{errorMessage}</span>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
{/if}
|
|
522
|
+
|
|
523
|
+
{#if successMessage}
|
|
524
|
+
<div
|
|
525
|
+
class="p-4 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-gray-800 dark:text-green-400"
|
|
526
|
+
>
|
|
527
|
+
<div class="flex items-center">
|
|
528
|
+
<svg
|
|
529
|
+
class="flex-shrink-0 inline w-4 h-4 mr-3"
|
|
530
|
+
aria-hidden="true"
|
|
531
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
532
|
+
fill="currentColor"
|
|
533
|
+
viewBox="0 0 20 20"
|
|
534
|
+
>
|
|
535
|
+
<path
|
|
536
|
+
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"
|
|
537
|
+
/>
|
|
538
|
+
</svg>
|
|
539
|
+
<span>{successMessage}</span>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
{/if}
|
|
543
|
+
|
|
544
|
+
{#if showFieldMapping && fields.length > 0}
|
|
545
|
+
<!-- Import Options -->
|
|
546
|
+
<div
|
|
547
|
+
class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
|
548
|
+
>
|
|
549
|
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Import Options</h3>
|
|
550
|
+
|
|
551
|
+
<!-- Control Set Details -->
|
|
552
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
553
|
+
<div>
|
|
554
|
+
<label
|
|
555
|
+
for="controlSetName"
|
|
556
|
+
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
557
|
+
>
|
|
558
|
+
Control Set Name <span class="text-red-500">*</span>
|
|
559
|
+
</label>
|
|
560
|
+
<input
|
|
561
|
+
type="text"
|
|
562
|
+
id="controlSetName"
|
|
563
|
+
bind:value={controlSetName}
|
|
564
|
+
placeholder="e.g., NIST 800-53 Rev 4"
|
|
565
|
+
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
566
|
+
required
|
|
567
|
+
/>
|
|
568
|
+
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
569
|
+
This will be used as the display name and folder name
|
|
570
|
+
</p>
|
|
571
|
+
</div>
|
|
572
|
+
|
|
573
|
+
<div>
|
|
574
|
+
<label
|
|
575
|
+
for="controlSetDescription"
|
|
576
|
+
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
577
|
+
>
|
|
578
|
+
Description
|
|
579
|
+
</label>
|
|
580
|
+
<input
|
|
581
|
+
type="text"
|
|
582
|
+
id="controlSetDescription"
|
|
583
|
+
bind:value={controlSetDescription}
|
|
584
|
+
placeholder="Optional description"
|
|
585
|
+
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
586
|
+
/>
|
|
587
|
+
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
588
|
+
Brief description of this control set
|
|
589
|
+
</p>
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
|
|
593
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
594
|
+
<div>
|
|
595
|
+
<label for="sheet" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
596
|
+
Sheet
|
|
597
|
+
</label>
|
|
598
|
+
<select
|
|
599
|
+
id="sheet"
|
|
600
|
+
bind:value={selectedSheet}
|
|
601
|
+
on:change={() => {
|
|
602
|
+
loadSheetData();
|
|
603
|
+
}}
|
|
604
|
+
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
605
|
+
>
|
|
606
|
+
{#each sheets as sheet}
|
|
607
|
+
<option value={sheet}>{sheet}</option>
|
|
608
|
+
{/each}
|
|
609
|
+
</select>
|
|
610
|
+
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
611
|
+
Select which worksheet contains your control data
|
|
612
|
+
</p>
|
|
613
|
+
</div>
|
|
614
|
+
|
|
615
|
+
<div>
|
|
616
|
+
<label
|
|
617
|
+
for="headerRow"
|
|
618
|
+
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
619
|
+
>
|
|
620
|
+
Select Header Row
|
|
621
|
+
</label>
|
|
622
|
+
<select
|
|
623
|
+
id="headerRow"
|
|
624
|
+
bind:value={headerRow}
|
|
625
|
+
on:change={loadSheetData}
|
|
626
|
+
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
627
|
+
>
|
|
628
|
+
{#each rowPreviews as preview}
|
|
629
|
+
<option value={preview.row}>
|
|
630
|
+
Row {preview.row}: {preview.preview}
|
|
631
|
+
</option>
|
|
632
|
+
{/each}
|
|
633
|
+
</select>
|
|
634
|
+
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
635
|
+
Select the row containing column headers
|
|
636
|
+
</p>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
<div>
|
|
640
|
+
<label
|
|
641
|
+
for="controlIdField"
|
|
642
|
+
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
643
|
+
>
|
|
644
|
+
Control ID Field <span class="text-red-500">*</span>
|
|
645
|
+
</label>
|
|
646
|
+
<select
|
|
647
|
+
id="controlIdField"
|
|
648
|
+
bind:value={controlIdField}
|
|
649
|
+
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:text-white {!controlIdField
|
|
650
|
+
? 'border-red-500'
|
|
651
|
+
: ''}"
|
|
652
|
+
required
|
|
653
|
+
>
|
|
654
|
+
<option value="" disabled>Select Control ID field</option>
|
|
655
|
+
{#each fields as field}
|
|
656
|
+
{@const exampleValue =
|
|
657
|
+
sampleData.length > 0 && sampleData[0][field]
|
|
658
|
+
? String(sampleData[0][field]).slice(0, 30)
|
|
659
|
+
: ''}
|
|
660
|
+
{@const hasShortValues =
|
|
661
|
+
!sampleData.length ||
|
|
662
|
+
sampleData.every((row) => !row[field] || String(row[field]).length < 25)}
|
|
663
|
+
{@const nonEmptyValues = sampleData
|
|
664
|
+
.map((row) => row[field])
|
|
665
|
+
.filter((v) => v != null && v !== '' && String(v).trim() !== '')}
|
|
666
|
+
{@const uniqueValues = new Set(nonEmptyValues)}
|
|
667
|
+
{@const hasUniqueValues =
|
|
668
|
+
!nonEmptyValues.length || uniqueValues.size === nonEmptyValues.length}
|
|
669
|
+
{@const hasNonEmptyValues = nonEmptyValues.length > 0}
|
|
670
|
+
{#if hasShortValues && hasUniqueValues && hasNonEmptyValues}
|
|
671
|
+
<option value={field}>
|
|
672
|
+
{field}{exampleValue ? ` (e.g., ${exampleValue})` : ''}
|
|
673
|
+
</option>
|
|
674
|
+
{/if}
|
|
675
|
+
{/each}
|
|
676
|
+
</select>
|
|
677
|
+
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
678
|
+
Column containing unique control identifiers (e.g., AC-1, SC-7)
|
|
679
|
+
</p>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
</div>
|
|
683
|
+
|
|
684
|
+
<!-- Field Configuration -->
|
|
685
|
+
<div
|
|
686
|
+
class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
|
687
|
+
>
|
|
688
|
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Organize Fields</h3>
|
|
689
|
+
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
690
|
+
Drag fields to organize them. <strong>Overview fields</strong> will appear as table columns in
|
|
691
|
+
the controls list.
|
|
692
|
+
</p>
|
|
693
|
+
|
|
694
|
+
<!-- Column Layout -->
|
|
695
|
+
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
|
696
|
+
<!-- Excluded Fields Column -->
|
|
697
|
+
<div
|
|
698
|
+
class="border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
|
|
699
|
+
>
|
|
700
|
+
<div
|
|
701
|
+
class="p-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700 rounded-t-lg"
|
|
702
|
+
>
|
|
703
|
+
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Excluded Fields</h4>
|
|
704
|
+
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Not imported</p>
|
|
705
|
+
</div>
|
|
706
|
+
<div
|
|
707
|
+
class="p-3 min-h-[400px] max-h-[600px] overflow-y-auto space-y-2 transition-colors
|
|
708
|
+
{dragOverTab === null ? 'bg-gray-100 dark:bg-gray-800' : ''}"
|
|
709
|
+
on:dragover={(e) => handleTabDragOver(e, null)}
|
|
710
|
+
on:dragleave={handleTabDragLeave}
|
|
711
|
+
on:drop={(e) => handleTabDrop(e, null)}
|
|
712
|
+
role="region"
|
|
713
|
+
aria-label="Excluded fields drop zone"
|
|
714
|
+
>
|
|
715
|
+
{#each fields.filter((f) => !fieldConfigs.get(f) || fieldConfigs.get(f)?.tab === null) as field (field)}
|
|
716
|
+
<div
|
|
717
|
+
draggable="true"
|
|
718
|
+
on:dragstart={(e) => handleFieldDragStart(e, field)}
|
|
719
|
+
on:dragend={handleFieldDragEnd}
|
|
720
|
+
role="button"
|
|
721
|
+
aria-label="Drag {field} field"
|
|
722
|
+
tabindex="0"
|
|
723
|
+
class="flex items-center px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded text-sm cursor-move hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors opacity-75"
|
|
724
|
+
>
|
|
725
|
+
<svg class="w-3 h-3 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
726
|
+
<path
|
|
727
|
+
d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"
|
|
728
|
+
/>
|
|
729
|
+
</svg>
|
|
730
|
+
<span class="truncate line-through">{field}</span>
|
|
731
|
+
{#if field === controlIdField}
|
|
732
|
+
<span class="ml-auto text-xs text-blue-600 dark:text-blue-400">ID</span>
|
|
733
|
+
{/if}
|
|
734
|
+
</div>
|
|
735
|
+
{/each}
|
|
736
|
+
{#if fields.filter((f) => !fieldConfigs.get(f) || fieldConfigs.get(f)?.tab === null).length === 0}
|
|
737
|
+
<p class="text-xs text-gray-400 dark:text-gray-500 text-center py-4">
|
|
738
|
+
No excluded fields
|
|
739
|
+
</p>
|
|
740
|
+
{/if}
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
|
|
744
|
+
<!-- Overview Tab Column -->
|
|
745
|
+
<div
|
|
746
|
+
class="border border-blue-300 dark:border-blue-700 rounded-lg bg-white dark:bg-gray-800"
|
|
747
|
+
>
|
|
748
|
+
<div
|
|
749
|
+
class="p-3 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 rounded-t-lg"
|
|
750
|
+
>
|
|
751
|
+
<h4 class="text-sm font-semibold text-blue-700 dark:text-blue-300">Overview Tab</h4>
|
|
752
|
+
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
|
753
|
+
Shows in details & table columns
|
|
754
|
+
</p>
|
|
755
|
+
</div>
|
|
756
|
+
<div
|
|
757
|
+
class="p-3 min-h-[400px] max-h-[600px] overflow-y-auto space-y-2 transition-colors
|
|
758
|
+
{dragOverTab === 'overview' ? 'bg-blue-50 dark:bg-blue-900/10' : ''}"
|
|
759
|
+
on:dragover={(e) => handleTabDragOver(e, 'overview')}
|
|
760
|
+
on:dragleave={handleTabDragLeave}
|
|
761
|
+
on:drop={(e) => handleTabDrop(e, 'overview')}
|
|
762
|
+
role="region"
|
|
763
|
+
aria-label="Overview tab drop zone"
|
|
764
|
+
>
|
|
765
|
+
{#each Array.from(fieldConfigs.entries())
|
|
766
|
+
.filter(([field, config]) => config.tab === 'overview')
|
|
767
|
+
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field, config], index (field)}
|
|
768
|
+
<div
|
|
769
|
+
draggable="true"
|
|
770
|
+
on:dragstart={(e) => handleFieldDragStart(e, field)}
|
|
771
|
+
on:dragend={handleFieldDragEnd}
|
|
772
|
+
on:dragover={(e) => handleFieldDragOver(e, field)}
|
|
773
|
+
on:dragleave={handleFieldDragLeave}
|
|
774
|
+
on:drop={(e) => handleFieldDrop(e, field, 'overview')}
|
|
775
|
+
role="button"
|
|
776
|
+
aria-label="{field} field in Overview tab"
|
|
777
|
+
tabindex="0"
|
|
778
|
+
class="flex items-center px-3 py-2 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 rounded text-sm cursor-move hover:bg-blue-200 dark:hover:bg-blue-800/30 transition-colors
|
|
779
|
+
{dragOverField === field && draggedField !== field
|
|
780
|
+
? 'border-t-2 border-blue-500'
|
|
781
|
+
: ''}"
|
|
782
|
+
>
|
|
783
|
+
<Draggable class="w-3 h-3 mr-2 flex-shrink-0" />
|
|
784
|
+
<span class="truncate">{field}</span>
|
|
785
|
+
</div>
|
|
786
|
+
{/each}
|
|
787
|
+
{#if Array.from(fieldConfigs.entries()).filter(([field, config]) => config.tab === 'overview').length === 0}
|
|
788
|
+
<p class="text-xs text-gray-400 dark:text-gray-500 text-center py-4">
|
|
789
|
+
Drop fields here
|
|
790
|
+
</p>
|
|
791
|
+
{/if}
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
|
|
795
|
+
<!-- Implementation Tab Column -->
|
|
796
|
+
<div
|
|
797
|
+
class="border border-green-300 dark:border-green-700 rounded-lg bg-white dark:bg-gray-800"
|
|
798
|
+
>
|
|
799
|
+
<div
|
|
800
|
+
class="p-3 border-b border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 rounded-t-lg"
|
|
801
|
+
>
|
|
802
|
+
<h4 class="text-sm font-semibold text-green-700 dark:text-green-300">
|
|
803
|
+
Implementation Tab
|
|
804
|
+
</h4>
|
|
805
|
+
<p class="text-xs text-green-600 dark:text-green-400 mt-1">Status & compliance</p>
|
|
806
|
+
</div>
|
|
807
|
+
<div
|
|
808
|
+
class="p-3 min-h-[400px] max-h-[600px] overflow-y-auto space-y-2 transition-colors
|
|
809
|
+
{dragOverTab === 'implementation' ? 'bg-green-50 dark:bg-green-900/10' : ''}"
|
|
810
|
+
on:dragover={(e) => handleTabDragOver(e, 'implementation')}
|
|
811
|
+
on:dragleave={handleTabDragLeave}
|
|
812
|
+
on:drop={(e) => handleTabDrop(e, 'implementation')}
|
|
813
|
+
role="region"
|
|
814
|
+
aria-label="Implementation tab drop zone"
|
|
815
|
+
>
|
|
816
|
+
{#each Array.from(fieldConfigs.entries())
|
|
817
|
+
.filter(([field, config]) => config.tab === 'implementation')
|
|
818
|
+
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field, config], index (field)}
|
|
819
|
+
<div
|
|
820
|
+
draggable="true"
|
|
821
|
+
on:dragstart={(e) => handleFieldDragStart(e, field)}
|
|
822
|
+
on:dragend={handleFieldDragEnd}
|
|
823
|
+
on:dragover={(e) => handleFieldDragOver(e, field)}
|
|
824
|
+
on:dragleave={handleFieldDragLeave}
|
|
825
|
+
on:drop={(e) => handleFieldDrop(e, field, 'implementation')}
|
|
826
|
+
role="button"
|
|
827
|
+
aria-label="{field} field in Implementation tab"
|
|
828
|
+
tabindex="0"
|
|
829
|
+
class="flex items-center px-3 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded text-sm cursor-move hover:bg-green-200 dark:hover:bg-green-800/30 transition-colors
|
|
830
|
+
{dragOverField === field && draggedField !== field
|
|
831
|
+
? 'border-t-2 border-green-500'
|
|
832
|
+
: ''}"
|
|
833
|
+
>
|
|
834
|
+
<Draggable class="w-3 h-3 mr-2 flex-shrink-0" />
|
|
835
|
+
<span class="truncate">{field}</span>
|
|
836
|
+
</div>
|
|
837
|
+
{/each}
|
|
838
|
+
{#if Array.from(fieldConfigs.entries()).filter(([field, config]) => config.tab === 'implementation').length === 0}
|
|
839
|
+
<p class="text-xs text-gray-400 dark:text-gray-500 text-center py-4">
|
|
840
|
+
Drop fields here
|
|
841
|
+
</p>
|
|
842
|
+
{/if}
|
|
843
|
+
</div>
|
|
844
|
+
</div>
|
|
845
|
+
|
|
846
|
+
<!-- Custom Tab Column -->
|
|
847
|
+
<div
|
|
848
|
+
class="border border-purple-300 dark:border-purple-700 rounded-lg bg-white dark:bg-gray-800"
|
|
849
|
+
>
|
|
850
|
+
<div
|
|
851
|
+
class="p-3 border-b border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 rounded-t-lg"
|
|
852
|
+
>
|
|
853
|
+
<h4 class="text-sm font-semibold text-purple-700 dark:text-purple-300">Custom Tab</h4>
|
|
854
|
+
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">Additional fields</p>
|
|
855
|
+
</div>
|
|
856
|
+
<div
|
|
857
|
+
class="p-3 min-h-[400px] max-h-[600px] overflow-y-auto space-y-2 transition-colors
|
|
858
|
+
{dragOverTab === 'custom' ? 'bg-purple-50 dark:bg-purple-900/10' : ''}"
|
|
859
|
+
on:dragover={(e) => handleTabDragOver(e, 'custom')}
|
|
860
|
+
on:dragleave={handleTabDragLeave}
|
|
861
|
+
on:drop={(e) => handleTabDrop(e, 'custom')}
|
|
862
|
+
role="region"
|
|
863
|
+
aria-label="Custom fields drop zone"
|
|
864
|
+
>
|
|
865
|
+
{#each Array.from(fieldConfigs.entries())
|
|
866
|
+
.filter(([field, config]) => config.tab === 'custom')
|
|
867
|
+
.sort((a, b) => a[1].displayOrder - b[1].displayOrder) as [field, config], index (field)}
|
|
868
|
+
<div
|
|
869
|
+
draggable="true"
|
|
870
|
+
on:dragstart={(e) => handleFieldDragStart(e, field)}
|
|
871
|
+
on:dragend={handleFieldDragEnd}
|
|
872
|
+
on:dragover={(e) => handleFieldDragOver(e, field)}
|
|
873
|
+
on:dragleave={handleFieldDragLeave}
|
|
874
|
+
on:drop={(e) => handleFieldDrop(e, field, 'custom')}
|
|
875
|
+
role="button"
|
|
876
|
+
aria-label="{field} field in Custom tab"
|
|
877
|
+
tabindex="0"
|
|
878
|
+
class="flex items-center px-3 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 rounded text-sm cursor-move hover:bg-purple-200 dark:hover:bg-purple-800/30 transition-colors
|
|
879
|
+
{dragOverField === field && draggedField !== field
|
|
880
|
+
? 'border-t-2 border-purple-500'
|
|
881
|
+
: ''}"
|
|
882
|
+
>
|
|
883
|
+
<Draggable class="w-3 h-3 mr-2 flex-shrink-0" />
|
|
884
|
+
<span class="truncate">{field}</span>
|
|
885
|
+
</div>
|
|
886
|
+
{/each}
|
|
887
|
+
{#if Array.from(fieldConfigs.entries()).filter(([field, config]) => config.tab === 'custom').length === 0}
|
|
888
|
+
<p class="text-xs text-gray-400 dark:text-gray-500 text-center py-4">
|
|
889
|
+
Drop fields here
|
|
890
|
+
</p>
|
|
891
|
+
{/if}
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
</div>
|
|
896
|
+
|
|
897
|
+
<!-- Sample Data Preview -->
|
|
898
|
+
{#if sampleData.length > 0}
|
|
899
|
+
<div
|
|
900
|
+
class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
|
901
|
+
>
|
|
902
|
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
903
|
+
Sample Data Preview
|
|
904
|
+
</h3>
|
|
905
|
+
<div class="overflow-x-auto">
|
|
906
|
+
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
|
907
|
+
<thead
|
|
908
|
+
class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-600 dark:text-gray-400"
|
|
909
|
+
>
|
|
910
|
+
<tr>
|
|
911
|
+
{#each fields.slice(0, 5) as field}
|
|
912
|
+
<th class="px-4 py-2">{field}</th>
|
|
913
|
+
{/each}
|
|
914
|
+
</tr>
|
|
915
|
+
</thead>
|
|
916
|
+
<tbody>
|
|
917
|
+
{#each sampleData as row}
|
|
918
|
+
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
|
|
919
|
+
{#each fields.slice(0, 5) as field}
|
|
920
|
+
<td class="px-4 py-2">{row[field] || ''}</td>
|
|
921
|
+
{/each}
|
|
922
|
+
</tr>
|
|
923
|
+
{/each}
|
|
924
|
+
</tbody>
|
|
925
|
+
</table>
|
|
926
|
+
</div>
|
|
927
|
+
</div>
|
|
928
|
+
{/if}
|
|
929
|
+
|
|
930
|
+
<!-- Import Button -->
|
|
931
|
+
<div class="flex justify-center">
|
|
932
|
+
<button
|
|
933
|
+
on:click={importSpreadsheet}
|
|
934
|
+
disabled={isLoading || !fileData || !controlIdField}
|
|
935
|
+
class="px-5 py-2.5 text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
936
|
+
title={!controlIdField ? 'Please select a Control ID field' : ''}
|
|
937
|
+
>
|
|
938
|
+
{#if isLoading}
|
|
939
|
+
<span class="flex items-center">
|
|
940
|
+
<svg
|
|
941
|
+
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
942
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
943
|
+
fill="none"
|
|
944
|
+
viewBox="0 0 24 24"
|
|
945
|
+
>
|
|
946
|
+
<circle
|
|
947
|
+
class="opacity-25"
|
|
948
|
+
cx="12"
|
|
949
|
+
cy="12"
|
|
950
|
+
r="10"
|
|
951
|
+
stroke="currentColor"
|
|
952
|
+
stroke-width="4"
|
|
953
|
+
></circle>
|
|
954
|
+
<path
|
|
955
|
+
class="opacity-75"
|
|
956
|
+
fill="currentColor"
|
|
957
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
958
|
+
></path>
|
|
959
|
+
</svg>
|
|
960
|
+
Importing...
|
|
961
|
+
</span>
|
|
962
|
+
{:else}
|
|
963
|
+
Import to Control Set
|
|
964
|
+
{/if}
|
|
965
|
+
</button>
|
|
966
|
+
</div>
|
|
967
|
+
{/if}
|
|
968
|
+
</div>
|