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).
202 lines
7.0 KiB
Vue
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>
|