Files
zeroclock/src/components/JsonImportWizard.vue
Your Name 7b118c1a1c feat: complete export/import cycle and remove sample data
Export now includes invoice_payments and recurring_invoices tables.
Import restored to use ID-based lookups and all fields for clients,
projects, tasks, and time entries. Added missing import support for
timeline_events, calendar_sources, calendar_events, invoice_payments,
and recurring_invoices. Export uses native save dialog instead of blob
download. Removed sample data seeding (seed.rs, UI, command).
2026-02-21 01:34:26 +02:00

202 lines
7.0 KiB
Vue

<script setup lang="ts">
import { watch, ref, computed } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { readTextFile } from '@tauri-apps/plugin-fs'
import { useFocusTrap } from '../utils/focusTrap'
import { useToastStore } from '../stores/toast'
import { Upload, ChevronLeft, ChevronRight, Loader2 } from 'lucide-vue-next'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; imported: [] }>()
const toastStore = useToastStore()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const step = ref(1)
const filePath = ref('')
const parsedData = ref<Record<string, any[]> | null>(null)
const entityCounts = ref<{ key: string; count: number; selected: boolean }[]>([])
const importing = ref(false)
const entityLabels: Record<string, string> = {
clients: 'Clients',
projects: 'Projects',
tasks: 'Tasks',
time_entries: 'Time Entries',
tags: 'Tags',
entry_tags: 'Entry Tags',
invoices: 'Invoices',
invoice_items: 'Invoice Items',
invoice_payments: 'Invoice Payments',
recurring_invoices: 'Recurring Invoices',
expenses: 'Expenses',
favorites: 'Favorites',
recurring_entries: 'Recurring Entries',
tracked_apps: 'Tracked Apps',
timeline_events: 'Timeline Events',
calendar_sources: 'Calendar Sources',
calendar_events: 'Calendar Events',
timesheet_locks: 'Timesheet Locks',
timesheet_rows: 'Timesheet Rows',
entry_templates: 'Entry Templates',
settings: 'Settings',
}
async function pickFile() {
const selected = await open({
multiple: false,
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (selected) {
filePath.value = selected as string
try {
const text = await readTextFile(selected as string)
parsedData.value = JSON.parse(text)
entityCounts.value = Object.entries(parsedData.value!)
.filter(([, arr]) => Array.isArray(arr) && arr.length > 0)
.map(([key, arr]) => ({ key, count: arr.length, selected: true }))
step.value = 2
} catch {
toastStore.error('Failed to parse JSON file')
}
}
}
const selectedCount = computed(() => entityCounts.value.filter(e => e.selected).length)
async function runImport() {
if (!parsedData.value) return
importing.value = true
try {
const data: Record<string, any[]> = {}
for (const entity of entityCounts.value) {
if (entity.selected) {
data[entity.key] = parsedData.value[entity.key]
}
}
await invoke('import_json_data', { data: JSON.stringify(data) })
const totalItems = entityCounts.value.filter(e => e.selected).reduce((sum, e) => sum + e.count, 0)
toastStore.success(`Imported ${totalItems} items`)
emit('imported')
emit('close')
} catch (e) {
toastStore.error('Import failed: ' + String(e))
} finally {
importing.value = false
}
}
watch(() => props.show, (val) => {
if (val) {
step.value = 1
filePath.value = ''
parsedData.value = null
entityCounts.value = []
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="import-title"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6"
>
<h2 id="import-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
Restore from Backup
</h2>
<!-- Step 1: File selection -->
<div v-if="step === 1" class="text-center py-4">
<button
@click="pickFile"
class="inline-flex items-center gap-2 px-6 py-3 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
<Upload class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
Select JSON File
</button>
<p class="text-[0.6875rem] text-text-tertiary mt-3">Choose a ZeroClock backup .json file</p>
</div>
<!-- Step 2: Preview and select -->
<div v-else-if="step === 2">
<p class="text-[0.75rem] text-text-secondary mb-3">
Found {{ entityCounts.length }} data types. Select which to import:
</p>
<div class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
<label
v-for="entity in entityCounts"
:key="entity.key"
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-elevated transition-colors duration-100 cursor-pointer"
>
<input
type="checkbox"
v-model="entity.selected"
class="w-4 h-4 rounded border-border-subtle text-accent focus:ring-accent"
/>
<span class="flex-1 text-[0.8125rem] text-text-primary">{{ entityLabels[entity.key] || entity.key }}</span>
<span class="text-[0.6875rem] text-text-tertiary">{{ entity.count }}</span>
</label>
</div>
</div>
<!-- Step 3: Importing -->
<div v-else-if="step === 3" class="flex items-center justify-center gap-3 py-8">
<Loader2 class="w-5 h-5 text-accent animate-spin" :stroke-width="1.5" aria-hidden="true" />
<span class="text-[0.8125rem] text-text-secondary">Importing data...</span>
</div>
<div v-if="step === 2" class="flex justify-between mt-4">
<button
@click="step = 1"
class="inline-flex items-center gap-1 px-3 py-2 text-[0.8125rem] text-text-secondary hover:text-text-primary transition-colors duration-150"
>
<ChevronLeft class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
Back
</button>
<div class="flex gap-3">
<button
@click="$emit('close')"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="step = 3; runImport()"
:disabled="selectedCount === 0"
class="inline-flex items-center gap-1 px-4 py-2 text-[0.8125rem] bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-50"
>
Import
<ChevronRight class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
</button>
</div>
</div>
<div v-if="step === 1" class="flex justify-end mt-4">
<button
@click="$emit('close')"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
</div>
</div>
</div>
</Transition>
</template>