feat: global quick entry dialog component
This commit is contained in:
255
src/components/QuickEntryDialog.vue
Normal file
255
src/components/QuickEntryDialog.vue
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick, onUnmounted, computed } from 'vue'
|
||||||
|
import { useFocusTrap } from '../utils/focusTrap'
|
||||||
|
import { useAnnouncer } from '../composables/useAnnouncer'
|
||||||
|
import AppSelect from './AppSelect.vue'
|
||||||
|
import AppDatePicker from './AppDatePicker.vue'
|
||||||
|
import { useProjectsStore, type Task } from '../stores/projects'
|
||||||
|
import { useEntriesStore } from '../stores/entries'
|
||||||
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const projectsStore = useProjectsStore()
|
||||||
|
const entriesStore = useEntriesStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
|
||||||
|
const { announce } = useAnnouncer()
|
||||||
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const selectedProjectId = ref<number | null>(null)
|
||||||
|
const selectedTaskId = ref<number | null>(null)
|
||||||
|
const description = ref('')
|
||||||
|
const entryDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
const durationInput = ref('')
|
||||||
|
const billable = ref(1)
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const availableProjects = computed(() => projectsStore.projects.filter(p => !p.archived))
|
||||||
|
|
||||||
|
const canSave = computed(() => !!selectedProjectId.value && !!durationInput.value.trim() && !saving.value)
|
||||||
|
|
||||||
|
watch(selectedProjectId, async (projectId) => {
|
||||||
|
selectedTaskId.value = null
|
||||||
|
if (projectId) {
|
||||||
|
tasks.value = await projectsStore.fetchTasks(projectId)
|
||||||
|
} else {
|
||||||
|
tasks.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.show, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
// Ensure projects are loaded
|
||||||
|
if (projectsStore.projects.length === 0) {
|
||||||
|
await projectsStore.fetchProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
const lastProjectId = parseInt(settingsStore.settings.last_project_id) || 0
|
||||||
|
selectedProjectId.value = lastProjectId || null
|
||||||
|
selectedTaskId.value = null
|
||||||
|
description.value = ''
|
||||||
|
entryDate.value = new Date().toISOString().split('T')[0]
|
||||||
|
durationInput.value = ''
|
||||||
|
billable.value = 1
|
||||||
|
saving.value = false
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (dialogRef.value) {
|
||||||
|
activateTrap(dialogRef.value, { onDeactivate: () => emit('close') })
|
||||||
|
}
|
||||||
|
announce('Quick entry dialog opened')
|
||||||
|
} else {
|
||||||
|
deactivateTrap()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => deactivateTrap())
|
||||||
|
|
||||||
|
function parseDuration(input: string): number | null {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
// H:MM format
|
||||||
|
if (trimmed.includes(':')) {
|
||||||
|
const [h, m] = trimmed.split(':').map(Number)
|
||||||
|
if (isNaN(h) || isNaN(m) || h < 0 || m < 0 || m > 59) return null
|
||||||
|
return h * 3600 + m * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decimal hours
|
||||||
|
const num = parseFloat(trimmed)
|
||||||
|
if (isNaN(num) || num < 0 || num > 24) return null
|
||||||
|
return Math.round(num * 3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!selectedProjectId.value || !durationInput.value) return
|
||||||
|
const duration = parseDuration(durationInput.value)
|
||||||
|
if (duration === null || duration <= 0) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
const startDate = new Date(entryDate.value + 'T09:00:00')
|
||||||
|
const endDate = new Date(startDate.getTime() + duration * 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await entriesStore.createEntry({
|
||||||
|
project_id: selectedProjectId.value,
|
||||||
|
task_id: selectedTaskId.value || undefined,
|
||||||
|
description: description.value || undefined,
|
||||||
|
start_time: startDate.toISOString(),
|
||||||
|
end_time: endDate.toISOString(),
|
||||||
|
duration,
|
||||||
|
billable: billable.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save last-used project
|
||||||
|
await settingsStore.updateSetting('last_project_id', String(selectedProjectId.value))
|
||||||
|
|
||||||
|
toastStore.success('Entry created')
|
||||||
|
announce('Time entry created successfully')
|
||||||
|
emit('saved')
|
||||||
|
emit('close')
|
||||||
|
} catch {
|
||||||
|
toastStore.error('Failed to create entry')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<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="quick-entry-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"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 id="quick-entry-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">
|
||||||
|
Quick Entry
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSave" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project *</label>
|
||||||
|
<AppSelect
|
||||||
|
v-model="selectedProjectId"
|
||||||
|
:options="availableProjects"
|
||||||
|
label-key="name"
|
||||||
|
value-key="id"
|
||||||
|
placeholder="Select project"
|
||||||
|
:searchable="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tasks.length > 0">
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Task</label>
|
||||||
|
<AppSelect
|
||||||
|
v-model="selectedTaskId"
|
||||||
|
:options="tasks"
|
||||||
|
label-key="name"
|
||||||
|
value-key="id"
|
||||||
|
placeholder="No task"
|
||||||
|
:placeholder-value="null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="quick-entry-desc" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
|
||||||
|
<input
|
||||||
|
id="quick-entry-desc"
|
||||||
|
v-model="description"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
||||||
|
placeholder="What did you work on?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Date</label>
|
||||||
|
<AppDatePicker v-model="entryDate" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="quick-entry-duration" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration *</label>
|
||||||
|
<input
|
||||||
|
id="quick-entry-duration"
|
||||||
|
v-model="durationInput"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary font-mono focus:outline-none focus:border-border-visible"
|
||||||
|
placeholder="1:30 or 1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="billable = billable ? 0 : 1"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="!!billable"
|
||||||
|
aria-label="Billable"
|
||||||
|
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
:class="billable ? 'bg-status-running' : 'bg-bg-elevated'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150"
|
||||||
|
:class="billable ? 'translate-x-[18px]' : 'translate-x-[3px]'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span class="text-[0.75rem] text-text-secondary">Billable</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 text-[0.8125rem] text-text-secondary border border-border-subtle rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!canSave"
|
||||||
|
class="px-4 py-2 text-[0.8125rem] bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user