tooltips, two-column timer, font selector, tray behavior, icons, readme
- Custom tooltip directive (WCAG AAA) on every button in the app - Two-column timer layout with sticky hero and recent entries sidebar - Timer font selector with 16 monospace Google Fonts and live preview - UI font selector with 15+ Google Fonts - Close-to-tray and minimize-to-tray settings - New app icons (no-glow variants), platform icon set - Mini timer pop-out window - Favorites strip with drag-reorder and inline actions - README with feature documentation - Remove tracked files that belong in gitignore
This commit is contained in:
133
src/App.vue
133
src/App.vue
@@ -17,6 +17,8 @@ import TourOverlay from './components/TourOverlay.vue'
|
||||
import RecurringPromptDialog from './components/RecurringPromptDialog.vue'
|
||||
import TimerSaveDialog from './components/TimerSaveDialog.vue'
|
||||
import QuickEntryDialog from './components/QuickEntryDialog.vue'
|
||||
import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue'
|
||||
import GlobalSearchDialog from './components/GlobalSearchDialog.vue'
|
||||
import { useOnboardingStore } from './stores/onboarding'
|
||||
import { useProjectsStore } from './stores/projects'
|
||||
import { useInvoicesStore } from './stores/invoices'
|
||||
@@ -26,6 +28,8 @@ const recurringStore = useRecurringStore()
|
||||
const timerStore = useTimerStore()
|
||||
const { announcement } = useAnnouncer()
|
||||
const showQuickEntry = ref(false)
|
||||
const showShortcuts = ref(false)
|
||||
const showSearch = ref(false)
|
||||
|
||||
function getProjectName(projectId?: number): string {
|
||||
if (!projectId) return ''
|
||||
@@ -39,7 +43,10 @@ function getProjectColor(projectId?: number): string {
|
||||
return projectsStore.projects.find(p => p.id === projectId)?.color || '#6B7280'
|
||||
}
|
||||
|
||||
let shortcutRegistering = false
|
||||
async function registerShortcuts() {
|
||||
if (shortcutRegistering) return
|
||||
shortcutRegistering = true
|
||||
try {
|
||||
const { unregisterAll, register } = await import('@tauri-apps/plugin-global-shortcut')
|
||||
await unregisterAll()
|
||||
@@ -72,6 +79,8 @@ async function registerShortcuts() {
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to register shortcuts:', e)
|
||||
} finally {
|
||||
shortcutRegistering = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +98,39 @@ function applyTheme() {
|
||||
el.setAttribute('data-accent', accent)
|
||||
}
|
||||
|
||||
function daysDiff(a: string, b: string): number {
|
||||
const ms = new Date(b).getTime() - new Date(a).getTime()
|
||||
return Math.floor(ms / 86400000)
|
||||
}
|
||||
|
||||
async function checkScheduledBackup() {
|
||||
const s = settingsStore.settings
|
||||
if (s.auto_backup !== 'true' || !s.backup_path) return
|
||||
const lastBackup = s.auto_backup_last || ''
|
||||
const frequency = s.auto_backup_frequency || 'daily'
|
||||
const retention = parseInt(s.auto_backup_retention || '7')
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const isDue = !lastBackup || (frequency === 'daily' && lastBackup < today) ||
|
||||
(frequency === 'weekly' && daysDiff(lastBackup, today) >= 7)
|
||||
if (!isDue) return
|
||||
|
||||
try {
|
||||
await invoke('auto_backup', { backupDir: s.backup_path })
|
||||
await settingsStore.updateSetting('auto_backup_last', today)
|
||||
const toastStore = useToastStore()
|
||||
const files = await invoke<any[]>('list_backup_files', { backupDir: s.backup_path })
|
||||
if (files.length > retention) {
|
||||
for (const old of files.slice(retention)) {
|
||||
await invoke('delete_backup_file', { path: old.path })
|
||||
}
|
||||
}
|
||||
toastStore.success('Auto-backup completed')
|
||||
} catch (e) {
|
||||
console.error('Scheduled backup failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function applyMotion() {
|
||||
const setting = settingsStore.settings.reduce_motion || 'system'
|
||||
const el = document.documentElement
|
||||
@@ -184,15 +226,94 @@ onMounted(async () => {
|
||||
|
||||
const invoicesStore = useInvoicesStore()
|
||||
await invoicesStore.fetchInvoices()
|
||||
await invoicesStore.checkOverdue()
|
||||
const overdueCount = await invoicesStore.checkOverdue()
|
||||
if (overdueCount > 0) {
|
||||
const toastStore = useToastStore()
|
||||
toastStore.info(`${overdueCount} invoice(s) now overdue`)
|
||||
}
|
||||
|
||||
await checkScheduledBackup()
|
||||
|
||||
// End-of-day reminder and weekly summary checks
|
||||
const reminderState = { eodShownToday: '', weeklySummaryShownWeek: '' }
|
||||
|
||||
async function checkReminders() {
|
||||
const now = new Date()
|
||||
const todayStr = now.toISOString().split('T')[0]
|
||||
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
|
||||
// End-of-day reminder
|
||||
if (settingsStore.settings.eod_reminder_enabled === 'true' && reminderState.eodShownToday !== todayStr) {
|
||||
const reminderTime = settingsStore.settings.eod_reminder_time || '17:00'
|
||||
if (currentTime >= reminderTime) {
|
||||
reminderState.eodShownToday = todayStr
|
||||
try {
|
||||
const entries = await invoke<any[]>('get_time_entries', { startDate: todayStr, endDate: todayStr })
|
||||
const totalSeconds = entries.reduce((sum: number, e: any) => sum + (e.duration || 0), 0)
|
||||
const totalHours = totalSeconds / 3600
|
||||
const goalHours = parseFloat(settingsStore.settings.daily_goal_hours) || 8
|
||||
if (totalHours < goalHours) {
|
||||
const remaining = (goalHours - totalHours).toFixed(1)
|
||||
const toastStore = useToastStore()
|
||||
toastStore.info(`End of day: ${totalHours.toFixed(1)}h logged today, ${remaining}h remaining to reach your ${goalHours}h goal`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly summary (Monday check)
|
||||
if (settingsStore.settings.weekly_summary_enabled === 'true' && now.getDay() === 1) {
|
||||
const weekId = todayStr
|
||||
if (reminderState.weeklySummaryShownWeek !== weekId && now.getHours() >= 9) {
|
||||
reminderState.weeklySummaryShownWeek = weekId
|
||||
try {
|
||||
const lastMonday = new Date(now)
|
||||
lastMonday.setDate(now.getDate() - 7)
|
||||
const lastSunday = new Date(now)
|
||||
lastSunday.setDate(now.getDate() - 1)
|
||||
const entries = await invoke<any[]>('get_time_entries', {
|
||||
startDate: lastMonday.toISOString().split('T')[0],
|
||||
endDate: lastSunday.toISOString().split('T')[0],
|
||||
})
|
||||
const totalSeconds = entries.reduce((sum: number, e: any) => sum + (e.duration || 0), 0)
|
||||
const totalHours = totalSeconds / 3600
|
||||
const goalHours = parseFloat(settingsStore.settings.weekly_goal_hours) || 40
|
||||
const toastStore = useToastStore()
|
||||
toastStore.info(`Weekly summary: ${totalHours.toFixed(1)}h logged last week (goal: ${goalHours}h)`)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkReminders()
|
||||
setInterval(checkReminders, 60000)
|
||||
|
||||
registerShortcuts()
|
||||
|
||||
// Auto-backup on window close
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
showSearch.value = true
|
||||
return
|
||||
}
|
||||
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
if ((e.target as HTMLElement)?.isContentEditable) return
|
||||
e.preventDefault()
|
||||
showShortcuts.value = !showShortcuts.value
|
||||
}
|
||||
})
|
||||
|
||||
// Handle window close - backup and optionally hide to tray
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
const win = getCurrentWindow()
|
||||
win.onCloseRequested(async () => {
|
||||
win.onCloseRequested(async (event) => {
|
||||
if (settingsStore.settings.auto_backup === 'true' && settingsStore.settings.backup_path) {
|
||||
try {
|
||||
await invoke('auto_backup', { backupDir: settingsStore.settings.backup_path })
|
||||
@@ -200,6 +321,10 @@ onMounted(async () => {
|
||||
console.error('Auto-backup failed:', e)
|
||||
}
|
||||
}
|
||||
if (settingsStore.settings.close_to_tray === 'true') {
|
||||
event.preventDefault()
|
||||
await win.hide()
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to register close handler:', e)
|
||||
@@ -320,4 +445,6 @@ watch(() => settingsStore.settings.persistent_notifications, (val) => {
|
||||
<div id="route-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="announcer" class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
|
||||
<TourOverlay />
|
||||
<KeyboardShortcutsDialog :show="showShortcuts" @close="showShortcuts = false" />
|
||||
<GlobalSearchDialog :show="showSearch" @close="showSearch = false" />
|
||||
</template>
|
||||
|
||||
115
src/components/AppCascadeDeleteDialog.vue
Normal file
115
src/components/AppCascadeDeleteDialog.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { AlertTriangle } from 'lucide-vue-next'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
entityType: string
|
||||
entityName: string
|
||||
impacts: { label: string; count: number }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
const deleteReady = ref(false)
|
||||
const countdown = ref(3)
|
||||
const liveAnnouncement = ref('')
|
||||
let countdownTimer: number | null = null
|
||||
|
||||
const { activate, deactivate } = useFocusTrap()
|
||||
|
||||
watch(() => props.show, async (val) => {
|
||||
if (val) {
|
||||
deleteReady.value = false
|
||||
countdown.value = 3
|
||||
liveAnnouncement.value = `Delete ${props.entityName}? This will also remove related data. Delete button available in 3 seconds.`
|
||||
await nextTick()
|
||||
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('cancel') })
|
||||
countdownTimer = window.setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
deleteReady.value = true
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
deactivate()
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#app">
|
||||
<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-[60]"
|
||||
@click.self="emit('cancel')"
|
||||
>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cascade-delete-title"
|
||||
aria-describedby="cascade-delete-desc"
|
||||
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6"
|
||||
>
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle class="w-5 h-5 text-status-error shrink-0 mt-0.5" :stroke-width="2" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 id="cascade-delete-title" class="text-[0.875rem] font-semibold text-text-primary">
|
||||
Delete {{ entityName }}?
|
||||
</h2>
|
||||
<p id="cascade-delete-desc" class="text-[0.75rem] text-text-secondary mt-1">
|
||||
This will permanently delete the {{ entityType }} and all related data:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-1.5 mb-4 pl-8" role="list" aria-label="Data that will be deleted">
|
||||
<li
|
||||
v-for="impact in impacts.filter(i => i.count > 0)"
|
||||
:key="impact.label"
|
||||
class="text-[0.75rem] text-text-secondary"
|
||||
>
|
||||
{{ impact.count }} {{ impact.label }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
@click="emit('cancel')"
|
||||
class="px-3 py-1.5 text-[0.75rem] text-text-secondary hover:text-text-primary transition-colors duration-150 rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="deleteReady && emit('confirm')"
|
||||
:disabled="!deleteReady"
|
||||
:aria-disabled="!deleteReady"
|
||||
:aria-label="'Permanently delete ' + entityName + ' and all related data'"
|
||||
class="px-3 py-1.5 text-[0.75rem] font-medium rounded-md transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
:class="deleteReady
|
||||
? 'bg-status-error text-white hover:bg-red-600'
|
||||
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'"
|
||||
>
|
||||
{{ deleteReady ? 'Delete Everything' : `Wait ${countdown}s...` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ liveAnnouncement }}</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Pipette } from 'lucide-vue-next'
|
||||
import { computeDropdownPosition } from '../utils/dropdown'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
@@ -16,6 +17,8 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const panelRef = ref<HTMLDivElement | null>(null)
|
||||
@@ -245,7 +248,7 @@ function onHuePointerUp() {
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330 })
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330, panelEl: panelRef.value })
|
||||
}
|
||||
|
||||
// ── Open / Close ────────────────────────────────────────────────────
|
||||
@@ -261,15 +264,18 @@ function open() {
|
||||
updatePosition()
|
||||
|
||||
nextTick(() => {
|
||||
updatePosition()
|
||||
drawGradient()
|
||||
drawHueStrip()
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
if (panelRef.value) activateTrap(panelRef.value)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
deactivateTrap()
|
||||
isOpen.value = false
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
@@ -296,6 +302,34 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
|
||||
// ── Keyboard Handlers for Accessibility ─────────────────────────────
|
||||
|
||||
function onGradientKeydown(e: KeyboardEvent) {
|
||||
const step = 5
|
||||
let handled = false
|
||||
if (e.key === 'ArrowRight') { saturation.value = Math.min(100, saturation.value + step); handled = true }
|
||||
else if (e.key === 'ArrowLeft') { saturation.value = Math.max(0, saturation.value - step); handled = true }
|
||||
else if (e.key === 'ArrowUp') { brightness.value = Math.min(100, brightness.value + step); handled = true }
|
||||
else if (e.key === 'ArrowDown') { brightness.value = Math.max(0, brightness.value - step); handled = true }
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
emitColor()
|
||||
}
|
||||
}
|
||||
|
||||
function onHueKeydown(e: KeyboardEvent) {
|
||||
const step = 5
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
hue.value = Math.min(360, hue.value + step)
|
||||
emitColor()
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
hue.value = Math.max(0, hue.value - step)
|
||||
emitColor()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -305,6 +339,9 @@ onBeforeUnmount(() => {
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
aria-label="Color picker"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
|
||||
:style="
|
||||
isOpen
|
||||
@@ -317,20 +354,26 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
>
|
||||
<span
|
||||
role="img"
|
||||
:aria-label="'Current color: ' + (modelValue?.toUpperCase() || '#000000')"
|
||||
class="w-5 h-5 rounded-md border border-border-subtle shrink-0"
|
||||
:style="{ backgroundColor: modelValue }"
|
||||
/>
|
||||
<span class="text-text-primary font-mono text-[0.75rem] tracking-wide">{{ modelValue?.toUpperCase() || '#000000' }}</span>
|
||||
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" />
|
||||
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<!-- Color picker popover -->
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
:style="panelStyle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Choose color"
|
||||
@keydown.escape.prevent="close"
|
||||
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
|
||||
>
|
||||
<!-- Preset swatches -->
|
||||
@@ -341,7 +384,9 @@ onBeforeUnmount(() => {
|
||||
:key="c"
|
||||
type="button"
|
||||
@click="selectPreset(c)"
|
||||
class="w-6 h-6 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
|
||||
:aria-label="'Color preset ' + c"
|
||||
:aria-pressed="currentHex === c.toUpperCase()"
|
||||
class="w-8 h-8 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
|
||||
:class="currentHex === c.toUpperCase() ? 'border-text-primary' : 'border-transparent'"
|
||||
:style="{ backgroundColor: c }"
|
||||
/>
|
||||
@@ -351,11 +396,15 @@ onBeforeUnmount(() => {
|
||||
<!-- Saturation/Brightness gradient -->
|
||||
<div class="px-3 pb-2">
|
||||
<div
|
||||
role="application"
|
||||
aria-label="Saturation and brightness"
|
||||
tabindex="0"
|
||||
class="relative rounded-lg overflow-hidden cursor-crosshair"
|
||||
style="touch-action: none;"
|
||||
@pointerdown="onGradientPointerDown"
|
||||
@pointermove="onGradientPointerMove"
|
||||
@pointerup="onGradientPointerUp"
|
||||
@keydown="onGradientKeydown"
|
||||
>
|
||||
<canvas
|
||||
ref="gradientRef"
|
||||
@@ -378,11 +427,18 @@ onBeforeUnmount(() => {
|
||||
<!-- Hue slider -->
|
||||
<div class="px-3 pb-2">
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Hue"
|
||||
:aria-valuenow="Math.round(hue)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="360"
|
||||
tabindex="0"
|
||||
class="relative rounded-md overflow-hidden cursor-pointer"
|
||||
style="touch-action: none;"
|
||||
@pointerdown="onHuePointerDown"
|
||||
@pointermove="onHuePointerMove"
|
||||
@pointerup="onHuePointerUp"
|
||||
@keydown="onHueKeydown"
|
||||
>
|
||||
<canvas
|
||||
ref="hueRef"
|
||||
@@ -404,6 +460,8 @@ onBeforeUnmount(() => {
|
||||
<!-- Hex input + preview -->
|
||||
<div class="px-3 pb-3 flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
:aria-label="'Selected color: ' + currentHex"
|
||||
class="w-8 h-8 rounded-lg border border-border-subtle shrink-0"
|
||||
:style="{ backgroundColor: currentHex }"
|
||||
/>
|
||||
@@ -412,6 +470,7 @@ onBeforeUnmount(() => {
|
||||
@input="onHexInput"
|
||||
type="text"
|
||||
maxlength="7"
|
||||
aria-label="Hex color value"
|
||||
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono tracking-wide placeholder-text-tertiary focus:outline-none focus:border-border-visible"
|
||||
placeholder="#D97706"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { getLocaleCode } from '../utils/locale'
|
||||
import { getFixedPositionMapping } from '../utils/dropdown'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
@@ -24,6 +26,8 @@ const emit = defineEmits<{
|
||||
'update:minute': [value: number]
|
||||
}>()
|
||||
|
||||
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const panelRef = ref<HTMLDivElement | null>(null)
|
||||
@@ -51,6 +55,9 @@ const displayText = computed(() => {
|
||||
return datePart
|
||||
})
|
||||
|
||||
// ── Reduced motion check ────────────────────────────────────────────
|
||||
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
|
||||
|
||||
// ── Time wheel ──────────────────────────────────────────────────────
|
||||
const WHEEL_ITEM_H = 36
|
||||
const WHEEL_VISIBLE = 5
|
||||
@@ -102,7 +109,7 @@ function onHourWheel(e: WheelEvent) {
|
||||
const dir = e.deltaY > 0 ? 1 : -1
|
||||
const cur = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
|
||||
const next = Math.min(23, Math.max(0, cur + dir))
|
||||
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' })
|
||||
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
|
||||
function onMinuteWheel(e: WheelEvent) {
|
||||
@@ -111,7 +118,29 @@ function onMinuteWheel(e: WheelEvent) {
|
||||
const dir = e.deltaY > 0 ? 1 : -1
|
||||
const cur = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
|
||||
const next = Math.min(59, Math.max(0, cur + dir))
|
||||
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' })
|
||||
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
|
||||
// Keyboard support for time wheels
|
||||
function onWheelKeydown(e: KeyboardEvent, type: 'hour' | 'minute') {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
|
||||
e.preventDefault()
|
||||
const dir = e.key === 'ArrowUp' ? -1 : 1
|
||||
if (type === 'hour') {
|
||||
const next = Math.min(23, Math.max(0, internalHour.value + dir))
|
||||
internalHour.value = next
|
||||
emit('update:hour', next)
|
||||
if (hourWheelRef.value) {
|
||||
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
} else {
|
||||
const next = Math.min(59, Math.max(0, internalMinute.value + dir))
|
||||
internalMinute.value = next
|
||||
emit('update:minute', next)
|
||||
if (minuteWheelRef.value) {
|
||||
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click-and-drag support
|
||||
@@ -141,7 +170,7 @@ function onWheelPointerUp(e: PointerEvent) {
|
||||
el.releasePointerCapture(e.pointerId)
|
||||
// Snap to nearest item
|
||||
const index = Math.round(el.scrollTop / WHEEL_ITEM_H)
|
||||
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: 'smooth' })
|
||||
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
|
||||
function scrollWheelsToTime() {
|
||||
@@ -234,33 +263,28 @@ const dayCells = computed<DayCell[]>(() => {
|
||||
const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
|
||||
// ── Positioning ─────────────────────────────────────────────────────
|
||||
function getZoomFactor(): number {
|
||||
const app = document.getElementById('app')
|
||||
if (!app) return 1
|
||||
const zoom = (app.style as any).zoom
|
||||
return zoom ? parseFloat(zoom) / 100 : 1
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
const zoom = getZoomFactor()
|
||||
const panelWidth = props.showTime ? 390 : 280
|
||||
const renderedWidth = panelWidth * zoom
|
||||
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
|
||||
const gap = 4
|
||||
|
||||
let leftViewport = rect.left
|
||||
if (leftViewport + renderedWidth > window.innerWidth) {
|
||||
leftViewport = window.innerWidth - renderedWidth - 8
|
||||
const panelWidth = props.showTime ? 390 : 280
|
||||
const estW = panelWidth * scaleX
|
||||
const vpW = window.innerWidth
|
||||
|
||||
let leftVP = rect.left
|
||||
if (leftVP + estW > vpW - gap) {
|
||||
leftVP = vpW - estW - gap
|
||||
}
|
||||
if (leftViewport < 0) leftViewport = 0
|
||||
if (leftVP < gap) leftVP = gap
|
||||
|
||||
panelStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${(rect.bottom + 4) / zoom}px`,
|
||||
left: `${leftViewport / zoom}px`,
|
||||
top: `${(rect.bottom + gap - offsetY) / scaleY}px`,
|
||||
left: `${(leftVP - offsetX) / scaleX}px`,
|
||||
width: `${panelWidth}px`,
|
||||
zIndex: '9999',
|
||||
zoom: `${zoom * 100}%`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,10 +319,12 @@ function open() {
|
||||
if (props.showTime) {
|
||||
scrollWheelsToTime()
|
||||
}
|
||||
if (panelRef.value) activateTrap(panelRef.value)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
deactivateTrap()
|
||||
isOpen.value = false
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
@@ -385,6 +411,8 @@ onBeforeUnmount(() => {
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
|
||||
:style="
|
||||
isOpen
|
||||
@@ -403,18 +431,23 @@ onBeforeUnmount(() => {
|
||||
{{ displayText ?? placeholder }}
|
||||
</span>
|
||||
<Calendar
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 text-text-secondary shrink-0"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Calendar popover -->
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
:style="panelStyle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Date picker"
|
||||
@keydown.escape.prevent="close"
|
||||
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
|
||||
>
|
||||
<!-- Month/year header -->
|
||||
@@ -422,9 +455,11 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
type="button"
|
||||
@click="prevMonthNav"
|
||||
aria-label="Previous month"
|
||||
v-tooltip="'Previous month'"
|
||||
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" :stroke-width="2" />
|
||||
<ChevronLeft aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
|
||||
</button>
|
||||
<span class="text-[0.8125rem] font-medium text-text-primary select-none">
|
||||
{{ viewMonthLabel }}
|
||||
@@ -432,9 +467,11 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
type="button"
|
||||
@click="nextMonthNav"
|
||||
aria-label="Next month"
|
||||
v-tooltip="'Next month'"
|
||||
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" :stroke-width="2" />
|
||||
<ChevronRight aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -443,10 +480,11 @@ onBeforeUnmount(() => {
|
||||
<!-- Calendar column -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="grid grid-cols-7 px-2">
|
||||
<div class="grid grid-cols-7 px-2" role="row">
|
||||
<div
|
||||
v-for="header in dayHeaders"
|
||||
:key="header"
|
||||
role="columnheader"
|
||||
class="text-center text-[0.6875rem] font-medium text-text-tertiary py-1 select-none"
|
||||
>
|
||||
{{ header }}
|
||||
@@ -454,13 +492,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- Day grid -->
|
||||
<div class="grid grid-cols-7 px-2 pb-2">
|
||||
<div class="grid grid-cols-7 px-2 pb-2" role="grid" aria-label="Calendar days">
|
||||
<button
|
||||
v-for="(cell, index) in dayCells"
|
||||
:key="index"
|
||||
type="button"
|
||||
:disabled="!cell.isCurrentMonth"
|
||||
@click="selectDay(cell)"
|
||||
:aria-label="new Date(cell.year, cell.month, cell.date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })"
|
||||
:aria-selected="cell.isCurrentMonth ? cell.dateString === modelValue : undefined"
|
||||
class="relative flex items-center justify-center h-8 w-full text-[0.75rem] rounded-lg transition-colors select-none"
|
||||
:class="[
|
||||
!cell.isCurrentMonth
|
||||
@@ -493,10 +533,17 @@ onBeforeUnmount(() => {
|
||||
<!-- Scrollable wheel -->
|
||||
<div
|
||||
ref="hourWheelRef"
|
||||
tabindex="0"
|
||||
role="spinbutton"
|
||||
:aria-valuenow="internalHour"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="23"
|
||||
aria-label="Hour"
|
||||
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
|
||||
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
|
||||
@scroll="onHourScroll"
|
||||
@wheel.prevent="onHourWheel"
|
||||
@keydown="onWheelKeydown($event, 'hour')"
|
||||
@pointerdown.prevent="onWheelPointerDown"
|
||||
@pointermove="onWheelPointerMove"
|
||||
@pointerup="onWheelPointerUp"
|
||||
@@ -530,10 +577,17 @@ onBeforeUnmount(() => {
|
||||
<!-- Scrollable wheel -->
|
||||
<div
|
||||
ref="minuteWheelRef"
|
||||
tabindex="0"
|
||||
role="spinbutton"
|
||||
:aria-valuenow="internalMinute"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="59"
|
||||
aria-label="Minute"
|
||||
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
|
||||
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
|
||||
@scroll="onMinuteScroll"
|
||||
@wheel.prevent="onMinuteWheel"
|
||||
@keydown="onWheelKeydown($event, 'minute')"
|
||||
@pointerdown.prevent="onWheelPointerDown"
|
||||
@pointermove="onWheelPointerMove"
|
||||
@pointerup="onWheelPointerUp"
|
||||
|
||||
169
src/components/AppDateRangePresets.vue
Normal file
169
src/components/AppDateRangePresets.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Date range presets"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button
|
||||
v-for="(preset, index) in presets"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
:aria-pressed="isActive(preset)"
|
||||
:tabindex="index === focusedIndex ? 0 : -1"
|
||||
:ref="(el) => { if (el) buttonRefs[index] = el as HTMLButtonElement }"
|
||||
class="px-3 py-1 text-[0.6875rem] font-medium rounded-full border transition-colors duration-150"
|
||||
:class="isActive(preset)
|
||||
? 'bg-accent text-bg-base border-accent'
|
||||
: 'border-border-subtle text-text-secondary hover:text-text-primary hover:border-border-visible'"
|
||||
@click="selectPreset(preset)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [payload: { start: string; end: string }]
|
||||
}>()
|
||||
|
||||
const focusedIndex = ref(0)
|
||||
const buttonRefs = ref<HTMLButtonElement[]>([])
|
||||
|
||||
interface Preset {
|
||||
label: string
|
||||
getRange: () => { start: string; end: string }
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${dd}`
|
||||
}
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const result = new Date(d)
|
||||
const day = result.getDay()
|
||||
const diff = day === 0 ? -6 : 1 - day
|
||||
result.setDate(result.getDate() + diff)
|
||||
return result
|
||||
}
|
||||
|
||||
const presets: Preset[] = [
|
||||
{
|
||||
label: 'Today',
|
||||
getRange: () => {
|
||||
const today = fmt(new Date())
|
||||
return { start: today, end: today }
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This Week',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const monday = getMonday(now)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
return { start: fmt(monday), end: fmt(sunday) }
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last Week',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const thisMonday = getMonday(now)
|
||||
const lastMonday = new Date(thisMonday)
|
||||
lastMonday.setDate(thisMonday.getDate() - 7)
|
||||
const lastSunday = new Date(lastMonday)
|
||||
lastSunday.setDate(lastMonday.getDate() + 6)
|
||||
return { start: fmt(lastMonday), end: fmt(lastSunday) }
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This Month',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const last = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
return { start: fmt(first), end: fmt(last) }
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last Month',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const last = new Date(now.getFullYear(), now.getMonth(), 0)
|
||||
return { start: fmt(first), end: fmt(last) }
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This Quarter',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const qMonth = Math.floor(now.getMonth() / 3) * 3
|
||||
const first = new Date(now.getFullYear(), qMonth, 1)
|
||||
const last = new Date(now.getFullYear(), qMonth + 3, 0)
|
||||
return { start: fmt(first), end: fmt(last) }
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 30 Days',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now)
|
||||
start.setDate(now.getDate() - 29)
|
||||
return { start: fmt(start), end: fmt(now) }
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This Year',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const first = new Date(now.getFullYear(), 0, 1)
|
||||
return { start: fmt(first), end: fmt(now) }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function isActive(preset: Preset): boolean {
|
||||
if (!props.startDate || !props.endDate) return false
|
||||
const range = preset.getRange()
|
||||
return range.start === props.startDate && range.end === props.endDate
|
||||
}
|
||||
|
||||
function selectPreset(preset: Preset) {
|
||||
const range = preset.getRange()
|
||||
emit('select', range)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
let next = focusedIndex.value
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
next = (focusedIndex.value + 1) % presets.length
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
next = (focusedIndex.value - 1 + presets.length) % presets.length
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
next = 0
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
next = presets.length - 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
focusedIndex.value = next
|
||||
buttonRefs.value[next]?.focus()
|
||||
}
|
||||
</script>
|
||||
@@ -1,33 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ show: boolean }>()
|
||||
defineEmits<{
|
||||
import { watch, ref } from 'vue'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
discard: []
|
||||
}>()
|
||||
|
||||
const { activate, deactivate } = useFocusTrap()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
setTimeout(() => {
|
||||
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('cancel') })
|
||||
}, 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-[60]"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
@click.self="$emit('cancel')"
|
||||
>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-xs p-6">
|
||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Unsaved Changes</h2>
|
||||
<p class="text-[0.75rem] text-text-secondary mb-6">
|
||||
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="discard-title" aria-describedby="discard-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-xs p-6">
|
||||
<h2 id="discard-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Unsaved Changes</h2>
|
||||
<p id="discard-desc" class="text-[0.75rem] text-text-secondary mb-6">
|
||||
You have unsaved changes. Do you want to discard them?
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
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"
|
||||
>
|
||||
Keep Editing
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('discard')"
|
||||
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
class="px-4 py-2 text-[0.8125rem] border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Props {
|
||||
precision?: number
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
label?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -19,6 +21,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
precision: 0,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
label: 'Number input',
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -84,24 +88,39 @@ function cancelEdit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="compact ? 'gap-1' : 'gap-2'"
|
||||
role="group"
|
||||
:aria-label="label"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Decrease value"
|
||||
v-tooltip="'Decrease'"
|
||||
@mousedown.prevent="startHold(decrement)"
|
||||
@mouseup="stopHold"
|
||||
@mouseleave="stopHold"
|
||||
@touchstart.prevent="startHold(decrement)"
|
||||
@touchend="stopHold"
|
||||
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
|
||||
:disabled="modelValue <= min"
|
||||
>
|
||||
<Minus class="w-3.5 h-3.5" :stroke-width="2" />
|
||||
<Minus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
@click="startEdit"
|
||||
class="min-w-[4rem] text-center text-[0.8125rem] font-mono text-text-primary cursor-text select-none"
|
||||
@keydown.enter="startEdit"
|
||||
@keydown.space.prevent="startEdit"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="'Edit value: ' + displayValue"
|
||||
class="text-center font-mono text-text-primary cursor-text select-none"
|
||||
:class="compact ? 'min-w-[2.5rem] text-[0.75rem]' : 'min-w-[4rem] text-[0.8125rem]'"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span v-if="prefix" class="text-text-tertiary">{{ prefix }}</span>
|
||||
{{ displayValue }}
|
||||
@@ -113,7 +132,9 @@ function cancelEdit() {
|
||||
v-model="editValue"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
class="w-20 text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg text-[0.8125rem] font-mono text-text-primary focus:outline-none"
|
||||
:aria-label="label"
|
||||
class="text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg font-mono text-text-primary focus:outline-none"
|
||||
:class="compact ? 'w-16 text-[0.75rem]' : 'w-20 text-[0.8125rem]'"
|
||||
@blur="commitEdit"
|
||||
@keydown.enter="commitEdit"
|
||||
@keydown.escape="cancelEdit"
|
||||
@@ -121,15 +142,18 @@ function cancelEdit() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Increase value"
|
||||
v-tooltip="'Increase'"
|
||||
@mousedown.prevent="startHold(increment)"
|
||||
@mouseup="stopHold"
|
||||
@mouseleave="stopHold"
|
||||
@touchstart.prevent="startHold(increment)"
|
||||
@touchend="stopHold"
|
||||
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
|
||||
:disabled="modelValue >= max"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" :stroke-width="2" />
|
||||
<Plus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Props {
|
||||
disabled?: boolean
|
||||
placeholderValue?: any
|
||||
searchable?: boolean
|
||||
ariaLabelledby?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -27,6 +28,7 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: any]
|
||||
}>()
|
||||
|
||||
const listboxId = 'appselect-lb-' + Math.random().toString(36).slice(2, 9)
|
||||
const isOpen = ref(false)
|
||||
const highlightedIndex = ref(-1)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
@@ -80,7 +82,10 @@ function isSelected(item: any): boolean {
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { estimatedHeight: 280 })
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, {
|
||||
estimatedHeight: 280,
|
||||
panelEl: panelRef.value,
|
||||
})
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -108,6 +113,8 @@ function open() {
|
||||
|
||||
nextTick(() => {
|
||||
scrollHighlightedIntoView()
|
||||
// Reposition with actual panel height (fixes above-flip offset)
|
||||
updatePosition()
|
||||
})
|
||||
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
@@ -220,6 +227,12 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
role="combobox"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="listbox"
|
||||
:aria-activedescendant="isOpen && highlightedIndex >= 0 ? 'appselect-option-' + highlightedIndex : undefined"
|
||||
:aria-labelledby="ariaLabelledby"
|
||||
:aria-controls="isOpen ? listboxId : undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
@@ -238,13 +251,16 @@ onBeforeUnmount(() => {
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
|
||||
class="truncate"
|
||||
>
|
||||
{{ selectedLabel ?? placeholder }}
|
||||
</span>
|
||||
<slot name="selected" :label="selectedLabel ?? placeholder" :is-placeholder="isPlaceholderSelected">
|
||||
<span
|
||||
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
|
||||
class="truncate"
|
||||
>
|
||||
{{ selectedLabel ?? placeholder }}
|
||||
</span>
|
||||
</slot>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 text-text-secondary shrink-0 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
:stroke-width="2"
|
||||
@@ -252,7 +268,7 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<!-- Dropdown panel -->
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
@@ -265,15 +281,19 @@ onBeforeUnmount(() => {
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
aria-label="Search options"
|
||||
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
|
||||
placeholder="Search..."
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-[240px] overflow-y-auto py-1">
|
||||
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" :id="listboxId">
|
||||
<div
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
|
||||
role="option"
|
||||
:id="'appselect-option-' + index"
|
||||
:aria-selected="isSelected(item)"
|
||||
data-option
|
||||
@click="select(item)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
@@ -284,9 +304,12 @@ onBeforeUnmount(() => {
|
||||
'text-text-primary': !item._isPlaceholder,
|
||||
}"
|
||||
>
|
||||
<span class="truncate">{{ getOptionLabel(item) }}</span>
|
||||
<slot name="option" :item="item" :label="getOptionLabel(item)" :selected="isSelected(item)">
|
||||
<span class="truncate">{{ getOptionLabel(item) }}</span>
|
||||
</slot>
|
||||
<Check
|
||||
v-if="isSelected(item)"
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 text-accent shrink-0"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
|
||||
161
src/components/AppShortcutRecorder.vue
Normal file
161
src/components/AppShortcutRecorder.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const recording = ref(false)
|
||||
const announcement = ref('')
|
||||
const recorderRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||
|
||||
const keyChips = computed(() => {
|
||||
if (!props.modelValue) return []
|
||||
return props.modelValue.split('+').map(k =>
|
||||
k === 'CmdOrCtrl' ? (isMac ? 'Cmd' : 'Ctrl') : k
|
||||
)
|
||||
})
|
||||
|
||||
function startRecording() {
|
||||
recording.value = true
|
||||
announcement.value = 'Recording. Press your key combination.'
|
||||
nextTick(() => {
|
||||
recorderRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function cancelRecording() {
|
||||
recording.value = false
|
||||
announcement.value = ''
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!recording.value) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
cancelRecording()
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore standalone modifier keys
|
||||
const modifierKeys = ['Control', 'Shift', 'Alt', 'Meta']
|
||||
if (modifierKeys.includes(e.key)) return
|
||||
|
||||
// Must have at least one modifier
|
||||
const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey || e.altKey
|
||||
if (!hasModifier) return
|
||||
|
||||
// Build the shortcut string
|
||||
const parts: string[] = []
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
parts.push('CmdOrCtrl')
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
parts.push('Shift')
|
||||
}
|
||||
if (e.altKey) {
|
||||
parts.push('Alt')
|
||||
}
|
||||
|
||||
// Normalize the key name
|
||||
let key = e.key
|
||||
if (key === ' ') {
|
||||
key = 'Space'
|
||||
} else if (key.length === 1) {
|
||||
key = key.toUpperCase()
|
||||
}
|
||||
|
||||
parts.push(key)
|
||||
|
||||
const combo = parts.join('+')
|
||||
recording.value = false
|
||||
emit('update:modelValue', combo)
|
||||
announcement.value = `Shortcut set to ${combo}`
|
||||
}
|
||||
|
||||
function clearShortcut() {
|
||||
emit('update:modelValue', '')
|
||||
announcement.value = 'Shortcut cleared'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="group" :aria-label="label || 'Keyboard shortcut'" class="inline-flex items-center gap-2">
|
||||
<!-- Key chips display -->
|
||||
<div v-if="!recording && modelValue" class="flex items-center gap-1" aria-hidden="true">
|
||||
<template v-for="(chip, index) in keyChips" :key="index">
|
||||
<span
|
||||
class="px-1.5 py-0.5 bg-bg-elevated border border-border-subtle rounded text-text-secondary font-mono text-[0.6875rem]"
|
||||
>{{ chip }}</span>
|
||||
<span v-if="index < keyChips.length - 1" class="text-text-tertiary text-[0.6875rem]">+</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Screen reader text -->
|
||||
<span class="sr-only">Current shortcut: {{ modelValue || 'None' }}</span>
|
||||
|
||||
<!-- Recording capture area (focused div) -->
|
||||
<div
|
||||
v-if="recording"
|
||||
ref="recorderRef"
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label="Press your key combination"
|
||||
@keydown="onKeydown"
|
||||
@blur="cancelRecording"
|
||||
class="flex items-center gap-2 px-3 py-1 border border-accent rounded-lg bg-bg-inset focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
<span class="text-[0.75rem] text-accent motion-safe:animate-pulse">Press keys...</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cancel recording"
|
||||
@mousedown.prevent="cancelRecording"
|
||||
class="text-[0.6875rem] text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Record button -->
|
||||
<button
|
||||
v-if="!recording"
|
||||
type="button"
|
||||
aria-label="Record shortcut"
|
||||
@click="startRecording"
|
||||
class="px-2.5 py-1 text-[0.6875rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated hover:text-text-primary transition-colors"
|
||||
>
|
||||
Record
|
||||
</button>
|
||||
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
v-if="!recording && modelValue"
|
||||
type="button"
|
||||
aria-label="Clear shortcut"
|
||||
v-tooltip="'Clear shortcut'"
|
||||
@click="clearShortcut"
|
||||
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<!-- aria-live region for announcements -->
|
||||
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,6 +21,7 @@ const triggerRef = ref<HTMLDivElement | null>(null)
|
||||
const panelRef = ref<HTMLDivElement | null>(null)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const highlightedIndex = ref(-1)
|
||||
|
||||
const selectedTags = computed(() => {
|
||||
return tagsStore.tags.filter(t => t.id && props.modelValue.includes(t.id))
|
||||
@@ -69,14 +70,17 @@ async function createAndAdd() {
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200 })
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200, panelEl: panelRef.value })
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
searchQuery.value = ''
|
||||
updatePosition()
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
nextTick(() => {
|
||||
updatePosition()
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
@@ -99,6 +103,30 @@ function onScrollOrResize() {
|
||||
if (isOpen.value) updatePosition()
|
||||
}
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
const total = filteredTags.value.length + (showCreateOption.value ? 1 : 0)
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, total - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
|
||||
} else if (e.key === 'Enter' && highlightedIndex.value >= 0) {
|
||||
e.preventDefault()
|
||||
if (highlightedIndex.value < filteredTags.value.length) {
|
||||
const tag = filteredTags.value[highlightedIndex.value]
|
||||
toggleTag(tag.id!)
|
||||
searchQuery.value = ''
|
||||
} else if (showCreateOption.value) {
|
||||
createAndAdd()
|
||||
}
|
||||
highlightedIndex.value = -1
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
@@ -116,30 +144,34 @@ onBeforeUnmount(() => {
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-primary"
|
||||
:style="{ backgroundColor: tag.color + '22', borderColor: tag.color + '44', border: '1px solid' }"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
|
||||
{{ tag.name }}
|
||||
<button @click.stop="removeTag(tag.id!)" class="ml-0.5 hover:text-status-error">
|
||||
<X class="w-2.5 h-2.5" />
|
||||
<button @click.stop="removeTag(tag.id!)" :aria-label="'Remove tag ' + tag.name" v-tooltip="'Remove tag'" class="ml-0.5 min-w-[24px] min-h-[24px] flex items-center justify-center hover:text-status-error">
|
||||
<X class="w-2.5 h-2.5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
key="__add_btn__"
|
||||
type="button"
|
||||
@click="isOpen ? close() : open()"
|
||||
aria-label="Add tag"
|
||||
:aria-expanded="isOpen"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-tertiary border border-border-subtle hover:text-text-secondary hover:border-border-visible transition-colors"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
<Plus class="w-3 h-3" aria-hidden="true" />
|
||||
Tag
|
||||
</button>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
:style="panelStyle"
|
||||
role="listbox"
|
||||
aria-label="Tag options"
|
||||
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
|
||||
>
|
||||
<div class="px-2 pt-2 pb-1">
|
||||
@@ -147,26 +179,33 @@ onBeforeUnmount(() => {
|
||||
ref="inputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
aria-label="Search or create tag"
|
||||
@keydown="onSearchKeydown"
|
||||
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
|
||||
placeholder="Search or create tag..."
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-[160px] overflow-y-auto py-1">
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
v-for="(tag, index) in filteredTags"
|
||||
:key="tag.id"
|
||||
:id="'tag-option-' + tag.id"
|
||||
role="option"
|
||||
@click="toggleTag(tag.id!); searchQuery = ''"
|
||||
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors"
|
||||
:class="{ 'bg-bg-elevated': index === highlightedIndex }"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
|
||||
<span class="text-[0.75rem] text-text-primary">{{ tag.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCreateOption"
|
||||
role="option"
|
||||
@click="createAndAdd"
|
||||
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors text-accent-text"
|
||||
:class="{ 'bg-bg-elevated': filteredTags.length === highlightedIndex }"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
<Plus class="w-3 h-3" aria-hidden="true" />
|
||||
<span class="text-[0.75rem]">Create "{{ searchQuery.trim() }}"</span>
|
||||
</div>
|
||||
<div v-if="filteredTags.length === 0 && !showCreateOption" class="px-3 py-3 text-center text-[0.75rem] text-text-tertiary">
|
||||
|
||||
376
src/components/AppTimePicker.vue
Normal file
376
src/components/AppTimePicker.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Clock } from 'lucide-vue-next'
|
||||
import { getFixedPositionMapping } from '../utils/dropdown'
|
||||
|
||||
interface Props {
|
||||
hour: number
|
||||
minute: number
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Select time',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:hour': [value: number]
|
||||
'update:minute': [value: number]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const panelRef = ref<HTMLDivElement | null>(null)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
|
||||
// Reduced motion check
|
||||
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
|
||||
|
||||
// Time wheel constants
|
||||
const WHEEL_ITEM_H = 36
|
||||
const WHEEL_VISIBLE = 5
|
||||
const WHEEL_HEIGHT = WHEEL_ITEM_H * WHEEL_VISIBLE
|
||||
const WHEEL_PAD = WHEEL_ITEM_H * 2
|
||||
|
||||
const internalHour = ref(props.hour)
|
||||
const internalMinute = ref(props.minute)
|
||||
const hourWheelRef = ref<HTMLDivElement | null>(null)
|
||||
const minuteWheelRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
watch(() => props.hour, (v) => { internalHour.value = v })
|
||||
watch(() => props.minute, (v) => { internalMinute.value = v })
|
||||
|
||||
const displayText = computed(() => {
|
||||
const hh = String(props.hour).padStart(2, '0')
|
||||
const mm = String(props.minute).padStart(2, '0')
|
||||
return `${hh}:${mm}`
|
||||
})
|
||||
|
||||
// Debounced scroll handlers
|
||||
let hourScrollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let minuteScrollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onHourScroll() {
|
||||
if (hourScrollTimer) clearTimeout(hourScrollTimer)
|
||||
hourScrollTimer = setTimeout(() => {
|
||||
if (!hourWheelRef.value) return
|
||||
const index = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
|
||||
const clamped = Math.min(23, Math.max(0, index))
|
||||
if (internalHour.value !== clamped) {
|
||||
internalHour.value = clamped
|
||||
emit('update:hour', clamped)
|
||||
}
|
||||
}, 60)
|
||||
}
|
||||
|
||||
function onMinuteScroll() {
|
||||
if (minuteScrollTimer) clearTimeout(minuteScrollTimer)
|
||||
minuteScrollTimer = setTimeout(() => {
|
||||
if (!minuteWheelRef.value) return
|
||||
const index = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
|
||||
const clamped = Math.min(59, Math.max(0, index))
|
||||
if (internalMinute.value !== clamped) {
|
||||
internalMinute.value = clamped
|
||||
emit('update:minute', clamped)
|
||||
}
|
||||
}, 60)
|
||||
}
|
||||
|
||||
// Mouse wheel: one item per tick
|
||||
function onHourWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
if (!hourWheelRef.value) return
|
||||
const dir = e.deltaY > 0 ? 1 : -1
|
||||
const cur = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
|
||||
const next = Math.min(23, Math.max(0, cur + dir))
|
||||
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
|
||||
function onMinuteWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
if (!minuteWheelRef.value) return
|
||||
const dir = e.deltaY > 0 ? 1 : -1
|
||||
const cur = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
|
||||
const next = Math.min(59, Math.max(0, cur + dir))
|
||||
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
|
||||
// Keyboard support
|
||||
function onWheelKeydown(e: KeyboardEvent, type: 'hour' | 'minute') {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
|
||||
e.preventDefault()
|
||||
const dir = e.key === 'ArrowUp' ? -1 : 1
|
||||
if (type === 'hour') {
|
||||
const next = Math.min(23, Math.max(0, internalHour.value + dir))
|
||||
internalHour.value = next
|
||||
emit('update:hour', next)
|
||||
if (hourWheelRef.value) {
|
||||
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
} else {
|
||||
const next = Math.min(59, Math.max(0, internalMinute.value + dir))
|
||||
internalMinute.value = next
|
||||
emit('update:minute', next)
|
||||
if (minuteWheelRef.value) {
|
||||
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click-and-drag support
|
||||
let dragEl: HTMLElement | null = null
|
||||
let dragStartY = 0
|
||||
let dragStartScrollTop = 0
|
||||
|
||||
function onWheelPointerDown(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
dragEl = el
|
||||
dragStartY = e.clientY
|
||||
dragStartScrollTop = el.scrollTop
|
||||
el.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onWheelPointerMove(e: PointerEvent) {
|
||||
if (!dragEl) return
|
||||
e.preventDefault()
|
||||
const delta = dragStartY - e.clientY
|
||||
dragEl.scrollTop = dragStartScrollTop + delta
|
||||
}
|
||||
|
||||
function onWheelPointerUp(e: PointerEvent) {
|
||||
if (!dragEl) return
|
||||
const el = dragEl
|
||||
dragEl = null
|
||||
el.releasePointerCapture(e.pointerId)
|
||||
const index = Math.round(el.scrollTop / WHEEL_ITEM_H)
|
||||
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: scrollBehavior })
|
||||
}
|
||||
|
||||
function scrollWheelsToTime() {
|
||||
if (hourWheelRef.value) {
|
||||
hourWheelRef.value.scrollTop = internalHour.value * WHEEL_ITEM_H
|
||||
}
|
||||
if (minuteWheelRef.value) {
|
||||
minuteWheelRef.value.scrollTop = internalMinute.value * WHEEL_ITEM_H
|
||||
}
|
||||
}
|
||||
|
||||
// Positioning
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
|
||||
const gap = 4
|
||||
|
||||
const panelWidth = 120
|
||||
const estW = panelWidth * scaleX
|
||||
const vpW = window.innerWidth
|
||||
const vpH = window.innerHeight
|
||||
|
||||
let leftVP = rect.left
|
||||
if (leftVP + estW > vpW - gap) {
|
||||
leftVP = vpW - estW - gap
|
||||
}
|
||||
if (leftVP < gap) leftVP = gap
|
||||
|
||||
let topVP = rect.bottom + gap
|
||||
// Use offsetHeight (unaffected by CSS transition transforms)
|
||||
if (panelRef.value) {
|
||||
const panelH = panelRef.value.offsetHeight * scaleY
|
||||
if (topVP + panelH > vpH && rect.top - gap - panelH >= 0) {
|
||||
topVP = rect.top - gap - panelH
|
||||
}
|
||||
}
|
||||
|
||||
panelStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${(topVP - offsetY) / scaleY}px`,
|
||||
left: `${(leftVP - offsetX) / scaleX}px`,
|
||||
zIndex: '9999',
|
||||
}
|
||||
}
|
||||
|
||||
// Open / Close
|
||||
function toggle() {
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
updatePosition()
|
||||
nextTick(() => {
|
||||
// Reposition with actual panel height (fixes above-flip offset)
|
||||
updatePosition()
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
scrollWheelsToTime()
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (
|
||||
triggerRef.value?.contains(target) ||
|
||||
panelRef.value?.contains(target)
|
||||
) {
|
||||
return
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
function onScrollOrResize() {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
|
||||
:style="
|
||||
isOpen
|
||||
? {
|
||||
borderColor: 'var(--color-accent)',
|
||||
boxShadow: '0 0 0 2px var(--color-accent-muted)',
|
||||
outline: 'none',
|
||||
}
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<span class="text-text-primary font-mono">
|
||||
{{ displayText }}
|
||||
</span>
|
||||
<Clock
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 text-text-secondary shrink-0"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Time picker popover -->
|
||||
<Teleport to="#app">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
:style="panelStyle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Time picker"
|
||||
@keydown.escape.prevent="close"
|
||||
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden p-3"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Hour wheel -->
|
||||
<div
|
||||
class="relative overflow-hidden rounded-lg"
|
||||
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 rounded-lg pointer-events-none bg-bg-elevated border border-border-subtle"
|
||||
:style="{ top: WHEEL_PAD + 'px', height: WHEEL_ITEM_H + 'px' }"
|
||||
/>
|
||||
<div
|
||||
ref="hourWheelRef"
|
||||
tabindex="0"
|
||||
role="spinbutton"
|
||||
:aria-valuenow="internalHour"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="23"
|
||||
aria-label="Hour"
|
||||
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
|
||||
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
|
||||
@scroll="onHourScroll"
|
||||
@wheel.prevent="onHourWheel"
|
||||
@keydown="onWheelKeydown($event, 'hour')"
|
||||
@pointerdown.prevent="onWheelPointerDown"
|
||||
@pointermove="onWheelPointerMove"
|
||||
@pointerup="onWheelPointerUp"
|
||||
>
|
||||
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
|
||||
<div
|
||||
v-for="h in 24"
|
||||
:key="h"
|
||||
class="shrink-0 flex items-center justify-center text-[0.875rem] font-mono select-none"
|
||||
:style="{ height: WHEEL_ITEM_H + 'px' }"
|
||||
:class="internalHour === h - 1 ? 'text-text-primary font-semibold' : 'text-text-tertiary'"
|
||||
>
|
||||
{{ String(h - 1).padStart(2, '0') }}
|
||||
</div>
|
||||
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-text-secondary text-sm font-mono font-semibold select-none">:</span>
|
||||
|
||||
<!-- Minute wheel -->
|
||||
<div
|
||||
class="relative overflow-hidden rounded-lg"
|
||||
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 rounded-lg pointer-events-none bg-bg-elevated border border-border-subtle"
|
||||
:style="{ top: WHEEL_PAD + 'px', height: WHEEL_ITEM_H + 'px' }"
|
||||
/>
|
||||
<div
|
||||
ref="minuteWheelRef"
|
||||
tabindex="0"
|
||||
role="spinbutton"
|
||||
:aria-valuenow="internalMinute"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="59"
|
||||
aria-label="Minute"
|
||||
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
|
||||
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
|
||||
@scroll="onMinuteScroll"
|
||||
@wheel.prevent="onMinuteWheel"
|
||||
@keydown="onWheelKeydown($event, 'minute')"
|
||||
@pointerdown.prevent="onWheelPointerDown"
|
||||
@pointermove="onWheelPointerMove"
|
||||
@pointerup="onWheelPointerUp"
|
||||
>
|
||||
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
|
||||
<div
|
||||
v-for="m in 60"
|
||||
:key="m"
|
||||
class="shrink-0 flex items-center justify-center text-[0.875rem] font-mono select-none"
|
||||
:style="{ height: WHEEL_ITEM_H + 'px' }"
|
||||
:class="internalMinute === m - 1 ? 'text-text-primary font-semibold' : 'text-text-tertiary'"
|
||||
>
|
||||
{{ String(m - 1).padStart(2, '0') }}
|
||||
</div>
|
||||
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, ref } from 'vue'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
continueTimer: []
|
||||
stopTimer: []
|
||||
}>()
|
||||
|
||||
const { activate, deactivate } = useFocusTrap()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
setTimeout(() => {
|
||||
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('stopTimer') })
|
||||
}, 50)
|
||||
} else {
|
||||
deactivate()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -17,9 +33,9 @@ const emit = defineEmits<{
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Tracked app not visible</h2>
|
||||
<p class="text-[0.75rem] text-text-secondary mb-6">
|
||||
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="tracking-title" aria-describedby="tracking-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||
<h2 id="tracking-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Tracked app not visible</h2>
|
||||
<p id="tracking-desc" class="text-[0.75rem] text-text-secondary mb-6">
|
||||
None of your tracked apps are currently visible on screen. The timer has been paused.
|
||||
</p>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
@@ -31,7 +47,7 @@ const emit = defineEmits<{
|
||||
</button>
|
||||
<button
|
||||
@click="emit('stopTimer')"
|
||||
class="w-full px-4 py-2.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
class="w-full px-4 py-2.5 border border-status-error text-status-error-text text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Stop & Save
|
||||
</button>
|
||||
|
||||
123
src/components/EntrySplitDialog.vue
Normal file
123
src/components/EntrySplitDialog.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, ref, computed } from 'vue'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
import type { TimeEntry } from '../stores/entries'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
entry: TimeEntry | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
split: [payload: { splitSeconds: number; descriptionB: string }]
|
||||
}>()
|
||||
|
||||
const { activate, deactivate } = useFocusTrap()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const splitSeconds = ref(0)
|
||||
const descriptionB = ref('')
|
||||
|
||||
const minSplit = 60
|
||||
const maxSplit = computed(() => {
|
||||
if (!props.entry) return 60
|
||||
return props.entry.duration - 60
|
||||
})
|
||||
|
||||
const durationA = computed(() => splitSeconds.value)
|
||||
const durationB = computed(() => {
|
||||
if (!props.entry) return 0
|
||||
return props.entry.duration - splitSeconds.value
|
||||
})
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.entry) {
|
||||
splitSeconds.value = Math.floor(props.entry.duration / 2 / 60) * 60
|
||||
descriptionB.value = props.entry.description || ''
|
||||
setTimeout(() => {
|
||||
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
|
||||
}, 50)
|
||||
} else {
|
||||
deactivate()
|
||||
}
|
||||
})
|
||||
|
||||
function confirm() {
|
||||
emit('split', { splitSeconds: splitSeconds.value, descriptionB: descriptionB.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show && entry"
|
||||
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="split-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-sm p-6"
|
||||
>
|
||||
<h2 id="split-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Split Entry</h2>
|
||||
|
||||
<p class="text-[0.75rem] text-text-secondary mb-4">
|
||||
Total duration: <span class="font-medium text-text-primary">{{ formatDuration(entry.duration) }}</span>
|
||||
</p>
|
||||
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Split point</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="splitSeconds"
|
||||
:min="minSplit"
|
||||
:max="maxSplit"
|
||||
:step="60"
|
||||
class="w-full h-2 bg-bg-elevated rounded-lg appearance-none cursor-pointer accent-accent mb-4"
|
||||
:aria-label="'Split at ' + formatDuration(splitSeconds)"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div class="p-3 bg-bg-elevated rounded-lg">
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Entry A</p>
|
||||
<p class="text-[0.9375rem] font-medium text-text-primary font-[family-name:var(--font-timer)]">{{ formatDuration(durationA) }}</p>
|
||||
</div>
|
||||
<div class="p-3 bg-bg-elevated rounded-lg">
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Entry B</p>
|
||||
<p class="text-[0.9375rem] font-medium text-text-primary font-[family-name:var(--font-timer)]">{{ formatDuration(durationB) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description for Entry B</label>
|
||||
<input
|
||||
v-model="descriptionB"
|
||||
type="text"
|
||||
class="w-full bg-bg-base border border-border-subtle rounded-lg px-3 py-2 text-[0.8125rem] text-text-primary placeholder-text-tertiary outline-none focus:border-accent transition-colors duration-150 mb-5"
|
||||
placeholder="Description..."
|
||||
/>
|
||||
|
||||
<div class="flex justify-end 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="confirm"
|
||||
class="px-4 py-2 text-[0.8125rem] bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -4,7 +4,7 @@ import { useFocusTrap } from '../utils/focusTrap'
|
||||
import { useAnnouncer } from '../composables/useAnnouncer'
|
||||
import { useEntryTemplatesStore, type EntryTemplate } from '../stores/entryTemplates'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
import { X, FileText, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
@@ -68,10 +68,46 @@ function onKeydown(e: KeyboardEvent) {
|
||||
function selectTemplate(tpl: EntryTemplate) {
|
||||
emit('select', tpl)
|
||||
}
|
||||
|
||||
const editingId = ref<number | null>(null)
|
||||
const editForm = ref({ name: '', project_id: 0, duration: 0 })
|
||||
const confirmDeleteId = ref<number | null>(null)
|
||||
|
||||
function startEdit(tpl: EntryTemplate) {
|
||||
editingId.value = tpl.id!
|
||||
editForm.value = { name: tpl.name, project_id: tpl.project_id, duration: tpl.duration || 0 }
|
||||
confirmDeleteId.value = null
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
async function saveEdit(tpl: EntryTemplate) {
|
||||
await templatesStore.updateTemplate({
|
||||
...tpl,
|
||||
name: editForm.value.name,
|
||||
project_id: editForm.value.project_id,
|
||||
duration: editForm.value.duration,
|
||||
})
|
||||
editingId.value = null
|
||||
announce('Template updated')
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
confirmDeleteId.value = id
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
async function executeDelete(id: number) {
|
||||
await templatesStore.deleteTemplate(id)
|
||||
confirmDeleteId.value = null
|
||||
announce('Template deleted')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
@@ -94,29 +130,102 @@ function selectTemplate(tpl: EntryTemplate) {
|
||||
@click="$emit('cancel')"
|
||||
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"
|
||||
v-tooltip="'Close'"
|
||||
>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="templatesStore.templates.length > 0" class="space-y-1 max-h-64 overflow-y-auto" role="listbox" aria-label="Entry templates">
|
||||
<button
|
||||
<div
|
||||
v-for="(tpl, i) in templatesStore.templates"
|
||||
:key="tpl.id"
|
||||
@click="selectTemplate(tpl)"
|
||||
role="option"
|
||||
:aria-selected="i === activeIndex"
|
||||
:class="[
|
||||
'w-full text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
||||
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
|
||||
]"
|
||||
>
|
||||
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary">
|
||||
{{ getProjectName(tpl.project_id) }}
|
||||
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
|
||||
</p>
|
||||
</button>
|
||||
<!-- Delete confirmation -->
|
||||
<div v-if="confirmDeleteId === tpl.id" class="px-3 py-2 rounded-lg bg-status-error/10 border border-status-error/20">
|
||||
<p class="text-[0.8125rem] text-text-primary mb-2">Delete this template?</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="executeDelete(tpl.id!)"
|
||||
class="px-3 py-1.5 text-[0.75rem] font-medium bg-status-error text-white rounded-lg hover:bg-status-error/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDeleteId = null"
|
||||
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else-if="editingId === tpl.id" class="px-3 py-2 rounded-lg bg-bg-elevated space-y-2">
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
|
||||
placeholder="Template name"
|
||||
/>
|
||||
<select
|
||||
v-model="editForm.project_id"
|
||||
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
|
||||
>
|
||||
<option v-for="p in projectsStore.activeProjects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="saveEdit(tpl)"
|
||||
class="px-3 py-1.5 text-[0.75rem] font-medium bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
@click="cancelEdit"
|
||||
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal display -->
|
||||
<div v-else class="flex items-center group">
|
||||
<button
|
||||
@click="selectTemplate(tpl)"
|
||||
role="option"
|
||||
:aria-selected="i === activeIndex"
|
||||
:class="[
|
||||
'flex-1 text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
||||
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
|
||||
]"
|
||||
>
|
||||
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary">
|
||||
{{ getProjectName(tpl.project_id) }}
|
||||
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
|
||||
</p>
|
||||
</button>
|
||||
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 pr-1">
|
||||
<button
|
||||
@click.stop="startEdit(tpl)"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
aria-label="Edit template"
|
||||
v-tooltip="'Edit'"
|
||||
>
|
||||
<Pencil class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
|
||||
</button>
|
||||
<button
|
||||
@click.stop="confirmDelete(tpl.id!)"
|
||||
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
aria-label="Delete template"
|
||||
v-tooltip="'Delete'"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-8 text-center">
|
||||
|
||||
164
src/components/GettingStartedChecklist.vue
Normal file
164
src/components/GettingStartedChecklist.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronDown, ChevronUp, Check, ArrowRight, Eye, PartyPopper } from 'lucide-vue-next'
|
||||
import { useOnboardingStore } from '../stores/onboarding'
|
||||
import { useTourStore } from '../stores/tour'
|
||||
import { TOURS } from '../utils/tours'
|
||||
|
||||
const router = useRouter()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const tourStore = useTourStore()
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
const progressPct = computed(() =>
|
||||
onboardingStore.totalCount > 0
|
||||
? (onboardingStore.completedCount / onboardingStore.totalCount) * 100
|
||||
: 0
|
||||
)
|
||||
|
||||
function goThere(route: string) {
|
||||
router.push(route)
|
||||
}
|
||||
|
||||
async function showMe(tourId: string, route: string) {
|
||||
await router.push(route)
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
const tour = TOURS[tourId]
|
||||
if (tour) {
|
||||
tourStore.start(tour)
|
||||
}
|
||||
}, 400)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="onboardingStore.isVisible"
|
||||
class="mb-8 bg-bg-surface border border-border-subtle rounded-lg overflow-hidden"
|
||||
role="region"
|
||||
aria-labelledby="checklist-heading"
|
||||
>
|
||||
<!-- Header -->
|
||||
<button
|
||||
@click="collapsed = !collapsed"
|
||||
:aria-expanded="!collapsed"
|
||||
aria-controls="checklist-body"
|
||||
class="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 id="checklist-heading" class="text-[0.8125rem] font-medium text-text-primary">Getting Started</h2>
|
||||
<span class="text-[0.6875rem] text-text-tertiary" aria-label="Completed {{ onboardingStore.completedCount }} of {{ onboardingStore.totalCount }} steps">
|
||||
{{ onboardingStore.completedCount }} / {{ onboardingStore.totalCount }}
|
||||
</span>
|
||||
</div>
|
||||
<component
|
||||
:is="collapsed ? ChevronDown : ChevronUp"
|
||||
class="w-4 h-4 text-text-tertiary transition-transform duration-200"
|
||||
:stroke-width="2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="px-4">
|
||||
<div class="w-full bg-bg-elevated rounded-full h-1">
|
||||
<div
|
||||
class="h-1 rounded-full bg-accent progress-bar"
|
||||
:style="{ width: progressPct + '%' }"
|
||||
role="progressbar"
|
||||
:aria-valuenow="onboardingStore.completedCount"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="onboardingStore.totalCount"
|
||||
aria-label="Getting started progress"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checklist items -->
|
||||
<Transition name="expand">
|
||||
<div v-if="!collapsed" id="checklist-body" class="px-4 py-3">
|
||||
<!-- All complete message -->
|
||||
<div v-if="onboardingStore.allComplete" class="flex items-center gap-3 py-3" role="status">
|
||||
<PartyPopper class="w-5 h-5 text-accent" :stroke-width="1.5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary font-medium">All done!</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary">You have explored all the basics. Happy tracking!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<ul v-else class="space-y-1" aria-label="Onboarding steps">
|
||||
<li
|
||||
v-for="item in onboardingStore.items"
|
||||
:key="item.key"
|
||||
class="flex items-center gap-3 py-2 group"
|
||||
>
|
||||
<!-- Checkbox indicator -->
|
||||
<div
|
||||
class="w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors duration-200"
|
||||
:class="item.completed
|
||||
? 'border-accent bg-accent'
|
||||
: 'border-border-visible'"
|
||||
role="img"
|
||||
:aria-label="item.completed ? 'Completed' : 'Not completed'"
|
||||
>
|
||||
<Check
|
||||
v-if="item.completed"
|
||||
class="w-3 h-3 text-bg-base"
|
||||
:stroke-width="3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-[0.8125rem] transition-colors duration-200"
|
||||
:class="item.completed ? 'text-text-tertiary line-through' : 'text-text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary">{{ item.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (always focusable, visually hidden until hover/focus) -->
|
||||
<div
|
||||
v-if="!item.completed"
|
||||
class="flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
<button
|
||||
@click="goThere(item.route)"
|
||||
:aria-label="'Go to ' + item.label"
|
||||
class="flex items-center gap-1 px-2 py-1 text-[0.6875rem] text-text-secondary border border-border-subtle rounded-md hover:bg-bg-elevated hover:text-text-primary transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent focus-visible:opacity-100"
|
||||
>
|
||||
<ArrowRight class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
|
||||
Go there
|
||||
</button>
|
||||
<button
|
||||
@click="showMe(item.tourId, item.route)"
|
||||
:aria-label="'Show me how to ' + item.label.toLowerCase()"
|
||||
class="flex items-center gap-1 px-2 py-1 text-[0.6875rem] text-accent-text border border-accent/30 rounded-md hover:bg-accent/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent focus-visible:opacity-100"
|
||||
>
|
||||
<Eye class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
|
||||
Show me
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Dismiss link -->
|
||||
<div class="mt-3 pt-2 border-t border-border-subtle">
|
||||
<button
|
||||
@click="onboardingStore.dismiss()"
|
||||
class="text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Dismiss checklist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
192
src/components/GlobalSearchDialog.vue
Normal file
192
src/components/GlobalSearchDialog.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, ref, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useInvoicesStore } from '../stores/invoices'
|
||||
import { Search, FolderKanban, Users, Clock, FileText } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
const invoicesStore = useInvoicesStore()
|
||||
const { activate, deactivate } = useFocusTrap()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const query = ref('')
|
||||
const activeIndex = ref(0)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
interface SearchResult {
|
||||
type: 'project' | 'client' | 'entry' | 'invoice'
|
||||
id: number
|
||||
label: string
|
||||
sublabel: string
|
||||
color?: string
|
||||
route: string
|
||||
}
|
||||
|
||||
const entryResults = ref<SearchResult[]>([])
|
||||
const searching = ref(false)
|
||||
|
||||
const localResults = computed((): SearchResult[] => {
|
||||
const q = query.value.toLowerCase().trim()
|
||||
if (!q) return []
|
||||
const results: SearchResult[] = []
|
||||
|
||||
for (const p of projectsStore.projects) {
|
||||
if (results.length >= 5) break
|
||||
if (p.name.toLowerCase().includes(q)) {
|
||||
results.push({ type: 'project', id: p.id!, label: p.name, sublabel: p.archived ? 'Archived' : 'Active', color: p.color, route: '/projects' })
|
||||
}
|
||||
}
|
||||
|
||||
for (const inv of invoicesStore.invoices) {
|
||||
if (results.length >= 10) break
|
||||
if (inv.invoice_number.toLowerCase().includes(q)) {
|
||||
results.push({ type: 'invoice', id: inv.id!, label: inv.invoice_number, sublabel: inv.status, route: '/invoices' })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
const allResults = computed(() => [...localResults.value, ...entryResults.value])
|
||||
|
||||
async function searchEntries(q: string) {
|
||||
if (!q.trim()) {
|
||||
entryResults.value = []
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
const rows = await invoke<any[]>('search_entries', { query: q, limit: 5 })
|
||||
entryResults.value = rows.map(r => ({
|
||||
type: 'entry' as const,
|
||||
id: r.id,
|
||||
label: r.description || '(no description)',
|
||||
sublabel: r.project_name || 'Unknown project',
|
||||
color: r.project_color,
|
||||
route: '/entries',
|
||||
}))
|
||||
} catch {
|
||||
entryResults.value = []
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
activeIndex.value = 0
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => searchEntries(query.value), 200)
|
||||
}
|
||||
|
||||
function navigate(result: SearchResult) {
|
||||
router.push(result.route)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const total = allResults.value.length
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % Math.max(total, 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value - 1 + Math.max(total, 1)) % Math.max(total, 1)
|
||||
} else if (e.key === 'Enter' && allResults.value[activeIndex.value]) {
|
||||
e.preventDefault()
|
||||
navigate(allResults.value[activeIndex.value])
|
||||
} else if (e.key === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const typeIcon: Record<string, any> = {
|
||||
project: FolderKanban,
|
||||
client: Users,
|
||||
entry: Clock,
|
||||
invoice: FileText,
|
||||
}
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
query.value = ''
|
||||
entryResults.value = []
|
||||
activeIndex.value = 0
|
||||
nextTick(() => {
|
||||
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
} else {
|
||||
deactivate()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-start justify-center pt-[15vh] p-4 z-50"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search"
|
||||
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 overflow-hidden"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle">
|
||||
<Search class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
@input="onInput"
|
||||
type="text"
|
||||
class="flex-1 bg-transparent text-[0.875rem] text-text-primary placeholder-text-tertiary outline-none"
|
||||
placeholder="Search projects, entries, invoices..."
|
||||
aria-label="Search"
|
||||
/>
|
||||
<kbd class="text-[0.625rem] text-text-tertiary border border-border-subtle rounded px-1.5 py-0.5">Esc</kbd>
|
||||
</div>
|
||||
|
||||
<div v-if="!query.trim()" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
|
||||
Type to search...
|
||||
</div>
|
||||
|
||||
<div v-else-if="allResults.length === 0 && !searching" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
|
||||
No results for "{{ query }}"
|
||||
</div>
|
||||
|
||||
<ul v-else class="max-h-80 overflow-y-auto py-2" role="listbox">
|
||||
<li
|
||||
v-for="(result, idx) in allResults"
|
||||
:key="result.type + '-' + result.id"
|
||||
role="option"
|
||||
:aria-selected="idx === activeIndex"
|
||||
class="flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors duration-100"
|
||||
:class="idx === activeIndex ? 'bg-accent/10' : 'hover:bg-bg-elevated'"
|
||||
@click="navigate(result)"
|
||||
@mouseenter="activeIndex = idx"
|
||||
>
|
||||
<component :is="typeIcon[result.type]" class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
|
||||
<span v-if="result.color" class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: result.color }" aria-hidden="true" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[0.8125rem] text-text-primary truncate">{{ result.label }}</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary truncate">{{ result.sublabel }}</p>
|
||||
</div>
|
||||
<span class="text-[0.5625rem] text-text-tertiary uppercase tracking-wider shrink-0">{{ result.type }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -14,6 +15,9 @@ const emit = defineEmits<{
|
||||
stopTimer: []
|
||||
}>()
|
||||
|
||||
const { activate, deactivate } = useFocusTrap()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const idleFormatted = computed(() => {
|
||||
const mins = Math.floor(props.idleSeconds / 60)
|
||||
const secs = props.idleSeconds % 60
|
||||
@@ -22,6 +26,16 @@ const idleFormatted = computed(() => {
|
||||
}
|
||||
return `${secs}s`
|
||||
})
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
setTimeout(() => {
|
||||
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('stopTimer') })
|
||||
}, 50)
|
||||
} else {
|
||||
deactivate()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,9 +44,9 @@ const idleFormatted = computed(() => {
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">You've been idle</h2>
|
||||
<p class="text-[0.75rem] text-text-secondary mb-6">
|
||||
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="idle-title" aria-describedby="idle-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||
<h2 id="idle-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">You've been idle</h2>
|
||||
<p id="idle-desc" class="text-[0.75rem] text-text-secondary mb-6">
|
||||
No keyboard or mouse input detected for <span class="font-mono font-medium text-text-primary">{{ idleFormatted }}</span>.
|
||||
</p>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
@@ -44,13 +58,13 @@ const idleFormatted = computed(() => {
|
||||
</button>
|
||||
<button
|
||||
@click="emit('continueSubtract')"
|
||||
class="w-full px-4 py-2.5 border border-border-visible text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
class="w-full px-4 py-2.5 border border-border-subtle text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
Continue (subtract {{ idleFormatted }})
|
||||
</button>
|
||||
<button
|
||||
@click="emit('stopTimer')"
|
||||
class="w-full px-4 py-2.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
class="w-full px-4 py-2.5 border border-status-error text-status-error-text text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Stop & Save
|
||||
</button>
|
||||
|
||||
212
src/components/InvoicePipelineView.vue
Normal file
212
src/components/InvoicePipelineView.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { useInvoicesStore, type Invoice } from '../stores/invoices'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { formatCurrency, formatDate } from '../utils/locale'
|
||||
import { GripVertical } from 'lucide-vue-next'
|
||||
|
||||
const emit = defineEmits<{ open: [id: number] }>()
|
||||
|
||||
const invoicesStore = useInvoicesStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const columns = ['draft', 'sent', 'overdue', 'paid'] as const
|
||||
const columnLabels: Record<string, string> = { draft: 'Draft', sent: 'Sent', overdue: 'Overdue', paid: 'Paid' }
|
||||
|
||||
function columnTotal(status: string): string {
|
||||
const items = invoicesStore.groupedByStatus[status] || []
|
||||
const sum = items.reduce((acc, inv) => acc + inv.total, 0)
|
||||
return formatCurrency(sum)
|
||||
}
|
||||
|
||||
// Pointer-based drag (works in Tauri webview unlike HTML5 DnD)
|
||||
const dragInv = ref<Invoice | null>(null)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartY = ref(0)
|
||||
const dragX = ref(0)
|
||||
const dragY = ref(0)
|
||||
const isDragging = ref(false)
|
||||
const dragOverCol = ref<string | null>(null)
|
||||
const columnRefs = ref<Record<string, HTMLElement>>({})
|
||||
const cardWidth = ref(200)
|
||||
|
||||
const DRAG_THRESHOLD = 6
|
||||
|
||||
function setColumnRef(col: string, el: HTMLElement | null) {
|
||||
if (el) columnRefs.value[col] = el
|
||||
}
|
||||
|
||||
function onPointerDown(inv: Invoice, e: PointerEvent) {
|
||||
// Only primary button
|
||||
if (e.button !== 0) return
|
||||
dragInv.value = inv
|
||||
dragStartX.value = e.clientX
|
||||
dragStartY.value = e.clientY
|
||||
dragX.value = e.clientX
|
||||
dragY.value = e.clientY
|
||||
isDragging.value = false
|
||||
// Measure the source card width for the ghost
|
||||
const el = (e.currentTarget as HTMLElement)
|
||||
if (el) cardWidth.value = el.offsetWidth
|
||||
document.addEventListener('pointermove', onPointerMove)
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragInv.value) return
|
||||
|
||||
const dx = e.clientX - dragStartX.value
|
||||
const dy = e.clientY - dragStartY.value
|
||||
|
||||
// Start drag only after threshold
|
||||
if (!isDragging.value) {
|
||||
if (Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
// Track position for the ghost
|
||||
dragX.value = e.clientX
|
||||
dragY.value = e.clientY
|
||||
|
||||
// Hit-test which column the pointer is over
|
||||
// The ghost has pointer-events:none so elementFromPoint sees through it
|
||||
const hit = document.elementFromPoint(e.clientX, e.clientY)
|
||||
if (hit) {
|
||||
let found: string | null = null
|
||||
for (const [col, el] of Object.entries(columnRefs.value)) {
|
||||
if (el.contains(hit)) {
|
||||
found = col
|
||||
break
|
||||
}
|
||||
}
|
||||
dragOverCol.value = found
|
||||
}
|
||||
}
|
||||
|
||||
async function onPointerUp() {
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
|
||||
const inv = dragInv.value
|
||||
const targetCol = dragOverCol.value
|
||||
const wasDragging = isDragging.value
|
||||
|
||||
dragInv.value = null
|
||||
dragOverCol.value = null
|
||||
isDragging.value = false
|
||||
|
||||
if (!inv) return
|
||||
|
||||
// If we were dragging and landed on a different column, move the invoice
|
||||
if (wasDragging && targetCol && targetCol !== inv.status) {
|
||||
const oldStatus = inv.status
|
||||
const ok = await invoicesStore.updateStatus(inv.id!, targetCol)
|
||||
if (ok) {
|
||||
toastStore.success(`Moved ${inv.invoice_number} to ${columnLabels[targetCol]}`, {
|
||||
onUndo: async () => {
|
||||
await invoicesStore.updateStatus(inv.id!, oldStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If we didn't drag (just clicked), open the invoice
|
||||
if (!wasDragging) {
|
||||
emit('open', inv.id!)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
|
||||
const reducedMotion = computed(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-4 gap-4 select-none">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col"
|
||||
:ref="(el) => setColumnRef(col, el as HTMLElement)"
|
||||
class="flex flex-col min-h-[300px] bg-bg-elevated rounded-lg overflow-hidden transition-all duration-150"
|
||||
:class="[
|
||||
dragOverCol === col && isDragging ? 'ring-2 ring-accent bg-accent/5' : '',
|
||||
col === 'overdue' ? 'border-t-2 border-status-error' : ''
|
||||
]"
|
||||
:aria-label="columnLabels[col] + ' invoices'"
|
||||
>
|
||||
<div class="px-3 py-2.5 border-b border-border-subtle">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[0.75rem] font-medium text-text-primary">{{ columnLabels[col] }}</span>
|
||||
<span class="text-[0.625rem] text-text-tertiary bg-bg-base rounded-full px-2 py-0.5">
|
||||
{{ (invoicesStore.groupedByStatus[col] || []).length }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ columnTotal(col) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-2" role="list">
|
||||
<div
|
||||
v-for="inv in (invoicesStore.groupedByStatus[col] || [])"
|
||||
:key="inv.id"
|
||||
role="listitem"
|
||||
tabindex="0"
|
||||
class="bg-bg-surface border border-border-subtle rounded-lg p-3 transition-all duration-150 hover:border-accent/50 group"
|
||||
:class="[
|
||||
isDragging && dragInv?.id === inv.id ? 'opacity-40 scale-95 cursor-grabbing' : 'cursor-grab',
|
||||
!reducedMotion ? 'hover:shadow-sm' : ''
|
||||
]"
|
||||
@pointerdown="onPointerDown(inv, $event)"
|
||||
@keydown.enter="emit('open', inv.id!)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<GripVertical
|
||||
class="w-3.5 h-3.5 text-text-tertiary opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5"
|
||||
:stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[0.8125rem] font-medium text-text-primary">{{ inv.invoice_number }}</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ formatDate(inv.date) }}</p>
|
||||
<p class="text-[0.8125rem] font-medium text-text-primary mt-1">
|
||||
{{ formatCurrency(inv.total) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drop zone placeholder when column is empty or drag active -->
|
||||
<div
|
||||
v-if="!(invoicesStore.groupedByStatus[col] || []).length"
|
||||
class="flex items-center justify-center h-20 text-[0.6875rem] text-text-tertiary border-2 border-dashed rounded-lg transition-colors"
|
||||
:class="isDragging && dragOverCol === col ? 'border-accent text-accent' : 'border-border-subtle'"
|
||||
>
|
||||
{{ isDragging ? 'Drop here' : 'No invoices' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating ghost tile that follows the cursor during drag -->
|
||||
<Teleport to="#app">
|
||||
<div
|
||||
v-if="isDragging && dragInv"
|
||||
class="fixed z-[200] pointer-events-none"
|
||||
:style="{
|
||||
left: dragX + 'px',
|
||||
top: dragY + 'px',
|
||||
width: cardWidth + 'px',
|
||||
transform: 'translate(-50%, -60%) rotate(-2deg)',
|
||||
}"
|
||||
>
|
||||
<div class="bg-bg-surface border-2 border-accent rounded-lg p-3 shadow-lg shadow-black/30 opacity-90">
|
||||
<p class="text-[0.8125rem] font-medium text-text-primary">{{ dragInv.invoice_number }}</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ formatDate(dragInv.date) }}</p>
|
||||
<p class="text-[0.8125rem] font-medium text-text-primary mt-1">{{ formatCurrency(dragInv.total) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -58,6 +58,7 @@ void clientAddress
|
||||
<img
|
||||
v-if="biz?.logo"
|
||||
:src="biz.logo"
|
||||
alt="Business logo"
|
||||
:style="{ width: '28px', height: '28px', objectFit: 'contain', marginBottom: '4px', display: 'block' }"
|
||||
/>
|
||||
<div :style="{ fontSize: '13px', fontWeight: '600', color: c.headerText }">
|
||||
@@ -102,10 +103,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -172,7 +173,7 @@ void clientAddress
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ textAlign: 'right' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
|
||||
<div :style="{ fontSize: '12px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,10 +202,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -268,7 +269,7 @@ void clientAddress
|
||||
</div>
|
||||
<!-- Biz info outside the block -->
|
||||
<div :style="{ flex: 1, padding: '5% 5%', display: 'flex', flexDirection: 'column', justifyContent: 'center' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<div :style="{ fontSize: '9px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
|
||||
<div v-for="(line, i) in bizAddressLines" :key="'blf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
|
||||
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
|
||||
@@ -303,10 +304,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -364,7 +365,7 @@ void clientAddress
|
||||
<div :style="{ padding: '6%' }">
|
||||
<!-- Centered header -->
|
||||
<div :style="{ textAlign: 'center', marginBottom: '10px' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', margin: '0 auto 6px', display: 'block' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', margin: '0 auto 6px', display: 'block' }" />
|
||||
<div :style="{ height: '1px', backgroundColor: '#e4e4e7', marginBottom: '8px' }" />
|
||||
<div :style="{ fontSize: '20px', fontWeight: '700', color: '#18181b', letterSpacing: '0.04em' }">INVOICE</div>
|
||||
<div :style="{ fontSize: '9px', fontWeight: '600', color: '#18181b', marginTop: '2px' }">{{ biz?.name || 'Your Business' }}</div>
|
||||
@@ -399,10 +400,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :style="{ textAlign: 'left', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', letterSpacing: '0.03em' }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', letterSpacing: '0.03em' }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -461,7 +462,7 @@ void clientAddress
|
||||
<!-- Traditional two-column header -->
|
||||
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }">
|
||||
<div>
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<div :style="{ fontSize: '11px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
|
||||
<div v-for="(line, i) in bizAddressLines" :key="'clf'+i">{{ line }}</div>
|
||||
<div v-if="biz?.email">{{ biz.email }}</div>
|
||||
@@ -491,10 +492,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px', border: '1px solid ' + c.tableBorder }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -560,7 +561,7 @@ void clientAddress
|
||||
<div v-if="invoice.due_date" :style="{ fontSize: '7.5px', color: c.bodyText }">Due {{ formatDate(invoice.due_date) }}</div>
|
||||
</div>
|
||||
<div :style="{ textAlign: 'right' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
|
||||
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
|
||||
<div v-for="(line, i) in bizAddressLines" :key="'modf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
|
||||
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
|
||||
@@ -591,10 +592,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, borderBottom: '1px solid ' + c.primary }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', borderBottom: '1px solid ' + c.primary }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', borderBottom: '1px solid ' + c.primary }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', borderBottom: '1px solid ' + c.primary }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, borderBottom: '1px solid ' + c.primary }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', borderBottom: '1px solid ' + c.primary }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', borderBottom: '1px solid ' + c.primary }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', borderBottom: '1px solid ' + c.primary }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -659,7 +660,7 @@ void clientAddress
|
||||
|
||||
<!-- Centered header -->
|
||||
<div :style="{ textAlign: 'center', marginBottom: '8px' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', margin: '0 auto 4px', display: 'block' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', margin: '0 auto 4px', display: 'block' }" />
|
||||
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.headerText, letterSpacing: '0.06em' }">INVOICE</div>
|
||||
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText, marginTop: '2px' }">{{ biz?.name || 'Your Business' }}</div>
|
||||
<div :style="{ fontSize: '7.5px', marginTop: '2px' }">
|
||||
@@ -697,20 +698,20 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :colspan="4" :style="{ padding: 0, height: '0' }">
|
||||
<th scope="col" :colspan="4" :style="{ padding: 0, height: '0' }">
|
||||
<div :style="{ height: '1px', backgroundColor: c.primary }" />
|
||||
<div :style="{ height: '1.5px' }" />
|
||||
<div :style="{ height: '1px', backgroundColor: c.primary }" />
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th :colspan="4" :style="{ padding: 0, height: '0' }">
|
||||
<th scope="col" :colspan="4" :style="{ padding: 0, height: '0' }">
|
||||
<div :style="{ height: '1px', backgroundColor: c.primary }" />
|
||||
<div :style="{ height: '1.5px' }" />
|
||||
<div :style="{ height: '1px', backgroundColor: c.primary }" />
|
||||
@@ -776,7 +777,7 @@ void clientAddress
|
||||
<div :style="{ paddingLeft: '7%', paddingRight: '5%', paddingTop: '5%', paddingBottom: '5%' }">
|
||||
<!-- Logo + Title -->
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain' }" />
|
||||
<div>
|
||||
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.primary }">INVOICE</div>
|
||||
<div :style="{ fontSize: '7.5px' }">#{{ invoice.invoice_number }}</div>
|
||||
@@ -909,10 +910,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '10px' }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '38px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '46px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '52px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '38px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '46px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '52px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -975,7 +976,7 @@ void clientAddress
|
||||
<div :style="{ width: '40px', height: '1px', backgroundColor: c.primary, marginTop: '4px' }" />
|
||||
</div>
|
||||
<div :style="{ textAlign: 'right' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
|
||||
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1008,10 +1009,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1077,7 +1078,7 @@ void clientAddress
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ textAlign: 'right' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
|
||||
<div :style="{ fontSize: '11px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
|
||||
<div v-if="biz?.email" :style="{ fontSize: '7px', opacity: 0.9 }">{{ biz.email }}</div>
|
||||
</div>
|
||||
@@ -1106,10 +1107,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1167,7 +1168,7 @@ void clientAddress
|
||||
<!-- Deep blue band -->
|
||||
<div :style="{ backgroundColor: c.headerBg, color: c.headerText, padding: '4% 6% 3%', minHeight: '12%', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }">
|
||||
<div>
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px' }" />
|
||||
<div :style="{ fontSize: '12px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
|
||||
</div>
|
||||
<div :style="{ fontSize: '22px', fontWeight: '700' }">INVOICE</div>
|
||||
@@ -1204,10 +1205,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px', border: '1px solid ' + c.tableBorder }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1266,7 +1267,7 @@ void clientAddress
|
||||
<!-- Header with logo left, watermark invoice number right -->
|
||||
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px', position: 'relative' }">
|
||||
<div>
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<div :style="{ fontSize: '11px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
|
||||
<div v-for="(line, i) in bizAddressLines" :key="'frf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
|
||||
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
|
||||
@@ -1307,10 +1308,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1369,7 +1370,7 @@ void clientAddress
|
||||
<!-- Header -->
|
||||
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }">
|
||||
<div>
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.primary }">INVOICE</div>
|
||||
</div>
|
||||
<div :style="{ textAlign: 'right' }">
|
||||
@@ -1407,10 +1408,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr :style="{ backgroundColor: c.tableHeaderBg }">
|
||||
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1482,7 +1483,7 @@ void clientAddress
|
||||
|
||||
<!-- Biz info -->
|
||||
<div :style="{ marginBottom: '10px' }">
|
||||
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
|
||||
<div :style="{ fontSize: '9px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
|
||||
<div v-for="(line, i) in bizAddressLines" :key="'sf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
|
||||
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
|
||||
@@ -1509,10 +1510,10 @@ void clientAddress
|
||||
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
|
||||
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -93,16 +93,18 @@ function selectTemplate(id: string) {
|
||||
style="height: 480px"
|
||||
>
|
||||
<!-- Left panel: Template list -->
|
||||
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface">
|
||||
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface" role="radiogroup" aria-label="Invoice templates">
|
||||
<div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id">
|
||||
<div
|
||||
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.1em] font-medium px-3 pt-3 pb-1"
|
||||
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium px-3 pt-3 pb-1"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</div>
|
||||
<button
|
||||
v-for="tmpl in getTemplatesByCategory(cat.id)"
|
||||
:key="tmpl.id"
|
||||
role="radio"
|
||||
:aria-checked="tmpl.id === modelValue"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-[0.75rem] transition-colors"
|
||||
:class="
|
||||
tmpl.id === modelValue
|
||||
@@ -114,6 +116,7 @@ function selectTemplate(id: string) {
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full shrink-0 border border-black/10"
|
||||
:style="{ backgroundColor: tmpl.colors.primary }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate">{{ tmpl.name }}</span>
|
||||
</button>
|
||||
@@ -121,7 +124,7 @@ function selectTemplate(id: string) {
|
||||
</div>
|
||||
|
||||
<!-- Right panel: Live preview -->
|
||||
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto">
|
||||
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto" aria-label="Template preview" aria-live="polite">
|
||||
<div class="w-full max-w-sm">
|
||||
<InvoicePreview
|
||||
:template="selectedTemplate"
|
||||
|
||||
192
src/components/JsonImportWizard.vue
Normal file
192
src/components/JsonImportWizard.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<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',
|
||||
invoices: 'Invoices',
|
||||
invoice_items: 'Invoice Items',
|
||||
expenses: 'Expenses',
|
||||
favorites: 'Favorites',
|
||||
recurring_entries: 'Recurring Entries',
|
||||
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>
|
||||
95
src/components/KeyboardShortcutsDialog.vue
Normal file
95
src/components/KeyboardShortcutsDialog.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, ref } from 'vue'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const { activate, deactivate } = useFocusTrap()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
setTimeout(() => {
|
||||
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
|
||||
}, 50)
|
||||
} else {
|
||||
deactivate()
|
||||
}
|
||||
})
|
||||
|
||||
const groups = [
|
||||
{
|
||||
label: 'Global',
|
||||
shortcuts: [
|
||||
{ keys: '?', description: 'Show keyboard shortcuts' },
|
||||
{ keys: 'Ctrl+Shift+T', description: 'Toggle timer' },
|
||||
{ keys: 'Ctrl+Shift+Z', description: 'Show/focus app' },
|
||||
{ keys: 'Ctrl+Shift+N', description: 'Quick entry' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Timer',
|
||||
shortcuts: [
|
||||
{ keys: 'Space', description: 'Start/stop timer (when focused)' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Navigation',
|
||||
shortcuts: [
|
||||
{ keys: 'Arrow keys', description: 'Navigate tabs, calendar, lists' },
|
||||
{ keys: 'Enter', description: 'Open/select focused item' },
|
||||
{ keys: 'Escape', description: 'Close dialog/popover' },
|
||||
]
|
||||
},
|
||||
]
|
||||
</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="shortcuts-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="shortcuts-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div v-for="group in groups" :key="group.label">
|
||||
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-2">
|
||||
{{ group.label }}
|
||||
</h3>
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-for="shortcut in group.shortcuts"
|
||||
:key="shortcut.keys"
|
||||
class="flex items-center justify-between text-[0.8125rem]"
|
||||
>
|
||||
<span class="text-text-secondary">{{ shortcut.description }}</span>
|
||||
<kbd class="px-2 py-0.5 bg-bg-elevated border border-border-subtle rounded text-[0.6875rem] font-mono text-text-primary">
|
||||
{{ shortcut.keys }}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="px-4 py-2 border border-border-subtle text-text-secondary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTimerStore } from '../stores/timer'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useInvoicesStore } from '../stores/invoices'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Clock,
|
||||
@@ -12,78 +14,250 @@ import {
|
||||
Grid3X3,
|
||||
BarChart3,
|
||||
FileText,
|
||||
Settings
|
||||
Settings,
|
||||
PanelLeftOpen,
|
||||
PanelLeftClose
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const timerStore = useTimerStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const invoicesStore = useInvoicesStore()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
|
||||
{ name: 'Timer', path: '/timer', icon: Clock },
|
||||
{ name: 'Clients', path: '/clients', icon: Users },
|
||||
{ name: 'Projects', path: '/projects', icon: FolderKanban },
|
||||
{ name: 'Entries', path: '/entries', icon: List },
|
||||
{ name: 'Calendar', path: '/calendar', icon: CalendarDays },
|
||||
{ name: 'Timesheet', path: '/timesheet', icon: Grid3X3 },
|
||||
{ name: 'Invoices', path: '/invoices', icon: FileText },
|
||||
{ name: 'Reports', path: '/reports', icon: BarChart3 },
|
||||
{ name: 'Settings', path: '/settings', icon: Settings }
|
||||
const expanded = ref(false)
|
||||
|
||||
interface NavItem {
|
||||
name: string
|
||||
path: string
|
||||
icon: any
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const groups: NavGroup[] = [
|
||||
{
|
||||
label: 'Track',
|
||||
items: [
|
||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
|
||||
{ name: 'Timer', path: '/timer', icon: Clock },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Manage',
|
||||
items: [
|
||||
{ name: 'Clients', path: '/clients', icon: Users },
|
||||
{ name: 'Projects', path: '/projects', icon: FolderKanban },
|
||||
{ name: 'Entries', path: '/entries', icon: List },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Views',
|
||||
items: [
|
||||
{ name: 'Calendar', path: '/calendar', icon: CalendarDays },
|
||||
{ name: 'Timesheet', path: '/timesheet', icon: Grid3X3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Business',
|
||||
items: [
|
||||
{ name: 'Invoices', path: '/invoices', icon: FileText },
|
||||
{ name: 'Reports', path: '/reports', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const settingsItem: NavItem = { name: 'Settings', path: '/settings', icon: Settings }
|
||||
|
||||
const currentPath = computed(() => route.path)
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
return navItems.findIndex(item => item.path === currentPath.value)
|
||||
})
|
||||
// Only show tooltips when nav is collapsed (labels are hidden)
|
||||
function tip(text: string) {
|
||||
return expanded.value ? '' : text
|
||||
}
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function toggleExpanded() {
|
||||
expanded.value = !expanded.value
|
||||
await settingsStore.updateSetting('nav_expanded', expanded.value ? 'true' : 'false')
|
||||
}
|
||||
|
||||
// Watch for settings to load (NavRail mounts before App.vue's fetchSettings resolves)
|
||||
watch(() => settingsStore.settings.nav_expanded, (val) => {
|
||||
if (val !== undefined) {
|
||||
expanded.value = val === 'true'
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="w-12 flex flex-col items-center bg-bg-surface border-r border-border-subtle shrink-0">
|
||||
<div class="relative flex-1 flex flex-col items-center pt-2 gap-1">
|
||||
<!-- Sliding active indicator -->
|
||||
<nav
|
||||
aria-label="Main navigation"
|
||||
class="flex flex-col bg-bg-surface border-r border-border-subtle shrink-0 transition-[width] duration-200 ease-out overflow-hidden"
|
||||
:class="expanded ? 'w-[180px]' : 'w-12'"
|
||||
>
|
||||
<!-- Scrollable nav items -->
|
||||
<div class="relative flex-1 flex flex-col pt-1 overflow-y-auto overflow-x-hidden">
|
||||
<div
|
||||
v-if="activeIndex >= 0"
|
||||
class="absolute left-0 w-[2px] bg-accent transition-all duration-300"
|
||||
:style="{ top: `${activeIndex * 52 + 8 + 8}px`, height: '36px' }"
|
||||
style="transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
@click="navigate(item.path)"
|
||||
class="relative w-12 h-[52px] flex items-center justify-center transition-colors duration-150 group"
|
||||
:class="currentPath === item.path
|
||||
? 'text-text-primary'
|
||||
: 'text-text-tertiary hover:text-text-secondary'"
|
||||
:title="item.name"
|
||||
v-for="(group, groupIndex) in groups"
|
||||
:key="group.label"
|
||||
>
|
||||
<component :is="item.icon" class="w-[18px] h-[18px]" :stroke-width="1.5" />
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute left-full ml-2 px-2 py-1 bg-bg-elevated border border-border-subtle rounded-lg text-[0.6875rem] text-text-primary whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-150 z-50">
|
||||
<div class="absolute -left-1 top-1/2 -translate-y-1/2 w-0 h-0 border-y-4 border-y-transparent border-r-4" style="border-right-color: var(--color-bg-elevated)"></div>
|
||||
{{ item.name }}
|
||||
<!-- Section header (expanded only) -->
|
||||
<div
|
||||
class="overflow-hidden transition-[max-height,opacity] duration-200 ease-out"
|
||||
:class="expanded ? 'max-h-8 opacity-100' : 'max-h-0 opacity-0'"
|
||||
>
|
||||
<p
|
||||
class="px-3 pt-3 pb-1 text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium truncate"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ group.label }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Divider (collapsed only, between groups) -->
|
||||
<div
|
||||
class="mx-2.5 border-t border-border-subtle overflow-hidden transition-[max-height,opacity,margin] duration-200 ease-out"
|
||||
:class="!expanded && groupIndex > 0 ? 'max-h-2 opacity-100 my-1' : 'max-h-0 opacity-0 my-0'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Items -->
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.path"
|
||||
v-tooltip.right="tip(item.name)"
|
||||
@click="navigate(item.path)"
|
||||
class="nav-item relative w-full flex items-center h-11 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
||||
:class="currentPath === item.path
|
||||
? 'text-text-primary'
|
||||
: 'text-text-tertiary hover:text-text-secondary'"
|
||||
:aria-label="item.name"
|
||||
:aria-current="currentPath === item.path ? 'page' : undefined"
|
||||
>
|
||||
<!-- Active indicator -->
|
||||
<div
|
||||
class="absolute left-0 top-1 bottom-1 w-[2px] rounded-full transition-opacity duration-200"
|
||||
:class="currentPath === item.path ? 'bg-accent opacity-100' : 'opacity-0'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Fixed-width icon container - always centered in 48px -->
|
||||
<div class="relative w-12 flex items-center justify-center shrink-0">
|
||||
<component :is="item.icon" aria-hidden="true" class="w-[18px] h-[18px]" :stroke-width="1.5" />
|
||||
<span
|
||||
v-if="item.path === '/invoices' && invoicesStore.overdueCount > 0"
|
||||
class="absolute top-[-2px] right-1.5 min-w-[1rem] h-4 flex items-center justify-center text-[0.5625rem] font-bold text-white bg-status-error rounded-full px-1"
|
||||
:aria-label="`${invoicesStore.overdueCount} overdue invoices`"
|
||||
>
|
||||
{{ invoicesStore.overdueCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Label (fades in with expand) -->
|
||||
<span
|
||||
class="text-[0.75rem] truncate whitespace-nowrap transition-opacity duration-200"
|
||||
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer status indicator (bottom) -->
|
||||
<div class="pb-4">
|
||||
<!-- Bottom section: Settings + toggle + timer -->
|
||||
<div class="flex flex-col pb-1.5 border-t border-border-subtle">
|
||||
<!-- Settings -->
|
||||
<button
|
||||
v-tooltip.right="tip(settingsItem.name)"
|
||||
@click="navigate(settingsItem.path)"
|
||||
class="nav-item relative w-full flex items-center h-11 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
||||
:class="currentPath === settingsItem.path
|
||||
? 'text-text-primary'
|
||||
: 'text-text-tertiary hover:text-text-secondary'"
|
||||
:aria-label="settingsItem.name"
|
||||
:aria-current="currentPath === settingsItem.path ? 'page' : undefined"
|
||||
>
|
||||
<!-- Active indicator -->
|
||||
<div
|
||||
class="absolute left-0 top-1 bottom-1 w-[2px] rounded-full transition-opacity duration-200"
|
||||
:class="currentPath === settingsItem.path ? 'bg-accent opacity-100' : 'opacity-0'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="w-12 flex items-center justify-center shrink-0">
|
||||
<component :is="settingsItem.icon" aria-hidden="true" class="w-[18px] h-[18px]" :stroke-width="1.5" />
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-[0.75rem] truncate whitespace-nowrap transition-opacity duration-200"
|
||||
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
{{ settingsItem.name }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Expand/collapse toggle -->
|
||||
<button
|
||||
v-tooltip.right="tip(expanded ? 'Collapse' : 'Expand')"
|
||||
@click="toggleExpanded"
|
||||
class="relative w-full flex items-center h-11 transition-colors duration-150 text-text-tertiary hover:text-text-secondary focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
||||
:aria-label="expanded ? 'Collapse navigation' : 'Expand navigation'"
|
||||
:aria-expanded="expanded"
|
||||
>
|
||||
<div class="w-12 flex items-center justify-center shrink-0">
|
||||
<component
|
||||
:is="expanded ? PanelLeftClose : PanelLeftOpen"
|
||||
aria-hidden="true"
|
||||
class="w-[16px] h-[16px]"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-[0.6875rem] truncate whitespace-nowrap transition-opacity duration-200"
|
||||
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
Collapse
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Timer status indicator (only when running/paused) -->
|
||||
<div
|
||||
v-if="timerStore.isRunning"
|
||||
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
|
||||
/>
|
||||
<div
|
||||
v-else-if="timerStore.isPaused"
|
||||
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
|
||||
/>
|
||||
v-if="timerStore.isRunning || timerStore.isPaused"
|
||||
class="flex items-center h-7"
|
||||
role="status"
|
||||
>
|
||||
<span class="sr-only">
|
||||
{{ timerStore.isRunning ? 'Timer running' : 'Timer paused' }}
|
||||
</span>
|
||||
<div class="w-12 flex items-center justify-center shrink-0">
|
||||
<div
|
||||
v-if="timerStore.isRunning"
|
||||
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-[0.6875rem] truncate whitespace-nowrap transition-opacity duration-200"
|
||||
:class="[
|
||||
expanded ? 'opacity-100' : 'opacity-0',
|
||||
timerStore.isRunning ? 'text-status-running' : 'text-status-warning'
|
||||
]"
|
||||
>
|
||||
{{ timerStore.isRunning ? 'Running' : 'Paused' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useFocusTrap } from '../utils/focusTrap'
|
||||
import { useAnnouncer } from '../composables/useAnnouncer'
|
||||
import AppSelect from './AppSelect.vue'
|
||||
import AppDatePicker from './AppDatePicker.vue'
|
||||
import AppTimePicker from './AppTimePicker.vue'
|
||||
import { useProjectsStore, type Task } from '../stores/projects'
|
||||
import { useEntriesStore } from '../stores/entries'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
@@ -31,14 +32,15 @@ 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 durationHour = ref(1)
|
||||
const durationMinute = ref(0)
|
||||
const billable = ref(1)
|
||||
const tasks = ref<Task[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
const availableProjects = computed(() => projectsStore.projects.filter(p => !p.archived))
|
||||
const availableProjects = computed(() => projectsStore.activeProjects)
|
||||
|
||||
const canSave = computed(() => !!selectedProjectId.value && !!durationInput.value.trim() && !saving.value)
|
||||
const canSave = computed(() => !!selectedProjectId.value && (durationHour.value > 0 || durationMinute.value > 0) && !saving.value)
|
||||
|
||||
watch(selectedProjectId, async (projectId) => {
|
||||
selectedTaskId.value = null
|
||||
@@ -62,7 +64,8 @@ watch(() => props.show, async (val) => {
|
||||
selectedTaskId.value = null
|
||||
description.value = ''
|
||||
entryDate.value = new Date().toISOString().split('T')[0]
|
||||
durationInput.value = ''
|
||||
durationHour.value = 1
|
||||
durationMinute.value = 0
|
||||
billable.value = 1
|
||||
saving.value = false
|
||||
|
||||
@@ -78,27 +81,9 @@ watch(() => props.show, async (val) => {
|
||||
|
||||
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
|
||||
const duration = durationHour.value * 3600 + durationMinute.value * 60
|
||||
if (!selectedProjectId.value || duration <= 0) return
|
||||
|
||||
saving.value = true
|
||||
|
||||
@@ -132,7 +117,7 @@ async function handleSave() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
@@ -154,6 +139,7 @@ async function handleSave() {
|
||||
@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"
|
||||
v-tooltip="'Close'"
|
||||
>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -201,13 +187,11 @@ async function handleSave() {
|
||||
<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"
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration *</label>
|
||||
<AppTimePicker
|
||||
v-model:hour="durationHour"
|
||||
v-model:minute="durationMinute"
|
||||
placeholder="Duration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ function onKeydown(e: KeyboardEvent) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
@@ -74,6 +74,7 @@ function onKeydown(e: KeyboardEvent) {
|
||||
@click="zoomOut"
|
||||
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
aria-label="Zoom out"
|
||||
v-tooltip="'Zoom out'"
|
||||
>
|
||||
<ZoomOut class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -84,6 +85,7 @@ function onKeydown(e: KeyboardEvent) {
|
||||
@click="zoomIn"
|
||||
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
aria-label="Zoom in"
|
||||
v-tooltip="'Zoom in'"
|
||||
>
|
||||
<ZoomIn class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -91,6 +93,7 @@ function onKeydown(e: KeyboardEvent) {
|
||||
@click="$emit('close')"
|
||||
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
aria-label="Close lightbox"
|
||||
v-tooltip="'Close'"
|
||||
>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
137
src/components/RecurringPromptDialog.vue
Normal file
137
src/components/RecurringPromptDialog.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, ref, computed } from 'vue'
|
||||
import { Clock, Calendar } from 'lucide-vue-next'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
projectName: string
|
||||
projectColor?: string
|
||||
taskName?: string
|
||||
description?: string
|
||||
duration: number
|
||||
timeOfDay: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
projectColor: '#6B7280',
|
||||
taskName: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
snooze: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { activate, deactivate } = useFocusTrap()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
const announcement = ref('')
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
const totalSeconds = props.duration
|
||||
const h = Math.floor(totalSeconds / 3600)
|
||||
const m = Math.floor((totalSeconds % 3600) / 60)
|
||||
if (h === 0) return `${m}m`
|
||||
return `${h}h ${m}m`
|
||||
})
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
announcement.value = `Recurring entry ready: ${props.projectName} - ${props.description || 'Scheduled task'}`
|
||||
setTimeout(() => {
|
||||
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('skip') })
|
||||
}, 50)
|
||||
} else {
|
||||
deactivate()
|
||||
announcement.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#app">
|
||||
<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-[60]"
|
||||
@click.self="$emit('skip')"
|
||||
>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="recurring-prompt-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-sm p-6"
|
||||
>
|
||||
<h2
|
||||
id="recurring-prompt-title"
|
||||
class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4"
|
||||
>
|
||||
Recurring Entry Ready
|
||||
</h2>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<!-- Project -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: projectColor }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-[0.8125rem] font-medium text-text-primary">{{ projectName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Task name -->
|
||||
<div v-if="taskName" class="text-[0.75rem] text-text-secondary pl-[1.125rem]">
|
||||
{{ taskName }}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="description" class="text-[0.75rem] text-text-secondary pl-[1.125rem]">
|
||||
{{ description }}
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="flex items-center gap-2 text-[0.75rem] text-text-secondary">
|
||||
<Clock class="w-3.5 h-3.5 shrink-0" :stroke-width="1.5" aria-hidden="true" />
|
||||
<span>{{ formattedDuration }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Time of day -->
|
||||
<div v-if="timeOfDay" class="flex items-center gap-2 text-[0.75rem] text-text-secondary">
|
||||
<Calendar class="w-3.5 h-3.5 shrink-0" :stroke-width="1.5" aria-hidden="true" />
|
||||
<span>{{ timeOfDay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@click="$emit('skip')"
|
||||
class="px-3 py-1.5 text-[0.75rem] text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('snooze')"
|
||||
class="px-3 py-1.5 text-[0.75rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
Snooze 30min
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm')"
|
||||
class="px-3 py-1.5 text-[0.75rem] font-medium bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Create Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- aria-live announcement -->
|
||||
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
|
||||
</template>
|
||||
@@ -61,7 +61,7 @@ async function fetchProcesses() {
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 280, estimatedHeight: 290 })
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 280, estimatedHeight: 290, panelEl: panelRef.value })
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -77,6 +77,7 @@ async function open() {
|
||||
updatePosition()
|
||||
highlightedIndex.value = 0
|
||||
searchQuery.value = ''
|
||||
nextTick(() => updatePosition())
|
||||
await fetchProcesses()
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
@@ -144,13 +145,15 @@ onBeforeUnmount(() => {
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="listbox"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 border border-border-subtle text-text-secondary text-[0.75rem] rounded-lg hover:bg-bg-elevated hover:text-text-primary transition-colors"
|
||||
>
|
||||
<Monitor class="w-3.5 h-3.5" :stroke-width="1.5" />
|
||||
<Monitor class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||
From Running Apps
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
@@ -165,20 +168,22 @@ onBeforeUnmount(() => {
|
||||
type="text"
|
||||
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search running applications"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="fetchProcesses"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
title="Refresh"
|
||||
aria-label="Refresh application list"
|
||||
v-tooltip="'Refresh'"
|
||||
>
|
||||
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" :stroke-width="1.5" />
|
||||
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[240px] overflow-y-auto py-1">
|
||||
<div v-if="loading && filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary">
|
||||
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" aria-label="Running applications">
|
||||
<div v-if="loading && filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary" aria-live="polite">
|
||||
Loading...
|
||||
</div>
|
||||
<div v-else-if="filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary">
|
||||
@@ -187,6 +192,9 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-for="(app, index) in filteredProcesses"
|
||||
:key="app.exe_path"
|
||||
role="option"
|
||||
:id="'app-option-' + index"
|
||||
:aria-selected="highlightedIndex === index"
|
||||
@click="selectApp(app)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
class="flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors"
|
||||
|
||||
@@ -63,7 +63,7 @@ function handleSave() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
@@ -86,6 +86,7 @@ function handleSave() {
|
||||
@click="$emit('cancel')"
|
||||
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"
|
||||
v-tooltip="'Close'"
|
||||
>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTimerStore } from '../stores/timer'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
const appWindow = getCurrentWindow()
|
||||
const isMaximized = ref(false)
|
||||
const timerStore = useTimerStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
isMaximized.value = await appWindow.isMaximized()
|
||||
@@ -20,7 +22,11 @@ function getProjectName(projectId: number | null): string {
|
||||
}
|
||||
|
||||
async function minimize() {
|
||||
await appWindow.minimize()
|
||||
if (settingsStore.settings.minimize_to_tray === 'true') {
|
||||
await appWindow.hide()
|
||||
} else {
|
||||
await appWindow.minimize()
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMaximize() {
|
||||
@@ -43,7 +49,7 @@ async function handleDoubleClick() {
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="h-10 flex items-center justify-between px-4 bg-bg-surface border-b border-border-subtle select-none shrink-0"
|
||||
class="h-11 flex items-center justify-between px-4 bg-bg-surface border-b border-border-subtle select-none shrink-0"
|
||||
@mousedown.left="startDrag"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
@@ -58,11 +64,18 @@ async function handleDoubleClick() {
|
||||
|
||||
<!-- Center: Running timer status -->
|
||||
<div
|
||||
role="status"
|
||||
aria-live="off"
|
||||
class="flex items-center gap-3 transition-opacity duration-150"
|
||||
:class="timerStore.isRunning ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
:class="timerStore.isRunning || timerStore.isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<!-- Pulsing green dot -->
|
||||
<div class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot" />
|
||||
<!-- Status dot -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="timerStore.isRunning ? 'bg-status-running animate-pulse-dot' : 'bg-status-warning'"
|
||||
/>
|
||||
<span class="sr-only">{{ timerStore.isRunning ? 'Timer running' : timerStore.isPaused ? 'Timer paused' : 'Timer stopped' }}</span>
|
||||
|
||||
<!-- Project name -->
|
||||
<span class="text-[0.6875rem] text-text-secondary">
|
||||
@@ -70,18 +83,46 @@ async function handleDoubleClick() {
|
||||
</span>
|
||||
|
||||
<!-- Elapsed time -->
|
||||
<span class="text-[0.75rem] font-mono text-text-primary tracking-wider">
|
||||
<span class="text-[0.75rem] font-[family-name:var(--font-timer)] text-text-primary tracking-wider">
|
||||
{{ timerStore.formattedTime }}
|
||||
</span>
|
||||
|
||||
<!-- Pause / Resume button -->
|
||||
<button
|
||||
v-if="timerStore.isRunning"
|
||||
v-tooltip.bottom="'Pause timer'"
|
||||
@click="timerStore.pauseManual()"
|
||||
@mousedown.stop
|
||||
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-warning transition-colors duration-150"
|
||||
aria-label="Pause timer"
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
|
||||
<rect x="3" y="2" width="3.5" height="12" rx="1" />
|
||||
<rect x="9.5" y="2" width="3.5" height="12" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
|
||||
v-tooltip.bottom="'Resume timer'"
|
||||
@click="timerStore.resumeFromPause()"
|
||||
@mousedown.stop
|
||||
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-accent-text transition-colors duration-150"
|
||||
aria-label="Resume timer"
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
|
||||
<path d="M4 2.5a.5.5 0 01.77-.42l9 5.5a.5.5 0 010 .84l-9 5.5A.5.5 0 014 13.5V2.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Stop button -->
|
||||
<button
|
||||
v-tooltip.bottom="'Stop timer'"
|
||||
@click="timerStore.stop()"
|
||||
@mousedown.stop
|
||||
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
|
||||
title="Stop timer"
|
||||
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
|
||||
aria-label="Stop timer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
|
||||
<rect x="3" y="3" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -90,35 +131,38 @@ async function handleDoubleClick() {
|
||||
<!-- Right: Window controls -->
|
||||
<div class="flex items-center" @mousedown.stop>
|
||||
<button
|
||||
v-tooltip.bottom="'Minimize'"
|
||||
@click="minimize"
|
||||
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
title="Minimize"
|
||||
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5">
|
||||
<path d="M5 12h14" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-tooltip.bottom="isMaximized ? 'Restore' : 'Maximize'"
|
||||
@click="toggleMaximize"
|
||||
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
:title="isMaximized ? 'Restore' : 'Maximize'"
|
||||
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
:aria-label="isMaximized ? 'Restore' : 'Maximize'"
|
||||
>
|
||||
<svg v-if="!isMaximized" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
|
||||
<svg v-if="!isMaximized" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
|
||||
<rect x="4" y="4" width="16" height="16" rx="1" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
|
||||
<svg v-else aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
|
||||
<rect x="3" y="7" width="14" height="14" rx="1" />
|
||||
<path d="M7 7V5a1 1 0 011-1h12a1 1 0 011 1v12a1 1 0 01-1 1h-2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-tooltip.bottom="'Close'"
|
||||
@click="close"
|
||||
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
|
||||
title="Close"
|
||||
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
|
||||
<path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -11,8 +11,7 @@ const toastStore = useToastStore()
|
||||
aria-label="Notifications"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
class="fixed top-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 pointer-events-none"
|
||||
style="margin-left: 24px;"
|
||||
class="fixed top-4 left-1/2 -translate-x-1/2 ml-6 z-[100] flex flex-col gap-2 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
v-for="toast in toastStore.toasts"
|
||||
@@ -24,7 +23,7 @@ const toastStore = useToastStore()
|
||||
@mouseleave="toastStore.resumeToast(toast.id)"
|
||||
@focusin="toastStore.pauseToast(toast.id)"
|
||||
@focusout="toastStore.resumeToast(toast.id)"
|
||||
class="w-80 flex items-center gap-3 px-4 py-3 bg-bg-surface border border-border-subtle rounded-lg shadow-lg pointer-events-auto border-l-[3px]"
|
||||
class="relative w-80 flex items-center gap-3 px-4 py-3 bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] pointer-events-auto border-l-[3px] overflow-hidden"
|
||||
:class="[
|
||||
toast.exiting ? 'animate-toast-exit' : 'animate-toast-enter',
|
||||
toast.type === 'success' ? 'border-l-status-running' : '',
|
||||
@@ -36,22 +35,38 @@ const toastStore = useToastStore()
|
||||
<AlertCircle v-if="toast.type === 'error'" class="w-4 h-4 text-status-error shrink-0" aria-hidden="true" :stroke-width="2" />
|
||||
<Info v-if="toast.type === 'info'" class="w-4 h-4 text-accent shrink-0" aria-hidden="true" :stroke-width="2" />
|
||||
<span class="sr-only">{{ toast.type === 'success' ? 'Success:' : toast.type === 'error' ? 'Error:' : 'Info:' }}</span>
|
||||
<span class="text-sm text-text-primary flex-1">{{ toast.message }}</span>
|
||||
<span class="text-[0.875rem] text-text-primary flex-1">{{ toast.message }}</span>
|
||||
<button
|
||||
v-if="toast.onUndo"
|
||||
aria-label="Undo"
|
||||
v-tooltip="'Undo'"
|
||||
@click.stop="toastStore.undoToast(toast.id)"
|
||||
class="shrink-0 p-1 text-accent hover:text-accent/80 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded"
|
||||
class="shrink-0 p-1 text-accent hover:text-accent/80 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded-lg"
|
||||
>
|
||||
<Undo2 class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="2" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
v-tooltip="'Dismiss'"
|
||||
@click.stop="toastStore.removeToast(toast.id)"
|
||||
class="shrink-0 p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded"
|
||||
class="shrink-0 p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded-lg"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="2" />
|
||||
</button>
|
||||
<div
|
||||
v-if="!toast.exiting"
|
||||
aria-hidden="true"
|
||||
class="absolute bottom-0 left-0 h-0.5 rounded-b-lg"
|
||||
:class="[
|
||||
toast.type === 'success' ? 'bg-status-running' : '',
|
||||
toast.type === 'error' ? 'bg-status-error' : '',
|
||||
toast.type === 'info' ? 'bg-accent' : ''
|
||||
]"
|
||||
:style="{
|
||||
animation: `toast-progress ${toast.duration}ms linear forwards`,
|
||||
animationPlayState: toast.paused ? 'paused' : 'running',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
208
src/components/TourOverlay.vue
Normal file
208
src/components/TourOverlay.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useTourStore } from '../stores/tour'
|
||||
import { getZoomFactor } from '../utils/dropdown'
|
||||
|
||||
const tourStore = useTourStore()
|
||||
|
||||
const spotlightStyle = ref<Record<string, string>>({})
|
||||
const tooltipStyle = ref<Record<string, string>>({})
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function positionElements() {
|
||||
const step = tourStore.currentStep
|
||||
if (!step) return
|
||||
|
||||
const el = document.querySelector(step.target) as HTMLElement | null
|
||||
if (!el) {
|
||||
if (!tourStore.isLastStep) {
|
||||
tourStore.next()
|
||||
} else {
|
||||
tourStore.stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
setTimeout(() => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const padding = 6
|
||||
const zoom = getZoomFactor()
|
||||
|
||||
spotlightStyle.value = {
|
||||
top: `${(rect.top - padding) / zoom}px`,
|
||||
left: `${(rect.left - padding) / zoom}px`,
|
||||
width: `${(rect.width + padding * 2) / zoom}px`,
|
||||
height: `${(rect.height + padding * 2) / zoom}px`,
|
||||
}
|
||||
|
||||
const placement = step.placement || 'bottom'
|
||||
const tooltipWidth = 280
|
||||
const tooltipGap = 12
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
if (placement === 'bottom') {
|
||||
top = rect.bottom + tooltipGap
|
||||
left = rect.left + rect.width / 2 - tooltipWidth * zoom / 2
|
||||
} else if (placement === 'top') {
|
||||
top = rect.top - tooltipGap
|
||||
left = rect.left + rect.width / 2 - tooltipWidth * zoom / 2
|
||||
} else if (placement === 'right') {
|
||||
top = rect.top + rect.height / 2
|
||||
left = rect.right + tooltipGap
|
||||
} else if (placement === 'left') {
|
||||
top = rect.top + rect.height / 2
|
||||
left = rect.left - tooltipGap - tooltipWidth * zoom
|
||||
}
|
||||
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - tooltipWidth * zoom - 12))
|
||||
|
||||
tooltipStyle.value = {
|
||||
top: placement === 'top' ? 'auto' : `${top / zoom}px`,
|
||||
bottom: placement === 'top' ? `${(window.innerHeight - top) / zoom}px` : 'auto',
|
||||
left: `${left / zoom}px`,
|
||||
width: `${tooltipWidth}px`,
|
||||
}
|
||||
|
||||
// Focus the tooltip for screen readers
|
||||
nextTick(() => {
|
||||
if (tooltipRef.value) {
|
||||
tooltipRef.value.focus()
|
||||
}
|
||||
})
|
||||
}, 350)
|
||||
}
|
||||
|
||||
watch(() => tourStore.currentStepIndex, () => {
|
||||
if (tourStore.isActive) {
|
||||
nextTick(() => positionElements())
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => tourStore.isActive, (active) => {
|
||||
if (active) {
|
||||
nextTick(() => positionElements())
|
||||
}
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!tourStore.isActive) return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
tourStore.stop()
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
tourStore.next()
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!tourStore.isFirstStep) tourStore.back()
|
||||
} else if (e.key === 'Tab') {
|
||||
// Trap focus within the tooltip
|
||||
if (!tooltipRef.value) return
|
||||
const focusable = tooltipRef.value.querySelectorAll('button')
|
||||
if (focusable.length === 0) return
|
||||
|
||||
const first = focusable[0] as HTMLElement
|
||||
const last = focusable[focusable.length - 1] as HTMLElement
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
if (tourStore.isActive) {
|
||||
positionElements()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#app">
|
||||
<Transition name="tour-fade">
|
||||
<div
|
||||
v-if="tourStore.isActive"
|
||||
class="fixed inset-0 z-[9998]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="'Feature tour: step ' + (tourStore.currentStepIndex + 1) + ' of ' + tourStore.totalSteps"
|
||||
>
|
||||
<!-- Backdrop - click to dismiss -->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
@click="tourStore.stop()"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Spotlight cutout -->
|
||||
<div
|
||||
class="absolute rounded-md pointer-events-none transition-all duration-300 ease-out"
|
||||
:style="spotlightStyle"
|
||||
style="box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Tooltip card -->
|
||||
<div
|
||||
ref="tooltipRef"
|
||||
tabindex="-1"
|
||||
class="absolute z-[9999] bg-bg-surface border border-border-subtle rounded-lg shadow-lg p-4 pointer-events-auto transition-all duration-300 ease-out focus:outline-none"
|
||||
:style="tooltipStyle"
|
||||
>
|
||||
<p class="text-[0.8125rem] font-medium text-text-primary mb-1" aria-live="polite">
|
||||
{{ tourStore.currentStep?.title }}
|
||||
</p>
|
||||
<p class="text-[0.75rem] text-text-secondary leading-relaxed mb-3">
|
||||
{{ tourStore.currentStep?.content }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[0.6875rem] text-text-tertiary" aria-hidden="true">
|
||||
{{ tourStore.currentStepIndex + 1 }} / {{ tourStore.totalSteps }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2" role="group" aria-label="Tour navigation">
|
||||
<button
|
||||
@click="tourStore.stop()"
|
||||
class="px-2.5 py-1 text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors duration-150 rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
v-if="!tourStore.isFirstStep"
|
||||
@click="tourStore.back()"
|
||||
class="px-2.5 py-1 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-md hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
@click="tourStore.next()"
|
||||
class="px-3 py-1 text-[0.6875rem] bg-accent text-bg-base font-medium rounded-md hover:bg-accent-hover transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||
>
|
||||
{{ tourStore.isLastStep ? 'Done' : 'Next' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
15
src/composables/useAnnouncer.ts
Normal file
15
src/composables/useAnnouncer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const announcement = ref('')
|
||||
|
||||
export function useAnnouncer() {
|
||||
function announce(message: string) {
|
||||
// Clear then set to ensure repeated identical messages are announced
|
||||
announcement.value = ''
|
||||
requestAnimationFrame(() => {
|
||||
announcement.value = message
|
||||
})
|
||||
}
|
||||
|
||||
return { announcement, announce }
|
||||
}
|
||||
256
src/directives/tooltip.ts
Normal file
256
src/directives/tooltip.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { Directive, DirectiveBinding } from 'vue'
|
||||
import { getZoomFactor } from '../utils/dropdown'
|
||||
|
||||
interface TooltipState {
|
||||
el: HTMLElement
|
||||
tooltipEl: HTMLElement | null
|
||||
text: string
|
||||
showTimeout: number | null
|
||||
hideTimeout: number | null
|
||||
placement: 'top' | 'bottom' | 'left' | 'right' | 'auto'
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
onKeyDown: (e: KeyboardEvent) => void
|
||||
onPointerDown: () => void
|
||||
}
|
||||
|
||||
const stateMap = new WeakMap<HTMLElement, TooltipState>()
|
||||
const SHOW_DELAY = 400
|
||||
const HIDE_DELAY = 100
|
||||
|
||||
function createTooltipEl(): HTMLElement {
|
||||
const tip = document.createElement('div')
|
||||
tip.setAttribute('role', 'tooltip')
|
||||
tip.className = [
|
||||
'fixed z-[9999] px-2 py-1 rounded-lg pointer-events-none',
|
||||
'bg-bg-elevated border border-border-subtle',
|
||||
'text-[0.6875rem] text-text-primary whitespace-nowrap',
|
||||
'opacity-0 transition-opacity duration-150',
|
||||
].join(' ')
|
||||
|
||||
const arrow = document.createElement('div')
|
||||
arrow.setAttribute('aria-hidden', 'true')
|
||||
arrow.dataset.arrow = ''
|
||||
arrow.className = 'absolute w-0 h-0'
|
||||
tip.appendChild(arrow)
|
||||
|
||||
const content = document.createElement('span')
|
||||
content.dataset.content = ''
|
||||
tip.appendChild(content)
|
||||
|
||||
return tip
|
||||
}
|
||||
|
||||
function positionTooltip(state: TooltipState) {
|
||||
const tip = state.tooltipEl
|
||||
if (!tip) return
|
||||
|
||||
const rect = state.el.getBoundingClientRect()
|
||||
const zoom = getZoomFactor()
|
||||
const margin = 6
|
||||
const arrowSize = 4
|
||||
|
||||
// Measure tooltip
|
||||
tip.style.left = '-9999px'
|
||||
tip.style.top = '-9999px'
|
||||
tip.style.opacity = '0'
|
||||
const tipRect = tip.getBoundingClientRect()
|
||||
const tipW = tipRect.width / zoom
|
||||
const tipH = tipRect.height / zoom
|
||||
|
||||
const elTop = rect.top / zoom
|
||||
const elLeft = rect.left / zoom
|
||||
const elW = rect.width / zoom
|
||||
const elH = rect.height / zoom
|
||||
const vpW = window.innerWidth / zoom
|
||||
const vpH = window.innerHeight / zoom
|
||||
|
||||
// Determine placement
|
||||
let placement = state.placement
|
||||
if (placement === 'auto') {
|
||||
const spaceAbove = elTop
|
||||
const spaceBelow = vpH - elTop - elH
|
||||
const spaceRight = vpW - elLeft - elW
|
||||
const spaceLeft = elLeft
|
||||
|
||||
// Prefer top, then bottom, then right, then left
|
||||
if (spaceAbove >= tipH + margin + arrowSize) placement = 'top'
|
||||
else if (spaceBelow >= tipH + margin + arrowSize) placement = 'bottom'
|
||||
else if (spaceRight >= tipW + margin + arrowSize) placement = 'right'
|
||||
else if (spaceLeft >= tipW + margin + arrowSize) placement = 'left'
|
||||
else placement = 'top'
|
||||
}
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
const arrow = tip.querySelector('[data-arrow]') as HTMLElement
|
||||
|
||||
// Reset arrow classes
|
||||
arrow.className = 'absolute w-0 h-0'
|
||||
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
top = elTop - tipH - margin
|
||||
left = elLeft + elW / 2 - tipW / 2
|
||||
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-t-4 left-1/2 -translate-x-1/2'
|
||||
arrow.style.cssText = 'bottom: -4px; border-top-color: var(--color-bg-elevated);'
|
||||
break
|
||||
case 'bottom':
|
||||
top = elTop + elH + margin
|
||||
left = elLeft + elW / 2 - tipW / 2
|
||||
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-b-4 left-1/2 -translate-x-1/2'
|
||||
arrow.style.cssText = 'top: -4px; border-bottom-color: var(--color-bg-elevated);'
|
||||
break
|
||||
case 'right':
|
||||
top = elTop + elH / 2 - tipH / 2
|
||||
left = elLeft + elW + margin
|
||||
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-r-4 top-1/2 -translate-y-1/2'
|
||||
arrow.style.cssText = 'left: -4px; border-right-color: var(--color-bg-elevated);'
|
||||
break
|
||||
case 'left':
|
||||
top = elTop + elH / 2 - tipH / 2
|
||||
left = elLeft - tipW - margin
|
||||
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-l-4 top-1/2 -translate-y-1/2'
|
||||
arrow.style.cssText = 'right: -4px; border-left-color: var(--color-bg-elevated);'
|
||||
break
|
||||
}
|
||||
|
||||
// Clamp to viewport
|
||||
left = Math.max(4, Math.min(left, vpW - tipW - 4))
|
||||
top = Math.max(4, Math.min(top, vpH - tipH - 4))
|
||||
|
||||
tip.style.left = `${left}px`
|
||||
tip.style.top = `${top}px`
|
||||
tip.style.opacity = '1'
|
||||
}
|
||||
|
||||
function show(state: TooltipState) {
|
||||
if (state.hideTimeout) {
|
||||
clearTimeout(state.hideTimeout)
|
||||
state.hideTimeout = null
|
||||
}
|
||||
if (state.tooltipEl) return
|
||||
if (!state.text) return
|
||||
|
||||
state.showTimeout = window.setTimeout(() => {
|
||||
const tip = createTooltipEl()
|
||||
const content = tip.querySelector('[data-content]') as HTMLElement
|
||||
content.textContent = state.text
|
||||
|
||||
// WCAG: link tooltip to trigger via aria-describedby
|
||||
const id = 'tooltip-' + Math.random().toString(36).slice(2, 9)
|
||||
tip.id = id
|
||||
state.el.setAttribute('aria-describedby', id)
|
||||
|
||||
const container = document.getElementById('app') || document.body
|
||||
container.appendChild(tip)
|
||||
state.tooltipEl = tip
|
||||
|
||||
// Position after DOM insertion (need measured size)
|
||||
requestAnimationFrame(() => positionTooltip(state))
|
||||
}, SHOW_DELAY)
|
||||
}
|
||||
|
||||
function hide(state: TooltipState) {
|
||||
if (state.showTimeout) {
|
||||
clearTimeout(state.showTimeout)
|
||||
state.showTimeout = null
|
||||
}
|
||||
if (!state.tooltipEl) return
|
||||
|
||||
state.hideTimeout = window.setTimeout(() => {
|
||||
state.el.removeAttribute('aria-describedby')
|
||||
state.tooltipEl?.remove()
|
||||
state.tooltipEl = null
|
||||
state.hideTimeout = null
|
||||
}, HIDE_DELAY)
|
||||
}
|
||||
|
||||
function parseBinding(binding: DirectiveBinding): { text: string; placement: 'top' | 'bottom' | 'left' | 'right' | 'auto' } {
|
||||
let text = ''
|
||||
let placement: 'top' | 'bottom' | 'left' | 'right' | 'auto' = 'auto'
|
||||
|
||||
if (typeof binding.value === 'string') {
|
||||
text = binding.value
|
||||
} else if (binding.value && typeof binding.value === 'object') {
|
||||
text = binding.value.text || ''
|
||||
placement = binding.value.placement || 'auto'
|
||||
}
|
||||
|
||||
// Modifier overrides
|
||||
if (binding.modifiers.top) placement = 'top'
|
||||
if (binding.modifiers.bottom) placement = 'bottom'
|
||||
if (binding.modifiers.left) placement = 'left'
|
||||
if (binding.modifiers.right) placement = 'right'
|
||||
|
||||
return { text, placement }
|
||||
}
|
||||
|
||||
export const vTooltip: Directive = {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||
const { text, placement } = parseBinding(binding)
|
||||
|
||||
const state: TooltipState = {
|
||||
el,
|
||||
tooltipEl: null,
|
||||
text,
|
||||
showTimeout: null,
|
||||
hideTimeout: null,
|
||||
placement,
|
||||
onMouseEnter: () => show(state),
|
||||
onMouseLeave: () => hide(state),
|
||||
onFocus: () => show(state),
|
||||
onBlur: () => hide(state),
|
||||
onKeyDown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && state.tooltipEl) {
|
||||
hide(state)
|
||||
}
|
||||
},
|
||||
onPointerDown: () => hide(state),
|
||||
}
|
||||
|
||||
el.addEventListener('mouseenter', state.onMouseEnter)
|
||||
el.addEventListener('mouseleave', state.onMouseLeave)
|
||||
el.addEventListener('focus', state.onFocus)
|
||||
el.addEventListener('blur', state.onBlur)
|
||||
el.addEventListener('keydown', state.onKeyDown)
|
||||
el.addEventListener('pointerdown', state.onPointerDown)
|
||||
|
||||
stateMap.set(el, state)
|
||||
},
|
||||
|
||||
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||
const state = stateMap.get(el)
|
||||
if (!state) return
|
||||
const { text, placement } = parseBinding(binding)
|
||||
state.text = text
|
||||
state.placement = placement
|
||||
|
||||
// Update live tooltip text if visible
|
||||
if (state.tooltipEl) {
|
||||
const content = state.tooltipEl.querySelector('[data-content]')
|
||||
if (content) content.textContent = text
|
||||
}
|
||||
},
|
||||
|
||||
beforeUnmount(el: HTMLElement) {
|
||||
const state = stateMap.get(el)
|
||||
if (!state) return
|
||||
|
||||
if (state.showTimeout) clearTimeout(state.showTimeout)
|
||||
if (state.hideTimeout) clearTimeout(state.hideTimeout)
|
||||
state.el.removeAttribute('aria-describedby')
|
||||
state.tooltipEl?.remove()
|
||||
|
||||
el.removeEventListener('mouseenter', state.onMouseEnter)
|
||||
el.removeEventListener('mouseleave', state.onMouseLeave)
|
||||
el.removeEventListener('focus', state.onFocus)
|
||||
el.removeEventListener('blur', state.onBlur)
|
||||
el.removeEventListener('keydown', state.onKeyDown)
|
||||
el.removeEventListener('pointerdown', state.onPointerDown)
|
||||
|
||||
stateMap.delete(el)
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
|
||||
import { MotionPlugin } from '@vueuse/motion'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import { vTooltip } from './directives/tooltip'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -11,4 +12,5 @@ const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(MotionPlugin)
|
||||
app.directive('tooltip', vTooltip)
|
||||
app.mount('#app')
|
||||
|
||||
5
src/mini-timer-entry.ts
Normal file
5
src/mini-timer-entry.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import MiniTimer from './views/MiniTimer.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
createApp(MiniTimer).mount('#app')
|
||||
@@ -23,6 +23,11 @@ const router = createRouter({
|
||||
name: 'Projects',
|
||||
component: () => import('../views/Projects.vue')
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
name: 'ProjectDetail',
|
||||
component: () => import('../views/ProjectDetail.vue')
|
||||
},
|
||||
{
|
||||
path: '/entries',
|
||||
name: 'Entries',
|
||||
@@ -61,4 +66,31 @@ const router = createRouter({
|
||||
]
|
||||
})
|
||||
|
||||
router.afterEach(async (to) => {
|
||||
const name = (to.name as string) || 'ZeroClock'
|
||||
document.title = `${name} - ZeroClock`
|
||||
|
||||
setTimeout(() => {
|
||||
const main = document.getElementById('main-content')
|
||||
if (main) {
|
||||
main.focus()
|
||||
}
|
||||
const announcer = document.getElementById('route-announcer')
|
||||
if (announcer) {
|
||||
announcer.textContent = `Navigated to ${name}`
|
||||
}
|
||||
}, 300)
|
||||
|
||||
// Track page visits for onboarding checklist
|
||||
try {
|
||||
const { useOnboardingStore } = await import('../stores/onboarding')
|
||||
const onboardingStore = useOnboardingStore()
|
||||
if (onboardingStore.loaded) {
|
||||
onboardingStore.onRouteVisit(to.path)
|
||||
}
|
||||
} catch {
|
||||
// Onboarding store not ready yet - ignore
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface Client {
|
||||
tax_id?: string
|
||||
payment_terms?: string
|
||||
notes?: string
|
||||
currency?: string
|
||||
}
|
||||
|
||||
export const useClientsStore = defineStore('clients', () => {
|
||||
|
||||
@@ -40,6 +40,20 @@ export const useEntryTemplatesStore = defineStore('entryTemplates', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTemplate(template: EntryTemplate): Promise<boolean> {
|
||||
try {
|
||||
await invoke('update_entry_template', { template })
|
||||
const index = templates.value.findIndex(t => t.id === template.id)
|
||||
if (index !== -1) {
|
||||
templates.value[index] = template
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
handleInvokeError(error, 'Failed to update template')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate(id: number) {
|
||||
try {
|
||||
await invoke('delete_entry_template', { id })
|
||||
@@ -49,5 +63,5 @@ export const useEntryTemplatesStore = defineStore('entryTemplates', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { templates, loading, fetchTemplates, createTemplate, deleteTemplate }
|
||||
return { templates, loading, fetchTemplates, createTemplate, updateTemplate, deleteTemplate }
|
||||
})
|
||||
|
||||
@@ -45,10 +45,15 @@ export const useFavoritesStore = defineStore('favorites', () => {
|
||||
}
|
||||
|
||||
async function reorderFavorites(ids: number[]): Promise<boolean> {
|
||||
const oldOrder = [...favorites.value]
|
||||
favorites.value = ids
|
||||
.map(id => favorites.value.find(f => f.id === id))
|
||||
.filter((f): f is Favorite => f !== undefined)
|
||||
try {
|
||||
await invoke('reorder_favorites', { ids })
|
||||
return true
|
||||
} catch (error) {
|
||||
favorites.value = oldOrder
|
||||
handleInvokeError(error, 'Failed to reorder favorites')
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { handleInvokeError } from '../utils/errorHandler'
|
||||
|
||||
@@ -152,9 +152,22 @@ export const useInvoicesStore = defineStore('invoices', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const overdueCount = computed(() => invoices.value.filter(i => i.status === 'overdue').length)
|
||||
|
||||
const groupedByStatus = computed(() => {
|
||||
const groups: Record<string, Invoice[]> = { draft: [], sent: [], overdue: [], paid: [] }
|
||||
for (const inv of invoices.value) {
|
||||
if (groups[inv.status]) groups[inv.status].push(inv)
|
||||
else groups.draft.push(inv)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
return {
|
||||
invoices,
|
||||
loading,
|
||||
overdueCount,
|
||||
groupedByStatus,
|
||||
fetchInvoices,
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { handleInvokeError } from '../utils/errorHandler'
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface Project {
|
||||
budget_amount?: number | null
|
||||
rounding_override?: number | null
|
||||
timeline_override?: string | null
|
||||
notes?: string | null
|
||||
currency?: string | null
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
@@ -21,6 +23,7 @@ export interface Task {
|
||||
project_id: number
|
||||
name: string
|
||||
estimated_hours?: number | null
|
||||
hourly_rate?: number | null
|
||||
}
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
@@ -112,9 +115,14 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const activeProjects = computed(() => projects.value.filter(p => !p.archived))
|
||||
const archivedProjects = computed(() => projects.value.filter(p => p.archived))
|
||||
|
||||
return {
|
||||
projects,
|
||||
loading,
|
||||
activeProjects,
|
||||
archivedProjects,
|
||||
fetchProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
settings.value[key] = value
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to update setting:', error)
|
||||
handleInvokeError(error, 'Failed to save setting')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from './settings'
|
||||
import { useProjectsStore } from './projects'
|
||||
import { useAnnouncer } from '../composables/useAnnouncer'
|
||||
import { audioEngine } from '../utils/audio'
|
||||
import { handleInvokeError } from '../utils/errorHandler'
|
||||
import type { TimeEntry } from './entries'
|
||||
|
||||
export type TimerState = 'STOPPED' | 'RUNNING' | 'PAUSED_IDLE' | 'PAUSED_APP' | 'PAUSED_MANUAL'
|
||||
@@ -441,7 +442,7 @@ export const useTimerStore = defineStore('timer', () => {
|
||||
const id = await invoke<number>('create_time_entry', { entry })
|
||||
currentEntry.value = { ...entry, id: Number(id) }
|
||||
} catch (error) {
|
||||
console.error('Failed to create time entry:', error)
|
||||
handleInvokeError(error, 'Failed to save time entry')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,9 +581,9 @@ export const useTimerStore = defineStore('timer', () => {
|
||||
pausedAt: pausedAt.value,
|
||||
}
|
||||
settingsStore.updateSetting('timer_state_backup', JSON.stringify(data))
|
||||
.catch(e => console.error('Failed to persist timer state:', e))
|
||||
.catch(e => handleInvokeError(e, 'Failed to save timer state'))
|
||||
} catch (e) {
|
||||
console.error('Failed to persist timer state:', e)
|
||||
handleInvokeError(e, 'Failed to save timer state')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,7 +642,7 @@ export const useTimerStore = defineStore('timer', () => {
|
||||
if (m > 0) parts.push(`${m}m`)
|
||||
announce(`Timer restored: ${project.name}, ${parts.join(' ') || 'less than a minute'}`)
|
||||
} catch (e) {
|
||||
console.error('Failed to restore timer state:', e)
|
||||
handleInvokeError(e, 'Failed to restore timer state')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
--color-status-running: #34D399;
|
||||
--color-status-warning: #EAB308;
|
||||
--color-status-error: #EF4444;
|
||||
--color-status-error-text: #F87171;
|
||||
--color-status-info: #3B82F6;
|
||||
|
||||
/* Fonts */
|
||||
@@ -53,7 +54,7 @@
|
||||
--color-accent: #8B5CF6;
|
||||
--color-accent-hover: #7C3AED;
|
||||
--color-accent-muted: rgba(139, 92, 246, 0.25);
|
||||
--color-accent-text: #A78BFA;
|
||||
--color-accent-text: #C4B5FD;
|
||||
}
|
||||
[data-accent="green"] {
|
||||
--color-accent: #10B981;
|
||||
@@ -91,6 +92,7 @@
|
||||
--color-text-tertiary: #57524D;
|
||||
--color-border-subtle: #E7E5E4;
|
||||
--color-border-visible: #D6D3D1;
|
||||
--color-status-error-text: #B91C1C;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -231,6 +233,11 @@
|
||||
animation: toast-exit 150ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-progress {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
|
||||
/* Pulse animation for timer colon */
|
||||
@keyframes pulse-colon {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
@@ -538,6 +545,26 @@
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.reduce-motion .btn-primary:hover,
|
||||
.reduce-motion .btn-primary:active,
|
||||
.reduce-motion .btn-icon:active,
|
||||
.reduce-motion .btn-icon-delete:active,
|
||||
.reduce-motion .card-hover:hover,
|
||||
.reduce-motion .card-hover:active {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-primary:hover,
|
||||
.btn-primary:active,
|
||||
.btn-icon:active,
|
||||
.btn-icon-delete:active,
|
||||
.card-hover:hover,
|
||||
.card-hover:active {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--color-text-secondary: #D0D0C8;
|
||||
@@ -558,3 +585,68 @@
|
||||
outline: 2px solid Highlight;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
header,
|
||||
nav,
|
||||
.fixed,
|
||||
.sticky,
|
||||
[role="tooltip"],
|
||||
[role="status"],
|
||||
.animate-pulse-dot,
|
||||
.animate-float,
|
||||
.sr-only-focusable,
|
||||
button[aria-label="Minimize"],
|
||||
button[aria-label="Maximize"],
|
||||
button[aria-label="Restore"],
|
||||
button[aria-label="Close"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body,
|
||||
[data-theme="dark"],
|
||||
[data-theme="light"] {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
* {
|
||||
color: black !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
a[href]::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.75em;
|
||||
color: #666 !important;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100% !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
228
src/utils/audio.ts
Normal file
228
src/utils/audio.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
export type SoundEvent =
|
||||
| 'timer_start'
|
||||
| 'timer_stop'
|
||||
| 'timer_pause'
|
||||
| 'timer_resume'
|
||||
| 'idle_alert'
|
||||
| 'goal_reached'
|
||||
| 'break_reminder'
|
||||
|
||||
export const SOUND_EVENTS: { key: SoundEvent; label: string }[] = [
|
||||
{ key: 'timer_start', label: 'Timer start' },
|
||||
{ key: 'timer_stop', label: 'Timer stop' },
|
||||
{ key: 'timer_pause', label: 'Pause' },
|
||||
{ key: 'timer_resume', label: 'Resume' },
|
||||
{ key: 'idle_alert', label: 'Idle alert' },
|
||||
{ key: 'goal_reached', label: 'Goal reached' },
|
||||
{ key: 'break_reminder', label: 'Break reminder' },
|
||||
]
|
||||
|
||||
export interface AudioSettings {
|
||||
enabled: boolean
|
||||
mode: 'synthesized' | 'system' | 'custom'
|
||||
volume: number
|
||||
events: Record<SoundEvent, boolean>
|
||||
}
|
||||
|
||||
export const DEFAULT_EVENTS: Record<SoundEvent, boolean> = {
|
||||
timer_start: true,
|
||||
timer_stop: true,
|
||||
timer_pause: true,
|
||||
timer_resume: true,
|
||||
idle_alert: true,
|
||||
goal_reached: true,
|
||||
break_reminder: true,
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AudioSettings = {
|
||||
enabled: false,
|
||||
mode: 'synthesized',
|
||||
volume: 70,
|
||||
events: { ...DEFAULT_EVENTS },
|
||||
}
|
||||
|
||||
class AudioEngine {
|
||||
private ctx: AudioContext | null = null
|
||||
private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } }
|
||||
|
||||
private ensureContext(): AudioContext {
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext()
|
||||
}
|
||||
if (this.ctx.state === 'suspended') {
|
||||
this.ctx.resume()
|
||||
}
|
||||
return this.ctx
|
||||
}
|
||||
|
||||
private get gain(): number {
|
||||
return this.settings.volume / 100
|
||||
}
|
||||
|
||||
updateSettings(partial: Partial<AudioSettings>) {
|
||||
if (partial.enabled !== undefined) this.settings.enabled = partial.enabled
|
||||
if (partial.mode !== undefined) this.settings.mode = partial.mode
|
||||
if (partial.volume !== undefined) this.settings.volume = partial.volume
|
||||
if (partial.events !== undefined) this.settings.events = { ...partial.events }
|
||||
}
|
||||
|
||||
getSettings(): AudioSettings {
|
||||
return { ...this.settings, events: { ...this.settings.events } }
|
||||
}
|
||||
|
||||
play(event: SoundEvent) {
|
||||
if (!this.settings.enabled) return
|
||||
if (!this.settings.events[event]) return
|
||||
if (this.settings.mode !== 'synthesized') return
|
||||
this.synthesize(event)
|
||||
}
|
||||
|
||||
playTest(event: SoundEvent) {
|
||||
this.synthesize(event)
|
||||
}
|
||||
|
||||
private synthesize(event: SoundEvent) {
|
||||
switch (event) {
|
||||
case 'timer_start':
|
||||
this.playTimerStart()
|
||||
break
|
||||
case 'timer_stop':
|
||||
this.playTimerStop()
|
||||
break
|
||||
case 'timer_pause':
|
||||
this.playTimerPause()
|
||||
break
|
||||
case 'timer_resume':
|
||||
this.playTimerResume()
|
||||
break
|
||||
case 'idle_alert':
|
||||
this.playIdleAlert()
|
||||
break
|
||||
case 'goal_reached':
|
||||
this.playGoalReached()
|
||||
break
|
||||
case 'break_reminder':
|
||||
this.playBreakReminder()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Quick ascending two-note chime: C5 then E5
|
||||
private playTimerStart() {
|
||||
const ctx = this.ensureContext()
|
||||
const t = ctx.currentTime
|
||||
const vol = this.gain
|
||||
|
||||
// Note 1: C5 (523Hz) for 100ms
|
||||
this.playTone(ctx, t, 523, 0.100, vol, 0.010, 0.050, 3)
|
||||
// Note 2: E5 (659Hz) for 150ms
|
||||
this.playTone(ctx, t + 0.110, 659, 0.150, vol, 0.010, 0.050, 3)
|
||||
}
|
||||
|
||||
// Descending resolve: G5 sliding to C5 over 250ms
|
||||
private playTimerStop() {
|
||||
const ctx = this.ensureContext()
|
||||
const t = ctx.currentTime
|
||||
const vol = this.gain
|
||||
const duration = 0.250
|
||||
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(784, t)
|
||||
osc.frequency.linearRampToValueAtTime(523, t + duration)
|
||||
|
||||
gain.gain.setValueAtTime(0, t)
|
||||
gain.gain.linearRampToValueAtTime(vol, t + 0.010)
|
||||
gain.gain.setValueAtTime(vol, t + duration - 0.100)
|
||||
gain.gain.linearRampToValueAtTime(0, t + duration)
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start(t)
|
||||
osc.stop(t + duration)
|
||||
}
|
||||
|
||||
// Single soft tone: A4 (440Hz) for 120ms
|
||||
private playTimerPause() {
|
||||
const ctx = this.ensureContext()
|
||||
const t = ctx.currentTime
|
||||
this.playTone(ctx, t, 440, 0.120, this.gain, 0.005, 0.060)
|
||||
}
|
||||
|
||||
// Single bright tone: C5 (523Hz) for 120ms
|
||||
private playTimerResume() {
|
||||
const ctx = this.ensureContext()
|
||||
const t = ctx.currentTime
|
||||
this.playTone(ctx, t, 523, 0.120, this.gain, 0.005, 0.060)
|
||||
}
|
||||
|
||||
// Two quick pulses at A5 (880Hz), each 80ms with 60ms gap
|
||||
private playIdleAlert() {
|
||||
const ctx = this.ensureContext()
|
||||
const t = ctx.currentTime
|
||||
const vol = this.gain
|
||||
|
||||
this.playTone(ctx, t, 880, 0.080, vol, 0.005, 0.030)
|
||||
this.playTone(ctx, t + 0.140, 880, 0.080, vol, 0.005, 0.030)
|
||||
}
|
||||
|
||||
// Ascending three-note fanfare: C5, E5, G5
|
||||
private playGoalReached() {
|
||||
const ctx = this.ensureContext()
|
||||
const t = ctx.currentTime
|
||||
const vol = this.gain
|
||||
|
||||
this.playTone(ctx, t, 523, 0.120, vol, 0.010, 0.040, 3)
|
||||
this.playTone(ctx, t + 0.130, 659, 0.120, vol, 0.010, 0.040, 3)
|
||||
this.playTone(ctx, t + 0.260, 784, 0.120, vol, 0.010, 0.040, 3)
|
||||
}
|
||||
|
||||
// Gentle single chime at E5 (659Hz), 200ms, long release
|
||||
private playBreakReminder() {
|
||||
const ctx = this.ensureContext()
|
||||
const t = ctx.currentTime
|
||||
this.playTone(ctx, t, 659, 0.200, this.gain, 0.020, 0.100)
|
||||
}
|
||||
|
||||
// Helper: play a single tone with ADSR-style envelope
|
||||
// Optional detuneCents adds a second oscillator slightly detuned for warmth
|
||||
private playTone(
|
||||
ctx: AudioContext,
|
||||
startAt: number,
|
||||
freq: number,
|
||||
duration: number,
|
||||
vol: number,
|
||||
attack: number,
|
||||
release: number,
|
||||
detuneCents?: number
|
||||
) {
|
||||
const endAt = startAt + duration
|
||||
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.gain.setValueAtTime(0, startAt)
|
||||
gainNode.gain.linearRampToValueAtTime(vol, startAt + attack)
|
||||
gainNode.gain.setValueAtTime(vol, endAt - release)
|
||||
gainNode.gain.linearRampToValueAtTime(0, endAt)
|
||||
gainNode.connect(ctx.destination)
|
||||
|
||||
const osc1 = ctx.createOscillator()
|
||||
osc1.type = 'sine'
|
||||
osc1.frequency.setValueAtTime(freq, startAt)
|
||||
osc1.connect(gainNode)
|
||||
osc1.start(startAt)
|
||||
osc1.stop(endAt)
|
||||
|
||||
if (detuneCents) {
|
||||
const osc2 = ctx.createOscillator()
|
||||
osc2.type = 'sine'
|
||||
osc2.frequency.setValueAtTime(freq, startAt)
|
||||
osc2.detune.setValueAtTime(detuneCents, startAt)
|
||||
osc2.connect(gainNode)
|
||||
osc2.start(startAt)
|
||||
osc2.stop(endAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const audioEngine = new AudioEngine()
|
||||
43
src/utils/chartTheme.ts
Normal file
43
src/utils/chartTheme.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export function getChartTheme() {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
return {
|
||||
accent: style.getPropertyValue('--color-accent').trim(),
|
||||
accentMuted: style.getPropertyValue('--color-accent-muted').trim(),
|
||||
textPrimary: style.getPropertyValue('--color-text-primary').trim(),
|
||||
textSecondary: style.getPropertyValue('--color-text-secondary').trim(),
|
||||
textTertiary: style.getPropertyValue('--color-text-tertiary').trim(),
|
||||
gridColor: style.getPropertyValue('--color-border-subtle').trim(),
|
||||
bgSurface: style.getPropertyValue('--color-bg-surface').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export type ChartTheme = ReturnType<typeof getChartTheme>
|
||||
|
||||
export function buildBarChartOptions(theme: ChartTheme) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: theme.bgSurface,
|
||||
titleColor: theme.textPrimary,
|
||||
bodyColor: theme.textSecondary,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: theme.textTertiary, font: { size: 11 } },
|
||||
grid: { display: false },
|
||||
border: { display: false },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: theme.textTertiary, font: { size: 11 } },
|
||||
grid: { color: theme.gridColor },
|
||||
border: { display: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
44
src/utils/csvExport.ts
Normal file
44
src/utils/csvExport.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
function escapeCSV(val: string): string {
|
||||
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||
return '"' + val.replace(/"/g, '""') + '"'
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
function toCSVRow(values: string[]): string {
|
||||
return values.map(escapeCSV).join(',')
|
||||
}
|
||||
|
||||
export function entriesToCSV(
|
||||
entries: { id?: number; start_time: string; duration: number; description?: string; billable?: number; project_id: number; task_id?: number }[],
|
||||
getProjectName: (id: number) => string,
|
||||
getClientName: (projectId: number) => string,
|
||||
getTaskName: (id?: number) => string,
|
||||
getTagNames: (entryId: number) => string
|
||||
): string {
|
||||
const headers = ['Date', 'Project', 'Client', 'Task', 'Description', 'Duration (hours)', 'Billable', 'Tags']
|
||||
const rows = entries.map(e => toCSVRow([
|
||||
e.start_time.split('T')[0],
|
||||
getProjectName(e.project_id),
|
||||
getClientName(e.project_id),
|
||||
getTaskName(e.task_id),
|
||||
e.description || '',
|
||||
(e.duration / 3600).toFixed(2),
|
||||
e.billable === 1 ? 'Yes' : 'No',
|
||||
getTagNames(e.id || 0),
|
||||
]))
|
||||
return [toCSVRow(headers), ...rows].join('\n')
|
||||
}
|
||||
|
||||
export async function downloadCSV(content: string, defaultName: string): Promise<boolean> {
|
||||
const path = await save({
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: 'CSV', extensions: ['csv'] }],
|
||||
})
|
||||
if (!path) return false
|
||||
await invoke('save_binary_file', { path, data: Array.from(new TextEncoder().encode(content)) })
|
||||
return true
|
||||
}
|
||||
86
src/utils/dropdown.ts
Normal file
86
src/utils/dropdown.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export function getZoomFactor(): number {
|
||||
const app = document.getElementById('app')
|
||||
if (!app) return 1
|
||||
const zoom = (app.style as any).zoom
|
||||
return zoom ? parseFloat(zoom) / 100 : 1
|
||||
}
|
||||
|
||||
// Cached probe results for fixed-position coordinate mapping inside #app.
|
||||
// CSS zoom behavior with position:fixed varies across Chromium versions,
|
||||
// so we detect the actual behavior at runtime with a probe element.
|
||||
let _probeCache: { scaleX: number; scaleY: number; offsetX: number; offsetY: number; zoomKey: string } | null = null
|
||||
|
||||
export function getFixedPositionMapping(): { scaleX: number; scaleY: number; offsetX: number; offsetY: number } {
|
||||
const app = document.getElementById('app')
|
||||
if (!app) return { scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 }
|
||||
|
||||
const zoomKey = (app.style as any).zoom || ''
|
||||
if (_probeCache && _probeCache.zoomKey === zoomKey) {
|
||||
return _probeCache
|
||||
}
|
||||
|
||||
const probe = document.createElement('div')
|
||||
probe.style.cssText = 'position:fixed;top:0;left:0;width:100px;height:100px;visibility:hidden;pointer-events:none'
|
||||
app.appendChild(probe)
|
||||
const r0 = probe.getBoundingClientRect()
|
||||
probe.style.top = '100px'
|
||||
probe.style.left = '100px'
|
||||
const r1 = probe.getBoundingClientRect()
|
||||
app.removeChild(probe)
|
||||
|
||||
const result = {
|
||||
scaleX: (r1.left - r0.left) / 100,
|
||||
scaleY: (r1.top - r0.top) / 100,
|
||||
offsetX: r0.left,
|
||||
offsetY: r0.top,
|
||||
zoomKey,
|
||||
}
|
||||
_probeCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
export function resetPositionCache() {
|
||||
_probeCache = null
|
||||
}
|
||||
|
||||
export function computeDropdownPosition(
|
||||
triggerEl: HTMLElement,
|
||||
opts?: { minWidth?: number; estimatedHeight?: number; panelEl?: HTMLElement | null }
|
||||
): Record<string, string> {
|
||||
const rect = triggerEl.getBoundingClientRect()
|
||||
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
|
||||
const gap = 4
|
||||
|
||||
const minW = (opts?.minWidth ?? 0) * scaleX
|
||||
const vpH = window.innerHeight
|
||||
const vpW = window.innerWidth
|
||||
|
||||
// Default: position below trigger
|
||||
let topVP = rect.bottom + gap
|
||||
|
||||
// Use offsetHeight (unaffected by CSS transition transforms like scale(0.95))
|
||||
// and multiply by scaleY to get viewport pixels.
|
||||
const panelEl = opts?.panelEl
|
||||
if (panelEl) {
|
||||
const panelH = panelEl.offsetHeight * scaleY
|
||||
if (topVP + panelH > vpH && rect.top - gap - panelH >= 0) {
|
||||
topVP = rect.top - gap - panelH
|
||||
}
|
||||
}
|
||||
|
||||
let leftVP = rect.left
|
||||
const widthVP = Math.max(rect.width, minW)
|
||||
if (leftVP + widthVP > vpW - gap) {
|
||||
leftVP = vpW - gap - widthVP
|
||||
}
|
||||
if (leftVP < gap) leftVP = gap
|
||||
|
||||
// Convert viewport pixels to CSS pixels for position:fixed inside #app
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${(topVP - offsetY) / scaleY}px`,
|
||||
left: `${(leftVP - offsetX) / scaleX}px`,
|
||||
width: `${widthVP / scaleX}px`,
|
||||
zIndex: '9999',
|
||||
}
|
||||
}
|
||||
71
src/utils/focusTrap.ts
Normal file
71
src/utils/focusTrap.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
export function useFocusTrap() {
|
||||
const trapElement = ref<HTMLElement | null>(null)
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
let isActive = false
|
||||
|
||||
function getFocusableElements(el: HTMLElement): HTMLElement[] {
|
||||
const selectors = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
return Array.from(el.querySelectorAll<HTMLElement>(selectors)).filter(e => e.offsetParent !== null)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
deactivate()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab' || !trapElement.value) return
|
||||
const focusable = getFocusableElements(trapElement.value)
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let onDeactivate: (() => void) | null = null
|
||||
|
||||
function activate(el: HTMLElement, opts?: { onDeactivate?: () => void, initialFocus?: HTMLElement }) {
|
||||
if (isActive) deactivate()
|
||||
isActive = true
|
||||
trapElement.value = el
|
||||
previouslyFocused = document.activeElement as HTMLElement
|
||||
onDeactivate = opts?.onDeactivate || null
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
const target = opts?.initialFocus || getFocusableElements(el)[0]
|
||||
if (target) {
|
||||
requestAnimationFrame(() => {
|
||||
if (trapElement.value === el) target.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
if (!isActive) return
|
||||
isActive = false
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
trapElement.value = null
|
||||
if (onDeactivate) onDeactivate()
|
||||
if (previouslyFocused) {
|
||||
previouslyFocused.focus()
|
||||
previouslyFocused = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isActive) deactivate()
|
||||
})
|
||||
|
||||
return { activate, deactivate }
|
||||
}
|
||||
47
src/utils/fonts.ts
Normal file
47
src/utils/fonts.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface TimerFont {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export const TIMER_FONTS: TimerFont[] = [
|
||||
{ label: 'JetBrains Mono', value: 'JetBrains Mono' },
|
||||
{ label: 'Fira Code', value: 'Fira Code' },
|
||||
{ label: 'Source Code Pro', value: 'Source Code Pro' },
|
||||
{ label: 'IBM Plex Mono', value: 'IBM Plex Mono' },
|
||||
{ label: 'Roboto Mono', value: 'Roboto Mono' },
|
||||
{ label: 'Space Mono', value: 'Space Mono' },
|
||||
{ label: 'Ubuntu Mono', value: 'Ubuntu Mono' },
|
||||
{ label: 'Inconsolata', value: 'Inconsolata' },
|
||||
{ label: 'Red Hat Mono', value: 'Red Hat Mono' },
|
||||
{ label: 'DM Mono', value: 'DM Mono' },
|
||||
{ label: 'Azeret Mono', value: 'Azeret Mono' },
|
||||
{ label: 'Martian Mono', value: 'Martian Mono' },
|
||||
{ label: 'Share Tech Mono', value: 'Share Tech Mono' },
|
||||
{ label: 'Anonymous Pro', value: 'Anonymous Pro' },
|
||||
{ label: 'Victor Mono', value: 'Victor Mono' },
|
||||
{ label: 'Overpass Mono', value: 'Overpass Mono' },
|
||||
]
|
||||
|
||||
const loadedFonts = new Set<string>()
|
||||
|
||||
export function loadGoogleFont(fontName: string): void {
|
||||
if (loadedFonts.has(fontName)) return
|
||||
loadedFonts.add(fontName)
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;500;600&display=swap`
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
export function applyTimerFont(fontName: string): void {
|
||||
document.documentElement.style.setProperty(
|
||||
'--font-timer',
|
||||
`'${fontName}', monospace`
|
||||
)
|
||||
}
|
||||
|
||||
export function loadAndApplyTimerFont(fontName: string): void {
|
||||
loadGoogleFont(fontName)
|
||||
applyTimerFont(fontName)
|
||||
}
|
||||
33
src/utils/formGuard.ts
Normal file
33
src/utils/formGuard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useFormGuard() {
|
||||
const _snapshot = ref('')
|
||||
const showDiscardDialog = ref(false)
|
||||
let _pendingClose: (() => void) | null = null
|
||||
|
||||
function snapshot(data: unknown) {
|
||||
_snapshot.value = JSON.stringify(data)
|
||||
}
|
||||
|
||||
function tryClose(data: unknown, closeFn: () => void) {
|
||||
if (JSON.stringify(data) !== _snapshot.value) {
|
||||
_pendingClose = closeFn
|
||||
showDiscardDialog.value = true
|
||||
} else {
|
||||
closeFn()
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDiscard() {
|
||||
showDiscardDialog.value = false
|
||||
_pendingClose?.()
|
||||
_pendingClose = null
|
||||
}
|
||||
|
||||
function cancelDiscard() {
|
||||
showDiscardDialog.value = false
|
||||
_pendingClose = null
|
||||
}
|
||||
|
||||
return { showDiscardDialog, snapshot, tryClose, confirmDiscard, cancelDiscard }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
@@ -7,7 +8,8 @@ marked.setOptions({
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
return marked.parseInline(text) as string
|
||||
const raw = marked.parseInline(text) as string
|
||||
return DOMPurify.sanitize(raw, { ALLOWED_TAGS: ['strong', 'em', 'code', 'a', 'br'], ALLOWED_ATTR: ['href', 'target', 'rel'] })
|
||||
}
|
||||
|
||||
export function stripMarkdown(text: string): string {
|
||||
|
||||
481
src/utils/reportPdf.ts
Normal file
481
src/utils/reportPdf.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { formatCurrency, formatNumber } from './locale'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReportProjectRow {
|
||||
name: string
|
||||
color: string
|
||||
hours: number
|
||||
rate: number
|
||||
earnings: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export interface HoursReportData {
|
||||
dateRange: { start: string; end: string }
|
||||
totalHours: number
|
||||
totalEarnings: number
|
||||
billableHours: number
|
||||
nonBillableHours: number
|
||||
projects: ReportProjectRow[]
|
||||
}
|
||||
|
||||
export interface ProfitabilityReportRow {
|
||||
project_name: string
|
||||
client_name: string | null
|
||||
total_hours: number
|
||||
hourly_rate: number
|
||||
revenue: number
|
||||
expenses: number
|
||||
net_profit: number
|
||||
budget_hours: number | null
|
||||
budget_used_pct: number | null
|
||||
}
|
||||
|
||||
export interface ProfitabilityReportData {
|
||||
dateRange: { start: string; end: string }
|
||||
totalRevenue: number
|
||||
totalExpenses: number
|
||||
totalNet: number
|
||||
totalHours: number
|
||||
avgRate: number
|
||||
rows: ProfitabilityReportRow[]
|
||||
}
|
||||
|
||||
export interface ExpenseReportData {
|
||||
dateRange: { start: string; end: string }
|
||||
totalAmount: number
|
||||
invoicedAmount: number
|
||||
uninvoicedAmount: number
|
||||
byCategory: { category: string; amount: number; percentage: number }[]
|
||||
byProject: { name: string; color: string; amount: number; percentage: number }[]
|
||||
}
|
||||
|
||||
export interface PatternsReportData {
|
||||
dateRange: { start: string; end: string }
|
||||
heatmap: number[][]
|
||||
peakDay: string
|
||||
peakHour: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared PDF helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const h = hex.replace('#', '')
|
||||
return [
|
||||
parseInt(h.substring(0, 2), 16),
|
||||
parseInt(h.substring(2, 4), 16),
|
||||
parseInt(h.substring(4, 6), 16),
|
||||
]
|
||||
}
|
||||
|
||||
function setText(doc: jsPDF, hex: string) {
|
||||
const [r, g, b] = hexToRgb(hex)
|
||||
doc.setTextColor(r, g, b)
|
||||
}
|
||||
|
||||
function setFill(doc: jsPDF, hex: string) {
|
||||
const [r, g, b] = hexToRgb(hex)
|
||||
doc.setFillColor(r, g, b)
|
||||
}
|
||||
|
||||
const PAGE_W = 210
|
||||
const PAGE_H = 297
|
||||
const MARGIN = 15
|
||||
const CONTENT_W = PAGE_W - MARGIN * 2
|
||||
|
||||
function drawHeader(doc: jsPDF, title: string, dateRange: { start: string; end: string }): number {
|
||||
let y = MARGIN
|
||||
|
||||
doc.setFontSize(18)
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text(title, MARGIN, y + 6)
|
||||
y += 10
|
||||
|
||||
doc.setFontSize(9)
|
||||
setText(doc, '#9CA3AF')
|
||||
doc.text(`${dateRange.start} to ${dateRange.end}`, MARGIN, y + 4)
|
||||
y += 10
|
||||
|
||||
setFill(doc, '#374151')
|
||||
doc.rect(MARGIN, y, CONTENT_W, 0.3, 'F')
|
||||
y += 6
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
function drawStatBox(doc: jsPDF, x: number, y: number, w: number, label: string, value: string) {
|
||||
setFill(doc, '#1F2937')
|
||||
doc.roundedRect(x, y, w, 18, 2, 2, 'F')
|
||||
|
||||
doc.setFontSize(7)
|
||||
setText(doc, '#9CA3AF')
|
||||
doc.text(label.toUpperCase(), x + 4, y + 6)
|
||||
|
||||
doc.setFontSize(11)
|
||||
setText(doc, '#F9FAFB')
|
||||
doc.text(value, x + 4, y + 14)
|
||||
}
|
||||
|
||||
function drawTableHeader(doc: jsPDF, y: number, cols: { label: string; x: number; w: number; align?: string }[]): number {
|
||||
setFill(doc, '#1F2937')
|
||||
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
|
||||
|
||||
doc.setFontSize(6.5)
|
||||
setText(doc, '#9CA3AF')
|
||||
for (const col of cols) {
|
||||
if (col.align === 'right') {
|
||||
doc.text(col.label.toUpperCase(), col.x + col.w - 2, y + 5, { align: 'right' })
|
||||
} else {
|
||||
doc.text(col.label.toUpperCase(), col.x + 2, y + 5)
|
||||
}
|
||||
}
|
||||
return y + 7
|
||||
}
|
||||
|
||||
function checkPageBreak(doc: jsPDF, y: number, needed: number): number {
|
||||
if (y + needed > PAGE_H - MARGIN) {
|
||||
doc.addPage()
|
||||
setFill(doc, '#0D1117')
|
||||
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
|
||||
return MARGIN
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hours Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderHoursReport(doc: jsPDF, data: HoursReportData) {
|
||||
let y = drawHeader(doc, 'Hours Report', data.dateRange)
|
||||
|
||||
const boxW = (CONTENT_W - 6) / 4
|
||||
drawStatBox(doc, MARGIN, y, boxW, 'Total Hours', formatNumber(data.totalHours, 1) + 'h')
|
||||
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Earnings', formatCurrency(data.totalEarnings))
|
||||
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Billable', formatNumber(data.billableHours, 1) + 'h')
|
||||
drawStatBox(doc, MARGIN + (boxW + 2) * 3, y, boxW, 'Non-Billable', formatNumber(data.nonBillableHours, 1) + 'h')
|
||||
y += 24
|
||||
|
||||
// Bar chart
|
||||
if (data.projects.length > 0) {
|
||||
const barAreaH = Math.min(data.projects.length * 10, 80)
|
||||
y = checkPageBreak(doc, y, barAreaH + 10)
|
||||
|
||||
doc.setFontSize(9)
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text('Hours by Project', MARGIN, y + 4)
|
||||
y += 8
|
||||
|
||||
const maxHours = Math.max(...data.projects.map(p => p.hours))
|
||||
const barMaxW = CONTENT_W - 50
|
||||
for (const proj of data.projects) {
|
||||
y = checkPageBreak(doc, y, 10)
|
||||
const barW = maxHours > 0 ? (proj.hours / maxHours) * barMaxW : 0
|
||||
|
||||
doc.setFontSize(7)
|
||||
setText(doc, '#D1D5DB')
|
||||
doc.text(proj.name.substring(0, 25), MARGIN, y + 4)
|
||||
|
||||
setFill(doc, proj.color || '#F59E0B')
|
||||
doc.roundedRect(MARGIN + 48, y, Math.max(barW, 1), 5, 1, 1, 'F')
|
||||
|
||||
setText(doc, '#9CA3AF')
|
||||
doc.text(formatNumber(proj.hours, 1) + 'h', MARGIN + 50 + barW, y + 4)
|
||||
y += 8
|
||||
}
|
||||
y += 4
|
||||
}
|
||||
|
||||
// Project table
|
||||
y = checkPageBreak(doc, y, 20)
|
||||
doc.setFontSize(9)
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text('Project Breakdown', MARGIN, y + 4)
|
||||
y += 8
|
||||
|
||||
const cols = [
|
||||
{ label: 'Project', x: MARGIN, w: 60 },
|
||||
{ label: 'Hours', x: MARGIN + 60, w: 30, align: 'right' },
|
||||
{ label: 'Rate', x: MARGIN + 90, w: 35, align: 'right' },
|
||||
{ label: 'Earnings', x: MARGIN + 125, w: 35, align: 'right' },
|
||||
{ label: '%', x: MARGIN + 160, w: 20, align: 'right' },
|
||||
]
|
||||
y = drawTableHeader(doc, y, cols)
|
||||
|
||||
doc.setFontSize(7)
|
||||
for (let i = 0; i < data.projects.length; i++) {
|
||||
y = checkPageBreak(doc, y, 7)
|
||||
const p = data.projects[i]
|
||||
|
||||
if (i % 2 === 0) {
|
||||
setFill(doc, '#111827')
|
||||
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
|
||||
}
|
||||
|
||||
setFill(doc, p.color || '#F59E0B')
|
||||
doc.circle(MARGIN + 4, y + 3.5, 1.5, 'F')
|
||||
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text(p.name.substring(0, 30), MARGIN + 8, y + 5)
|
||||
setText(doc, '#D1D5DB')
|
||||
doc.text(formatNumber(p.hours, 1), MARGIN + 60 + 30 - 2, y + 5, { align: 'right' })
|
||||
doc.text(formatCurrency(p.rate), MARGIN + 90 + 35 - 2, y + 5, { align: 'right' })
|
||||
doc.text(formatCurrency(p.earnings), MARGIN + 125 + 35 - 2, y + 5, { align: 'right' })
|
||||
setText(doc, '#9CA3AF')
|
||||
doc.text(formatNumber(p.percentage, 0) + '%', MARGIN + 160 + 20 - 2, y + 5, { align: 'right' })
|
||||
y += 7
|
||||
}
|
||||
|
||||
// Totals row
|
||||
y = checkPageBreak(doc, y, 8)
|
||||
setFill(doc, '#1F2937')
|
||||
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
|
||||
doc.setFontSize(7)
|
||||
setText(doc, '#F9FAFB')
|
||||
doc.text('Total', MARGIN + 8, y + 5)
|
||||
doc.text(formatNumber(data.totalHours, 1), MARGIN + 60 + 30 - 2, y + 5, { align: 'right' })
|
||||
doc.text(formatCurrency(data.totalEarnings), MARGIN + 125 + 35 - 2, y + 5, { align: 'right' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profitability Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderProfitabilityReport(doc: jsPDF, data: ProfitabilityReportData) {
|
||||
let y = drawHeader(doc, 'Profitability Report', data.dateRange)
|
||||
|
||||
const boxW = (CONTENT_W - 8) / 5
|
||||
drawStatBox(doc, MARGIN, y, boxW, 'Revenue', formatCurrency(data.totalRevenue))
|
||||
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Expenses', formatCurrency(data.totalExpenses))
|
||||
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Net Profit', formatCurrency(data.totalNet))
|
||||
drawStatBox(doc, MARGIN + (boxW + 2) * 3, y, boxW, 'Hours', formatNumber(data.totalHours, 1) + 'h')
|
||||
drawStatBox(doc, MARGIN + (boxW + 2) * 4, y, boxW, 'Avg Rate', formatCurrency(data.avgRate) + '/hr')
|
||||
y += 24
|
||||
|
||||
const cols = [
|
||||
{ label: 'Project', x: MARGIN, w: 35 },
|
||||
{ label: 'Client', x: MARGIN + 35, w: 30 },
|
||||
{ label: 'Hours', x: MARGIN + 65, w: 18, align: 'right' },
|
||||
{ label: 'Rate', x: MARGIN + 83, w: 22, align: 'right' },
|
||||
{ label: 'Revenue', x: MARGIN + 105, w: 25, align: 'right' },
|
||||
{ label: 'Expenses', x: MARGIN + 130, w: 22, align: 'right' },
|
||||
{ label: 'Net', x: MARGIN + 152, w: 22, align: 'right' },
|
||||
{ label: 'Budget', x: MARGIN + 174, w: 16, align: 'right' },
|
||||
]
|
||||
y = drawTableHeader(doc, y, cols)
|
||||
|
||||
doc.setFontSize(6.5)
|
||||
for (let i = 0; i < data.rows.length; i++) {
|
||||
y = checkPageBreak(doc, y, 7)
|
||||
const r = data.rows[i]
|
||||
|
||||
if (i % 2 === 0) {
|
||||
setFill(doc, '#111827')
|
||||
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
|
||||
}
|
||||
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text(r.project_name.substring(0, 18), MARGIN + 2, y + 5)
|
||||
setText(doc, '#D1D5DB')
|
||||
doc.text((r.client_name || '-').substring(0, 16), MARGIN + 37, y + 5)
|
||||
doc.text(formatNumber(r.total_hours, 1), MARGIN + 65 + 18 - 2, y + 5, { align: 'right' })
|
||||
doc.text(formatCurrency(r.hourly_rate), MARGIN + 83 + 22 - 2, y + 5, { align: 'right' })
|
||||
doc.text(formatCurrency(r.revenue), MARGIN + 105 + 25 - 2, y + 5, { align: 'right' })
|
||||
setText(doc, r.expenses > 0 ? '#FCA5A5' : '#D1D5DB')
|
||||
doc.text(formatCurrency(r.expenses), MARGIN + 130 + 22 - 2, y + 5, { align: 'right' })
|
||||
setText(doc, r.net_profit >= 0 ? '#86EFAC' : '#FCA5A5')
|
||||
doc.text(formatCurrency(r.net_profit), MARGIN + 152 + 22 - 2, y + 5, { align: 'right' })
|
||||
setText(doc, '#9CA3AF')
|
||||
doc.text(r.budget_used_pct != null ? formatNumber(r.budget_used_pct, 0) + '%' : '-', MARGIN + 174 + 16 - 2, y + 5, { align: 'right' })
|
||||
y += 7
|
||||
}
|
||||
|
||||
// Totals row
|
||||
y = checkPageBreak(doc, y, 8)
|
||||
setFill(doc, '#1F2937')
|
||||
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
|
||||
doc.setFontSize(6.5)
|
||||
setText(doc, '#F9FAFB')
|
||||
doc.text('Total', MARGIN + 2, y + 5)
|
||||
doc.text(formatNumber(data.totalHours, 1), MARGIN + 65 + 18 - 2, y + 5, { align: 'right' })
|
||||
doc.text(formatCurrency(data.totalRevenue), MARGIN + 105 + 25 - 2, y + 5, { align: 'right' })
|
||||
doc.text(formatCurrency(data.totalExpenses), MARGIN + 130 + 22 - 2, y + 5, { align: 'right' })
|
||||
setText(doc, data.totalNet >= 0 ? '#86EFAC' : '#FCA5A5')
|
||||
doc.text(formatCurrency(data.totalNet), MARGIN + 152 + 22 - 2, y + 5, { align: 'right' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expenses Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderExpensesReport(doc: jsPDF, data: ExpenseReportData) {
|
||||
let y = drawHeader(doc, 'Expenses Report', data.dateRange)
|
||||
|
||||
const boxW = (CONTENT_W - 4) / 3
|
||||
drawStatBox(doc, MARGIN, y, boxW, 'Total', formatCurrency(data.totalAmount))
|
||||
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Invoiced', formatCurrency(data.invoicedAmount))
|
||||
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Uninvoiced', formatCurrency(data.uninvoicedAmount))
|
||||
y += 24
|
||||
|
||||
// By category
|
||||
doc.setFontSize(9)
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text('By Category', MARGIN, y + 4)
|
||||
y += 8
|
||||
|
||||
const catCols = [
|
||||
{ label: 'Category', x: MARGIN, w: 80 },
|
||||
{ label: 'Amount', x: MARGIN + 80, w: 50, align: 'right' },
|
||||
{ label: '%', x: MARGIN + 130, w: 30, align: 'right' },
|
||||
]
|
||||
y = drawTableHeader(doc, y, catCols)
|
||||
|
||||
doc.setFontSize(7)
|
||||
for (let i = 0; i < data.byCategory.length; i++) {
|
||||
y = checkPageBreak(doc, y, 7)
|
||||
const c = data.byCategory[i]
|
||||
if (i % 2 === 0) {
|
||||
setFill(doc, '#111827')
|
||||
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
|
||||
}
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text(c.category, MARGIN + 2, y + 5)
|
||||
setText(doc, '#D1D5DB')
|
||||
doc.text(formatCurrency(c.amount), MARGIN + 80 + 50 - 2, y + 5, { align: 'right' })
|
||||
setText(doc, '#9CA3AF')
|
||||
doc.text(formatNumber(c.percentage, 0) + '%', MARGIN + 130 + 30 - 2, y + 5, { align: 'right' })
|
||||
y += 7
|
||||
}
|
||||
y += 8
|
||||
|
||||
// By project
|
||||
y = checkPageBreak(doc, y, 20)
|
||||
doc.setFontSize(9)
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text('By Project', MARGIN, y + 4)
|
||||
y += 8
|
||||
|
||||
const projCols = [
|
||||
{ label: 'Project', x: MARGIN, w: 80 },
|
||||
{ label: 'Amount', x: MARGIN + 80, w: 50, align: 'right' },
|
||||
{ label: '%', x: MARGIN + 130, w: 30, align: 'right' },
|
||||
]
|
||||
y = drawTableHeader(doc, y, projCols)
|
||||
|
||||
doc.setFontSize(7)
|
||||
for (let i = 0; i < data.byProject.length; i++) {
|
||||
y = checkPageBreak(doc, y, 7)
|
||||
const p = data.byProject[i]
|
||||
if (i % 2 === 0) {
|
||||
setFill(doc, '#111827')
|
||||
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
|
||||
}
|
||||
setFill(doc, p.color || '#F59E0B')
|
||||
doc.circle(MARGIN + 4, y + 3.5, 1.5, 'F')
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text(p.name.substring(0, 30), MARGIN + 8, y + 5)
|
||||
setText(doc, '#D1D5DB')
|
||||
doc.text(formatCurrency(p.amount), MARGIN + 80 + 50 - 2, y + 5, { align: 'right' })
|
||||
setText(doc, '#9CA3AF')
|
||||
doc.text(formatNumber(p.percentage, 0) + '%', MARGIN + 130 + 30 - 2, y + 5, { align: 'right' })
|
||||
y += 7
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Patterns Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderPatternsReport(doc: jsPDF, data: PatternsReportData) {
|
||||
let y = drawHeader(doc, 'Work Patterns Report', data.dateRange)
|
||||
|
||||
const boxW = (CONTENT_W - 2) / 2
|
||||
drawStatBox(doc, MARGIN, y, boxW, 'Peak Day', data.peakDay)
|
||||
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Peak Hour', data.peakHour)
|
||||
y += 24
|
||||
|
||||
doc.setFontSize(9)
|
||||
setText(doc, '#E5E7EB')
|
||||
doc.text('Activity Heatmap', MARGIN, y + 4)
|
||||
y += 8
|
||||
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const labelW = 12
|
||||
const cellW = (CONTENT_W - labelW) / 24
|
||||
const cellH = 8
|
||||
|
||||
// Hour labels
|
||||
doc.setFontSize(5)
|
||||
setText(doc, '#6B7280')
|
||||
for (let h = 0; h < 24; h++) {
|
||||
if (h % 3 === 0) {
|
||||
doc.text(String(h).padStart(2, '0'), MARGIN + labelW + h * cellW + cellW / 2, y, { align: 'center' })
|
||||
}
|
||||
}
|
||||
y += 4
|
||||
|
||||
const maxVal = Math.max(...data.heatmap.flat(), 0.01)
|
||||
|
||||
for (let d = 0; d < 7; d++) {
|
||||
setText(doc, '#9CA3AF')
|
||||
doc.setFontSize(6)
|
||||
doc.text(days[d], MARGIN, y + cellH / 2 + 1.5)
|
||||
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const val = data.heatmap[d]?.[h] || 0
|
||||
const intensity = Math.min(val / maxVal, 1)
|
||||
if (intensity > 0) {
|
||||
const r = Math.round(245 * intensity + 31 * (1 - intensity))
|
||||
const g = Math.round(158 * intensity + 41 * (1 - intensity))
|
||||
const b = Math.round(11 * intensity + 55 * (1 - intensity))
|
||||
doc.setFillColor(r, g, b)
|
||||
} else {
|
||||
setFill(doc, '#1F2937')
|
||||
}
|
||||
doc.roundedRect(MARGIN + labelW + h * cellW + 0.5, y + 0.5, cellW - 1, cellH - 1, 0.5, 0.5, 'F')
|
||||
}
|
||||
y += cellH
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function generateHoursReportPdf(data: HoursReportData): jsPDF {
|
||||
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
|
||||
setFill(doc, '#0D1117')
|
||||
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
|
||||
renderHoursReport(doc, data)
|
||||
return doc
|
||||
}
|
||||
|
||||
export function generateProfitabilityReportPdf(data: ProfitabilityReportData): jsPDF {
|
||||
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
|
||||
setFill(doc, '#0D1117')
|
||||
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
|
||||
renderProfitabilityReport(doc, data)
|
||||
return doc
|
||||
}
|
||||
|
||||
export function generateExpensesReportPdf(data: ExpenseReportData): jsPDF {
|
||||
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
|
||||
setFill(doc, '#0D1117')
|
||||
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
|
||||
renderExpensesReport(doc, data)
|
||||
return doc
|
||||
}
|
||||
|
||||
export function generatePatternsReportPdf(data: PatternsReportData): jsPDF {
|
||||
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
|
||||
setFill(doc, '#0D1117')
|
||||
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
|
||||
renderPatternsReport(doc, data)
|
||||
return doc
|
||||
}
|
||||
100
src/utils/tours.ts
Normal file
100
src/utils/tours.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { TourDefinition } from '../stores/tour'
|
||||
|
||||
export const TOURS: Record<string, TourDefinition> = {
|
||||
clients: {
|
||||
id: 'clients',
|
||||
route: '/clients',
|
||||
steps: [
|
||||
{
|
||||
target: '[data-tour-id="new-client"]',
|
||||
title: 'Add a client',
|
||||
content: 'Click here to create your first client. Add their name, email, company, and payment terms.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
],
|
||||
},
|
||||
projects: {
|
||||
id: 'projects',
|
||||
route: '/projects',
|
||||
steps: [
|
||||
{
|
||||
target: '[data-tour-id="new-project"]',
|
||||
title: 'Create a project',
|
||||
content: 'Projects organize your time entries. Set an hourly rate, pick a color, and assign a client.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
],
|
||||
},
|
||||
timer: {
|
||||
id: 'timer',
|
||||
route: '/timer',
|
||||
steps: [
|
||||
{
|
||||
target: '[data-tour-id="timer-project"]',
|
||||
title: 'Pick a project',
|
||||
content: 'Select which project you are working on. This links your time to the right place.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour-id="timer-start"]',
|
||||
title: 'Start tracking',
|
||||
content: 'Hit this button to start the clock. Press it again to stop and save your entry.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour-id="timer-description"]',
|
||||
title: 'Describe your work',
|
||||
content: 'Add a note about what you are working on. This shows up in entries and reports.',
|
||||
placement: 'top',
|
||||
},
|
||||
],
|
||||
},
|
||||
entries: {
|
||||
id: 'entries',
|
||||
route: '/entries',
|
||||
steps: [
|
||||
{
|
||||
target: '[data-tour-id="entries-tabs"]',
|
||||
title: 'Browse your entries',
|
||||
content: 'Switch between list and timeline views to see your tracked time different ways.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
],
|
||||
},
|
||||
invoices: {
|
||||
id: 'invoices',
|
||||
route: '/invoices',
|
||||
steps: [
|
||||
{
|
||||
target: '[data-tour-id="new-invoice"]',
|
||||
title: 'Create an invoice',
|
||||
content: 'Generate invoices from your tracked time. Pick a client, date range, and template.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
],
|
||||
},
|
||||
reports: {
|
||||
id: 'reports',
|
||||
route: '/reports',
|
||||
steps: [
|
||||
{
|
||||
target: '[data-tour-id="reports-daterange"]',
|
||||
title: 'Set your date range',
|
||||
content: 'Pick a start and end date for your report. The default is the current month.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour-id="reports-generate"]',
|
||||
title: 'Generate the report',
|
||||
content: 'Click Generate to see hours, earnings, and project breakdowns for the selected period.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour-id="reports-tabs"]',
|
||||
title: 'Explore report types',
|
||||
content: 'Switch between Hours, Profitability, and Expenses views for different insights.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
44
src/utils/uiFonts.ts
Normal file
44
src/utils/uiFonts.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface UIFont {
|
||||
label: string
|
||||
value: string
|
||||
category: 'default' | 'accessibility' | 'general'
|
||||
}
|
||||
|
||||
export const UI_FONTS: UIFont[] = [
|
||||
{ label: 'Inter (Default)', value: 'Inter', category: 'default' },
|
||||
{ label: 'OpenDyslexic', value: 'OpenDyslexic', category: 'accessibility' },
|
||||
{ label: 'Atkinson Hyperlegible', value: 'Atkinson Hyperlegible', category: 'accessibility' },
|
||||
{ label: 'Lexie Readable', value: 'Lexie Readable', category: 'accessibility' },
|
||||
{ label: 'Comic Neue', value: 'Comic Neue', category: 'general' },
|
||||
{ label: 'Nunito', value: 'Nunito', category: 'general' },
|
||||
{ label: 'Lato', value: 'Lato', category: 'general' },
|
||||
{ label: 'Source Sans 3', value: 'Source Sans 3', category: 'general' },
|
||||
]
|
||||
|
||||
const loadedUIFonts = new Set<string>()
|
||||
|
||||
export function loadUIFont(fontName: string): void {
|
||||
if (fontName === 'Inter' || loadedUIFonts.has(fontName)) return
|
||||
loadedUIFonts.add(fontName)
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;500;600;700&display=swap`
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
export function applyUIFont(fontName: string): void {
|
||||
const el = document.documentElement
|
||||
if (fontName === 'Inter') {
|
||||
el.style.removeProperty('--font-ui')
|
||||
document.body.style.fontFamily = ''
|
||||
} else {
|
||||
const fontValue = `'${fontName}', system-ui, sans-serif`
|
||||
el.style.setProperty('--font-ui', fontValue)
|
||||
document.body.style.fontFamily = `var(--font-ui)`
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAndApplyUIFont(fontName: string): void {
|
||||
loadUIFont(fontName)
|
||||
applyUIFont(fontName)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,11 +32,12 @@
|
||||
<p v-if="client.company" class="text-xs text-text-secondary mt-0.5 truncate">{{ client.company }}</p>
|
||||
<p v-if="client.email" class="text-xs text-text-tertiary mt-0.5 truncate">{{ client.email }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100 shrink-0 ml-2">
|
||||
<div class="flex items-center gap-1 shrink-0 ml-2">
|
||||
<button
|
||||
@click.stop="openEditDialog(client)"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
aria-label="Edit"
|
||||
v-tooltip="'Edit'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
@@ -46,6 +47,7 @@
|
||||
@click.stop="confirmDelete(client)"
|
||||
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
|
||||
aria-label="Delete"
|
||||
v-tooltip="'Delete'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@@ -173,6 +175,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Currency</label>
|
||||
<AppSelect
|
||||
v-model="formData.currency"
|
||||
:options="currencyOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="Default (global setting)"
|
||||
:placeholder-value="undefined"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client-notes" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
|
||||
<textarea
|
||||
@@ -221,23 +235,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Users } from 'lucide-vue-next'
|
||||
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
||||
import AppCascadeDeleteDialog from '../components/AppCascadeDeleteDialog.vue'
|
||||
import AppSelect from '../components/AppSelect.vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { handleInvokeError } from '../utils/errorHandler'
|
||||
import { useClientsStore, type Client } from '../stores/clients'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { useFormGuard } from '../utils/formGuard'
|
||||
import { getCurrencies } from '../utils/locale'
|
||||
|
||||
const clientsStore = useClientsStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const { showDiscardDialog, snapshot: snapshotForm, tryClose: tryCloseForm, confirmDiscard, cancelDiscard } = useFormGuard()
|
||||
|
||||
const currencyOptions = computed(() =>
|
||||
getCurrencies().map(c => ({ label: `${c.code} - ${c.name}`, value: c.code }))
|
||||
)
|
||||
|
||||
function getFormData() {
|
||||
return { name: formData.name, email: formData.email, phone: formData.phone, address: formData.address, company: formData.company, tax_id: formData.tax_id, payment_terms: formData.payment_terms, notes: formData.notes }
|
||||
return { name: formData.name, email: formData.email, phone: formData.phone, address: formData.address, company: formData.company, tax_id: formData.tax_id, payment_terms: formData.payment_terms, notes: formData.notes, currency: formData.currency }
|
||||
}
|
||||
|
||||
function tryCloseDialog() {
|
||||
@@ -261,6 +281,7 @@ const formData = reactive<Client>({
|
||||
tax_id: undefined,
|
||||
payment_terms: undefined,
|
||||
notes: undefined,
|
||||
currency: undefined,
|
||||
})
|
||||
|
||||
// Open create dialog
|
||||
@@ -274,6 +295,7 @@ function openCreateDialog() {
|
||||
formData.tax_id = undefined
|
||||
formData.payment_terms = undefined
|
||||
formData.notes = undefined
|
||||
formData.currency = undefined
|
||||
snapshotForm(getFormData())
|
||||
showDialog.value = true
|
||||
}
|
||||
@@ -290,6 +312,7 @@ function openEditDialog(client: Client) {
|
||||
formData.tax_id = client.tax_id
|
||||
formData.payment_terms = client.payment_terms
|
||||
formData.notes = client.notes
|
||||
formData.currency = client.currency
|
||||
snapshotForm(getFormData())
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
@@ -4,17 +4,23 @@
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="space-y-6" aria-busy="true" aria-label="Loading dashboard data">
|
||||
<div class="h-6 w-48 bg-bg-elevated rounded animate-pulse" />
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div v-for="i in 4" :key="i" class="bg-bg-surface rounded-lg p-4">
|
||||
<div v-for="i in 4" :key="i" class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<div class="h-3 w-16 bg-bg-elevated rounded animate-pulse mb-2" />
|
||||
<div class="h-6 w-24 bg-bg-elevated rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-bg-surface rounded-lg p-4">
|
||||
<div class="h-48 bg-bg-elevated rounded animate-pulse" />
|
||||
</div>
|
||||
<div class="bg-bg-surface rounded-lg p-4 space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="h-10 bg-bg-elevated rounded animate-pulse" />
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4"><div class="h-20 bg-bg-elevated rounded animate-pulse" /></div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4"><div class="h-48 bg-bg-elevated rounded animate-pulse" /></div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4 space-y-2">
|
||||
<div v-for="i in 5" :key="i" class="h-8 bg-bg-elevated rounded animate-pulse" />
|
||||
</div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4"><div class="h-20 bg-bg-elevated rounded animate-pulse" /></div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4 space-y-2">
|
||||
<div v-for="i in 3" :key="i" class="h-12 bg-bg-elevated rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,19 +41,62 @@
|
||||
<!-- Main content -->
|
||||
<Transition name="fade" appear>
|
||||
<div v-if="!loading && !isEmpty">
|
||||
<!-- Running timer indicator -->
|
||||
<div
|
||||
v-if="timerStore.timerState === 'RUNNING'"
|
||||
class="flex items-center gap-3 px-4 py-2.5 mb-4 bg-status-running/10 border border-status-running/20 rounded-lg"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-running opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-status-running" />
|
||||
</span>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="text-[0.8125rem] font-medium text-text-primary">Now tracking</span>
|
||||
<div
|
||||
v-if="timerProjectColor"
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: timerProjectColor }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-[0.75rem] text-text-secondary truncate">{{ timerProjectName }}</span>
|
||||
<span v-if="timerStore.description" class="text-[0.6875rem] text-text-tertiary truncate">- {{ timerStore.description }}</span>
|
||||
</div>
|
||||
<span class="text-[0.8125rem] font-mono text-status-running shrink-0">{{ formatDuration(timerStore.elapsedSeconds) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Greeting header -->
|
||||
<div class="mb-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-[family-name:var(--font-heading)] text-text-secondary">{{ greeting }}</h1>
|
||||
<p class="text-xs text-text-tertiary mt-1">{{ formattedDate }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats row - 4 columns -->
|
||||
<dl class="grid grid-cols-4 gap-6 mb-8">
|
||||
<div>
|
||||
<!-- Stats row - 4 card columns -->
|
||||
<dl class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(todayStats.totalSeconds) }}</dd>
|
||||
<!-- Today's breakdown bar -->
|
||||
<div
|
||||
v-if="todayBreakdown.length > 0"
|
||||
class="flex w-full h-2 rounded-full overflow-hidden mt-2 gap-px"
|
||||
role="img"
|
||||
:aria-label="todayBreakdownLabel"
|
||||
>
|
||||
<div
|
||||
v-for="seg in todayBreakdown"
|
||||
:key="seg.projectId"
|
||||
class="h-full rounded-sm first:rounded-l-full last:rounded-r-full transition-all duration-300 relative group"
|
||||
:style="{ width: seg.percent + '%', backgroundColor: seg.color, minWidth: seg.percent > 0 ? '3px' : '0' }"
|
||||
>
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-bg-elevated border border-border-subtle rounded text-[0.625rem] text-text-primary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-10 transition-opacity">
|
||||
{{ seg.name }} - {{ formatNumber(seg.hours, 1) }}h
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Week</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(weekStats.totalSeconds) }}</dd>
|
||||
<div v-if="lastWeekSeconds > 0" class="flex items-center gap-1 mt-1">
|
||||
@@ -56,27 +105,27 @@
|
||||
<ChevronDown v-else-if="weekDiff < 0" class="w-3 h-3 text-status-error" aria-hidden="true" />
|
||||
<Minus v-else class="w-3 h-3 text-text-tertiary" aria-hidden="true" />
|
||||
<span class="text-[0.625rem]"
|
||||
:class="weekDiff >= 0 ? 'text-status-running' : 'text-status-error'">
|
||||
:class="weekDiff >= 0 ? 'text-status-running' : 'text-status-error-text'">
|
||||
{{ formatDuration(Math.abs(weekDiff)) }} {{ weekDiff >= 0 ? 'more' : 'less' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Month</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(monthStats.totalSeconds) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Active Projects</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ activeProjectsCount }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Goal Progress -->
|
||||
<div v-if="goalProgress" class="mt-6">
|
||||
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Goals</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div v-if="goalProgress" class="bg-bg-surface border border-border-subtle rounded-lg p-4 mb-6">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Goals</h2>
|
||||
<div class="grid grid-cols-[1fr_1fr_auto] gap-4 items-center">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-[0.75rem] text-text-secondary">Today</span>
|
||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.today_seconds) }}</span>
|
||||
</div>
|
||||
@@ -88,7 +137,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-[0.75rem] text-text-secondary">This Week</span>
|
||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.week_seconds) }}</span>
|
||||
</div>
|
||||
@@ -99,123 +148,138 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<span class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ goalProgress.streak_days }}</span>
|
||||
<span class="text-[0.6875rem] text-text-tertiary">day streak</span>
|
||||
<div class="flex flex-col items-center px-3">
|
||||
<span class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium leading-none">{{ goalProgress.streak_days }}</span>
|
||||
<span class="text-[0.6875rem] text-text-tertiary mt-0.5">day streak</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly chart -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Weekly Hours</h2>
|
||||
<div class="h-48" aria-label="Weekly hours bar chart">
|
||||
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4-week sparkline -->
|
||||
<div v-if="weeklySparkline.length > 0" class="mb-8 bg-bg-surface rounded-lg p-4 border border-border-subtle">
|
||||
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Last 4 Weeks</h2>
|
||||
<div class="flex items-end gap-3 h-16" role="img" :aria-label="sparklineLabel">
|
||||
<div
|
||||
v-for="(week, i) in weeklySparkline"
|
||||
:key="i"
|
||||
class="flex-1 flex flex-col items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-sm transition-all duration-300"
|
||||
:class="i === weeklySparkline.length - 1 ? 'bg-accent' : 'bg-accent-muted'"
|
||||
:style="{ height: `${week.percent}%`, minHeight: week.hours > 0 ? '4px' : '0px' }"
|
||||
/>
|
||||
<span class="text-[0.5625rem] text-text-tertiary">{{ week.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent entries -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">Recent Entries</h2>
|
||||
<router-link to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors" aria-label="View all time entries">View all</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="recentEntries.length > 0">
|
||||
<div
|
||||
v-for="entry in recentEntries"
|
||||
:key="entry.id"
|
||||
class="flex items-center justify-between py-3 border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</span>
|
||||
</div>
|
||||
<span class="text-[0.75rem] font-mono text-text-secondary">{{ formatDuration(entry.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-[0.75rem] text-text-tertiary py-8">
|
||||
No entries yet. Start tracking your time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Budget Alerts -->
|
||||
<div v-if="budgetAlerts.length > 0" class="mt-6">
|
||||
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Budget Alerts</h2>
|
||||
<div class="space-y-2">
|
||||
<!-- Budget Alerts (full width, high visibility) -->
|
||||
<div v-if="budgetAlerts.length > 0" class="bg-bg-surface border border-border-subtle rounded-lg p-4 mb-6">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Budget Alerts</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="alert in budgetAlerts"
|
||||
:key="alert.id"
|
||||
class="flex items-center justify-between py-2 px-3 rounded-lg"
|
||||
:class="alert.pct > 90 ? 'bg-status-error/10' : 'bg-status-warning/10'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: alert.color }" aria-hidden="true" />
|
||||
<span class="text-[0.75rem] text-text-primary">{{ alert.name }}</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: alert.color }" aria-hidden="true" />
|
||||
<span class="text-[0.75rem] text-text-primary truncate">{{ alert.name }}</span>
|
||||
</div>
|
||||
<span class="text-[0.75rem] font-mono" :class="alert.pct > 90 ? 'text-status-error' : 'text-status-warning'">
|
||||
{{ alert.pct.toFixed(0) }}%
|
||||
<span class="text-[0.75rem] font-mono shrink-0 ml-2" :class="alert.pct > 90 ? 'text-status-error-text' : 'text-status-warning'">
|
||||
{{ formatNumber(alert.pct, 0) }}%
|
||||
</span>
|
||||
<span class="sr-only">{{ alert.pct > 90 ? 'Over budget' : 'Approaching budget limit' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Forecasts -->
|
||||
<div v-if="projectForecasts.length > 0" class="mt-6">
|
||||
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Project Forecasts</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="forecast in projectForecasts"
|
||||
:key="forecast.id"
|
||||
class="px-3 py-2.5 bg-bg-surface border border-border-subtle rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: forecast.color }" aria-hidden="true" />
|
||||
<span class="text-[0.8125rem] text-text-primary">{{ forecast.name }}</span>
|
||||
<!-- 2x2 grid: charts + lists -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
|
||||
<!-- Weekly chart -->
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Weekly Hours</h2>
|
||||
<div class="h-44" aria-label="Weekly hours bar chart">
|
||||
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent entries -->
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary">Recent Entries</h2>
|
||||
<router-link to="/entries" class="text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors" aria-label="View all time entries">View all</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="recentEntries.length > 0" class="space-y-0">
|
||||
<div
|
||||
v-for="entry in recentEntries"
|
||||
:key="entry.id"
|
||||
class="group flex items-center justify-between py-2 border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-[0.75rem] text-text-primary truncate">{{ getProjectName(entry.project_id) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<button
|
||||
@click="replayEntry(entry)"
|
||||
class="p-1 text-text-tertiary hover:text-status-running transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Start timer with this entry"
|
||||
v-tooltip="'Replay entry'"
|
||||
>
|
||||
<Play class="h-3 w-3" :stroke-width="2" fill="currentColor" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="text-[0.75rem] font-mono text-text-secondary">{{ formatDuration(entry.duration) }}</span>
|
||||
</div>
|
||||
<span class="text-[0.6875rem] font-mono" :class="paceColor(forecast.pace)">
|
||||
{{ forecast.paceLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-[0.625rem] text-text-tertiary">{{ forecast.hoursUsed.toFixed(0) }}h / {{ forecast.budgetHours }}h</span>
|
||||
<span class="text-[0.625rem] text-text-tertiary">{{ forecast.dailyAvg.toFixed(1) }}h/day</span>
|
||||
</div>
|
||||
<div class="w-full bg-bg-elevated rounded-full h-1" role="progressbar" :aria-valuenow="Math.round(forecast.pct)" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
|
||||
<p v-else class="text-[0.75rem] text-text-tertiary py-6 text-center">
|
||||
No entries yet. Start tracking your time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 4-week sparkline -->
|
||||
<div v-if="weeklySparkline.length > 0" class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Last 4 Weeks</h2>
|
||||
<div class="flex items-end gap-3 h-16" role="img" :aria-label="sparklineLabel">
|
||||
<div
|
||||
v-for="(week, i) in weeklySparkline"
|
||||
:key="i"
|
||||
class="flex-1 flex flex-col items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="h-1 rounded-full progress-bar"
|
||||
:class="forecast.pct > 90 ? 'bg-status-error' : forecast.pct > 75 ? 'bg-status-warning' : 'bg-accent'"
|
||||
:style="{ width: Math.min(forecast.pct, 100) + '%' }"
|
||||
class="w-full rounded-sm transition-all duration-300"
|
||||
:class="i === weeklySparkline.length - 1 ? 'bg-accent' : 'bg-accent-muted'"
|
||||
:style="{ height: `${week.percent}%`, minHeight: week.hours > 0 ? '4px' : '0px' }"
|
||||
/>
|
||||
<span class="text-[0.5625rem] text-text-tertiary">{{ week.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Forecasts -->
|
||||
<div v-if="projectForecasts.length > 0" class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Project Forecasts</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="forecast in projectForecasts"
|
||||
:key="forecast.id"
|
||||
class="px-3 py-2 bg-bg-base rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: forecast.color }" aria-hidden="true" />
|
||||
<span class="text-[0.75rem] text-text-primary truncate">{{ forecast.name }}</span>
|
||||
</div>
|
||||
<span class="text-[0.6875rem] font-mono shrink-0 ml-2" :class="paceColor(forecast.pace)">
|
||||
{{ forecast.paceLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-[0.625rem] text-text-tertiary">{{ formatNumber(forecast.hoursUsed, 0) }}h / {{ forecast.budgetHours }}h</span>
|
||||
<span class="text-[0.625rem] text-text-tertiary">{{ formatNumber(forecast.dailyAvg, 1) }}h/day</span>
|
||||
</div>
|
||||
<div class="w-full bg-bg-elevated rounded-full h-1" role="progressbar" :aria-valuenow="Math.round(forecast.pct)" aria-valuemin="0" aria-valuemax="100" :aria-label="forecast.name + ' budget: ' + Math.round(forecast.pct) + '%'">
|
||||
<div
|
||||
class="h-1 rounded-full progress-bar"
|
||||
:class="forecast.pct > 90 ? 'bg-status-error' : forecast.pct > 75 ? 'bg-status-warning' : 'bg-accent'"
|
||||
:style="{ width: Math.min(forecast.pct, 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -235,13 +299,16 @@ import {
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js'
|
||||
import { Clock, ChevronUp, ChevronDown, Minus } from 'lucide-vue-next'
|
||||
import { Clock, ChevronUp, ChevronDown, Minus, Play } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import GettingStartedChecklist from '../components/GettingStartedChecklist.vue'
|
||||
import { useOnboardingStore } from '../stores/onboarding'
|
||||
import { useEntriesStore } from '../stores/entries'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { formatDateLong } from '../utils/locale'
|
||||
import { useTimerStore } from '../stores/timer'
|
||||
import { formatDateLong, formatNumber } from '../utils/locale'
|
||||
import { getChartTheme, buildBarChartOptions } from '../utils/chartTheme'
|
||||
import type { TimeEntry } from '../stores/entries'
|
||||
|
||||
@@ -252,6 +319,9 @@ const entriesStore = useEntriesStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const timerStore = useTimerStore()
|
||||
const router = useRouter()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
@@ -288,7 +358,7 @@ const weekDiff = ref(0)
|
||||
// 4-week sparkline data
|
||||
const weeklySparkline = ref<Array<{ hours: number; percent: number; label: string }>>([])
|
||||
const sparklineLabel = computed(() =>
|
||||
weeklySparkline.value.map(w => `${w.label}: ${w.hours.toFixed(1)} hours`).join(', ')
|
||||
weeklySparkline.value.map(w => `${w.label}: ${formatNumber(w.hours, 1)} hours`).join(', ')
|
||||
)
|
||||
|
||||
// Greeting based on time of day
|
||||
@@ -344,6 +414,19 @@ function formatDuration(seconds: number): string {
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
function replayEntry(entry: TimeEntry) {
|
||||
if (!timerStore.isStopped) {
|
||||
toastStore.info('Timer is already running. Stop it first.')
|
||||
return
|
||||
}
|
||||
timerStore.setProject(entry.project_id)
|
||||
timerStore.setTask(entry.task_id || null)
|
||||
timerStore.setDescription(entry.description || '')
|
||||
timerStore.setBillable(entry.billable ?? 1)
|
||||
timerStore.start()
|
||||
router.push('/timer')
|
||||
}
|
||||
|
||||
// Get project name by ID
|
||||
function getProjectName(projectId: number): string {
|
||||
const project = projectsStore.projects.find(p => p.id === projectId)
|
||||
@@ -361,6 +444,48 @@ const activeProjectsCount = computed(() => {
|
||||
return projectsStore.projects.filter(p => !p.archived).length
|
||||
})
|
||||
|
||||
// Running timer helpers
|
||||
const timerProjectName = computed(() => {
|
||||
if (!timerStore.selectedProjectId) return ''
|
||||
return getProjectName(timerStore.selectedProjectId)
|
||||
})
|
||||
const timerProjectColor = computed(() => {
|
||||
if (!timerStore.selectedProjectId) return ''
|
||||
return getProjectColor(timerStore.selectedProjectId)
|
||||
})
|
||||
|
||||
// Today's breakdown - stacked bar showing time by project
|
||||
interface BreakdownSegment {
|
||||
projectId: number
|
||||
name: string
|
||||
color: string
|
||||
seconds: number
|
||||
hours: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
const todayBreakdown = computed<BreakdownSegment[]>(() => {
|
||||
const byProject = todayStats.value.byProject as Array<{ project_id: number; total_seconds: number }> | undefined
|
||||
if (!byProject || byProject.length === 0) return []
|
||||
const total = todayStats.value.totalSeconds
|
||||
if (total <= 0) return []
|
||||
return byProject.map(bp => {
|
||||
const p = projectsStore.projects.find(proj => proj.id === bp.project_id)
|
||||
return {
|
||||
projectId: bp.project_id,
|
||||
name: p?.name || 'Unknown',
|
||||
color: p?.color || '#6B7280',
|
||||
seconds: bp.total_seconds,
|
||||
hours: bp.total_seconds / 3600,
|
||||
percent: (bp.total_seconds / total) * 100,
|
||||
}
|
||||
}).filter(s => s.percent > 0)
|
||||
})
|
||||
|
||||
const todayBreakdownLabel = computed(() =>
|
||||
todayBreakdown.value.map(s => `${s.name}: ${formatNumber(s.hours, 1)} hours`).join(', ')
|
||||
)
|
||||
|
||||
// Budget status
|
||||
interface DashboardBudgetStatus {
|
||||
hours_used: number
|
||||
@@ -441,7 +566,7 @@ const projectForecasts = computed<ForecastData[]>(() => {
|
||||
function paceColor(pace: string | null): string {
|
||||
if (pace === 'ahead') return 'text-status-running'
|
||||
if (pace === 'on_track') return 'text-accent-text'
|
||||
if (pace === 'behind') return 'text-status-error'
|
||||
if (pace === 'behind') return 'text-status-error-text'
|
||||
if (pace === 'complete') return 'text-status-running'
|
||||
return 'text-text-tertiary'
|
||||
}
|
||||
@@ -510,7 +635,7 @@ const chartOptions = computed(() => {
|
||||
callbacks: {
|
||||
label: (context: { raw: unknown }) => {
|
||||
const hours = context.raw as number
|
||||
return `${hours.toFixed(1)} hours`
|
||||
return `${formatNumber(hours, 1)} hours`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Entries</h1>
|
||||
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-4">Entries</h1>
|
||||
|
||||
<!-- Running timer indicator -->
|
||||
<div
|
||||
v-if="timerStore.timerState === 'RUNNING'"
|
||||
class="flex items-center gap-3 px-4 py-2 mb-4 bg-status-running/10 border border-status-running/20 rounded-lg"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-running opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-status-running" />
|
||||
</span>
|
||||
<span class="text-[0.75rem] font-medium text-text-primary">Currently tracking</span>
|
||||
<div
|
||||
v-if="timerStore.selectedProjectId"
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.color || '#6B7280' }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-[0.75rem] text-text-secondary truncate">{{ projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.name || '' }}</span>
|
||||
<span class="text-[0.75rem] font-mono text-status-running ml-auto shrink-0">{{ formatRunningDuration(timerStore.elapsedSeconds) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tab Toggle: Time | Expenses -->
|
||||
<div data-tour-id="entries-tabs" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Entry type" @keydown="onMainTabKeydown">
|
||||
@@ -14,7 +36,10 @@
|
||||
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
||||
:class="activeTab === 'time' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||
>
|
||||
Time
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Clock class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||
Time
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
id="tab-expenses"
|
||||
@@ -69,36 +94,78 @@
|
||||
:placeholder-value="null"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="applyFilters"
|
||||
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
@click="clearFilters"
|
||||
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="copyPreviousDay"
|
||||
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
@click="applyFilters"
|
||||
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
|
||||
>
|
||||
Copy Yesterday
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
@click="copyPreviousWeek"
|
||||
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
@click="clearFilters"
|
||||
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
|
||||
>
|
||||
Copy Last Week
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Add entries dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click.stop="showActionsMenu = !showActionsMenu"
|
||||
class="inline-flex items-center gap-1.5 text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="showActionsMenu"
|
||||
>
|
||||
<Plus class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
|
||||
Add
|
||||
<ChevronDown class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="showActionsMenu"
|
||||
class="absolute right-0 top-full mt-1 w-44 bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden z-30 py-1"
|
||||
role="menu"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
role="menuitem"
|
||||
@click="copyPreviousDay(); showActionsMenu = false"
|
||||
class="w-full text-left px-3 py-2 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
Copy Yesterday
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
@click="copyPreviousWeek(); showActionsMenu = false"
|
||||
class="w-full text-left px-3 py-2 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
Copy Last Week
|
||||
</button>
|
||||
<div class="border-t border-border-subtle my-1" />
|
||||
<button
|
||||
role="menuitem"
|
||||
@click="showTemplatePicker = true; showActionsMenu = false"
|
||||
class="w-full text-left px-3 py-2 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
From Template
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
@click="openBulkAdd(); showActionsMenu = false"
|
||||
class="w-full text-left px-3 py-2 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
Bulk Add
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<button
|
||||
@click="showTemplatePicker = true"
|
||||
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
@click="exportEntriesCSV"
|
||||
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent inline-flex items-center gap-1"
|
||||
>
|
||||
From Template
|
||||
<Download class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +181,7 @@
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle bg-bg-surface">
|
||||
<th class="px-4 py-3 w-10">
|
||||
<input type="checkbox" :checked="allSelected" :indeterminate="someSelected" @change="toggleSelectAll" aria-label="Select all entries" class="w-4 h-4 rounded border-border-visible accent-accent" />
|
||||
<input type="checkbox" :checked="allSelected" :indeterminate="someSelected" @change="toggleSelectAll" v-tooltip="'Select all'" aria-label="Select all entries" class="w-4 h-4 rounded border-border-visible accent-accent" />
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Date</th>
|
||||
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Project</th>
|
||||
@@ -169,13 +236,11 @@
|
||||
<span
|
||||
v-if="entry.billable === 0"
|
||||
class="text-[0.5625rem] font-sans font-medium text-text-tertiary bg-bg-elevated px-1 py-0.5 rounded"
|
||||
title="Non-billable"
|
||||
>NB</span>
|
||||
><span class="sr-only">Non-billable</span><span aria-hidden="true">NB</span></span>
|
||||
{{ formatDuration(entry.duration) }}
|
||||
<span
|
||||
v-if="getRoundedDuration(entry.duration) !== null"
|
||||
class="ml-1 text-[0.5625rem] font-sans text-text-tertiary"
|
||||
:title="'Actual: ' + formatDuration(entry.duration) + ' - Rounded: ' + formatDuration(getRoundedDuration(entry.duration)!)"
|
||||
>
|
||||
<Clock class="inline w-3 h-3 mr-0.5" aria-hidden="true" :stroke-width="1.5" />
|
||||
<span class="sr-only">Duration rounded from {{ formatDuration(entry.duration) }} to {{ formatDuration(getRoundedDuration(entry.duration)!) }}</span>
|
||||
@@ -186,21 +251,41 @@
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<template v-if="isEntryLocked(entry)">
|
||||
<span class="flex items-center gap-1 text-[0.6875rem] text-amber-500" title="This entry is in a locked week">
|
||||
<span class="flex items-center gap-1 text-[0.6875rem] text-amber-500">
|
||||
<Lock class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
|
||||
<span class="sr-only">This entry is in a locked week</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
|
||||
<div class="flex items-center justify-end gap-1 ">
|
||||
<button
|
||||
@click="replayEntry(entry)"
|
||||
v-tooltip="'Replay entry'"
|
||||
class="p-1.5 text-text-tertiary hover:text-status-running transition-colors duration-150"
|
||||
aria-label="Start timer with this entry"
|
||||
>
|
||||
<Play class="h-3.5 w-3.5" :stroke-width="2" fill="currentColor" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="duplicateEntry(entry)"
|
||||
v-tooltip="'Duplicate'"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
aria-label="Duplicate entry"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="entry.duration > 120"
|
||||
@click="openSplitDialog(entry)"
|
||||
v-tooltip="'Split entry'"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
aria-label="Split entry"
|
||||
>
|
||||
<Scissors class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="openEditDialog(entry)"
|
||||
v-tooltip="'Edit'"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
aria-label="Edit entry"
|
||||
>
|
||||
@@ -210,6 +295,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete(entry)"
|
||||
v-tooltip="'Delete'"
|
||||
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
|
||||
aria-label="Delete entry"
|
||||
>
|
||||
@@ -249,8 +335,24 @@
|
||||
<button @click="bulkToggleBillable" class="px-3 py-1.5 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-md hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
|
||||
Toggle Billable
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button @click="showBulkProjectPicker = !showBulkProjectPicker" class="px-3 py-1.5 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-md hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
|
||||
Change Project
|
||||
</button>
|
||||
<div v-if="showBulkProjectPicker" class="absolute bottom-full left-0 mb-2 w-56 z-20">
|
||||
<AppSelect
|
||||
:model-value="null"
|
||||
:options="projectsStore.projects"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Select project..."
|
||||
searchable
|
||||
@update:model-value="bulkChangeProject($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="showBulkDeleteConfirm">
|
||||
<span class="text-[0.6875rem] text-status-error font-medium">Delete {{ selectedIds.size }} entries?</span>
|
||||
<span class="text-[0.6875rem] text-status-error-text font-medium">Delete {{ selectedIds.size }} entries?</span>
|
||||
<button @click="executeBulkDelete" class="px-3 py-1.5 text-[0.6875rem] bg-status-error text-white rounded-md hover:bg-red-600 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
|
||||
Confirm
|
||||
</button>
|
||||
@@ -285,6 +387,12 @@
|
||||
<div id="tabpanel-expenses" role="tabpanel" aria-labelledby="tab-expenses">
|
||||
<!-- Expense Filters -->
|
||||
<div class="bg-bg-surface rounded-lg p-4 mb-6 flex flex-wrap items-end gap-4">
|
||||
<AppDateRangePresets
|
||||
:start-date="expStartDate"
|
||||
:end-date="expEndDate"
|
||||
@select="({ start, end }) => { expStartDate = start; expEndDate = end; applyExpenseFilters() }"
|
||||
class="mb-3 w-full"
|
||||
/>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date</label>
|
||||
<AppDatePicker
|
||||
@@ -321,18 +429,20 @@
|
||||
:placeholder-value="null"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="applyExpenseFilters"
|
||||
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
@click="clearExpenseFilters"
|
||||
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="applyExpenseFilters"
|
||||
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
@click="clearExpenseFilters"
|
||||
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
@click="openAddExpenseDialog"
|
||||
@@ -409,9 +519,10 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
|
||||
<div class="flex items-center justify-end gap-1 ">
|
||||
<button
|
||||
@click="openEditExpenseDialog(expense)"
|
||||
v-tooltip="'Edit'"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
aria-label="Edit expense"
|
||||
>
|
||||
@@ -419,6 +530,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDeleteExpense(expense)"
|
||||
v-tooltip="'Delete'"
|
||||
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
|
||||
aria-label="Delete expense"
|
||||
>
|
||||
@@ -491,7 +603,6 @@
|
||||
<button
|
||||
type="button"
|
||||
@click="editForm.billable = editForm.billable === 1 ? 0 : 1"
|
||||
:title="editForm.billable === 1 ? 'Billable' : 'Non-billable'"
|
||||
:aria-label="editForm.billable === 1 ? 'Mark as non-billable' : 'Mark as billable'"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-[0.8125rem] transition-colors"
|
||||
:class="editForm.billable === 1
|
||||
@@ -579,7 +690,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete"
|
||||
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
class="px-4 py-2 border border-status-error text-status-error-text font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -712,7 +823,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="handleDeleteExpense"
|
||||
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
class="px-4 py-2 border border-status-error text-status-error-text font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -727,6 +838,117 @@
|
||||
@cancel="showTemplatePicker = false"
|
||||
/>
|
||||
|
||||
<EntrySplitDialog
|
||||
:show="showSplitDialog"
|
||||
:entry="splitTarget"
|
||||
@close="showSplitDialog = false; splitTarget = null"
|
||||
@split="handleSplit"
|
||||
/>
|
||||
|
||||
<!-- Bulk Add Dialog -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showBulkAdd"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
@click.self="showBulkAdd = false"
|
||||
>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-3xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="bulk-add-title">
|
||||
<h2 id="bulk-add-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Bulk Add Entries</h2>
|
||||
<p class="text-[0.75rem] text-text-tertiary mb-4">Add multiple time entries at once. Each row creates one entry.</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle">
|
||||
<th class="px-2 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-28">Date</th>
|
||||
<th class="px-2 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Project</th>
|
||||
<th class="px-2 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</th>
|
||||
<th class="px-2 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-20">Hours</th>
|
||||
<th class="px-2 py-2 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, i) in bulkRows"
|
||||
:key="i"
|
||||
class="border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<td class="px-2 py-1.5">
|
||||
<AppDatePicker v-model="row.date" placeholder="Date" />
|
||||
</td>
|
||||
<td class="px-2 py-1.5">
|
||||
<AppSelect
|
||||
v-model="row.project_id"
|
||||
:options="projectsStore.activeProjects"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Project"
|
||||
:placeholder-value="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-1.5">
|
||||
<input
|
||||
v-model="row.description"
|
||||
type="text"
|
||||
class="w-full px-2 py-1 bg-bg-inset border border-border-subtle rounded text-[0.75rem] text-text-primary focus:outline-none focus:border-border-visible"
|
||||
placeholder="Description"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-1.5">
|
||||
<AppNumberInput
|
||||
v-model="row.hours"
|
||||
:min="0"
|
||||
:step="0.25"
|
||||
:precision="2"
|
||||
compact
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-1.5">
|
||||
<button
|
||||
@click="bulkRows.splice(i, 1)"
|
||||
v-tooltip="'Remove row'"
|
||||
class="p-1 text-text-tertiary hover:text-status-error transition-colors"
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="bulkRows.push({ date: new Date().toISOString().split('T')[0], project_id: 0, description: '', hours: 0 })"
|
||||
class="mt-3 text-[0.75rem] text-accent-text hover:underline"
|
||||
>
|
||||
+ Add Row
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
|
||||
<p class="text-[0.75rem] text-text-secondary">
|
||||
{{ bulkRows.filter(r => r.project_id && r.hours > 0).length }} valid entries, {{ formatNumber(bulkRows.reduce((s, r) => s + (r.hours || 0), 0), 1) }}h total
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="showBulkAdd = false"
|
||||
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="submitBulkEntries"
|
||||
:disabled="bulkRows.filter(r => r.project_id && r.hours > 0).length === 0"
|
||||
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create Entries
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
|
||||
|
||||
<ReceiptLightbox
|
||||
@@ -742,8 +964,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { List as ListIcon, Copy, DollarSign, Receipt, Plus, Pencil, Trash2, Lock, Image, Upload, Clock } from 'lucide-vue-next'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { List as ListIcon, Copy, DollarSign, Receipt, Plus, Pencil, Trash2, Lock, Image, Upload, Clock, Scissors, Download, Play, ChevronDown } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
|
||||
import AppNumberInput from '../components/AppNumberInput.vue'
|
||||
import AppSelect from '../components/AppSelect.vue'
|
||||
@@ -752,6 +975,7 @@ import AppDateRangePresets from '../components/AppDateRangePresets.vue'
|
||||
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
||||
import AppTagInput from '../components/AppTagInput.vue'
|
||||
import EntryTemplatePicker from '../components/EntryTemplatePicker.vue'
|
||||
import EntrySplitDialog from '../components/EntrySplitDialog.vue'
|
||||
import ReceiptLightbox from '../components/ReceiptLightbox.vue'
|
||||
import { useEntriesStore, type TimeEntry } from '../stores/entries'
|
||||
import { useExpensesStore, EXPENSE_CATEGORIES, type Expense } from '../stores/expenses'
|
||||
@@ -759,10 +983,12 @@ import { useProjectsStore } from '../stores/projects'
|
||||
import { useTagsStore } from '../stores/tags'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { useEntryTemplatesStore } from '../stores/entryTemplates'
|
||||
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
|
||||
import { useTimerStore } from '../stores/timer'
|
||||
import { formatDate, formatCurrency, getCurrencySymbol, formatNumber } from '../utils/locale'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useFormGuard } from '../utils/formGuard'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import { entriesToCSV, downloadCSV } from '../utils/csvExport'
|
||||
|
||||
const entriesStore = useEntriesStore()
|
||||
const expensesStore = useExpensesStore()
|
||||
@@ -771,6 +997,21 @@ const tagsStore = useTagsStore()
|
||||
const toast = useToastStore()
|
||||
const entryTemplatesStore = useEntryTemplatesStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const timerStore = useTimerStore()
|
||||
const router = useRouter()
|
||||
|
||||
function formatRunningDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
const showActionsMenu = ref(false)
|
||||
|
||||
function closeActionsMenu() { showActionsMenu.value = false }
|
||||
onMounted(() => document.addEventListener('click', closeActionsMenu))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', closeActionsMenu))
|
||||
|
||||
const showTemplatePicker = ref(false)
|
||||
const showReceiptLightbox = ref(false)
|
||||
const receiptLightboxData = ref({ imageUrl: '', description: '', category: '', date: '', amount: '' })
|
||||
@@ -1101,6 +1342,151 @@ async function duplicateEntry(entry: TimeEntry) {
|
||||
await loadTaskMap()
|
||||
}
|
||||
|
||||
function replayEntry(entry: TimeEntry) {
|
||||
if (!timerStore.isStopped) {
|
||||
toast.info('Timer is already running. Stop it first.')
|
||||
return
|
||||
}
|
||||
timerStore.setProject(entry.project_id)
|
||||
timerStore.setTask(entry.task_id || null)
|
||||
timerStore.setDescription(entry.description || '')
|
||||
timerStore.setBillable(entry.billable ?? 1)
|
||||
timerStore.start()
|
||||
router.push('/timer')
|
||||
}
|
||||
|
||||
// Entry split
|
||||
const splitTarget = ref<TimeEntry | null>(null)
|
||||
const showSplitDialog = ref(false)
|
||||
|
||||
function openSplitDialog(entry: TimeEntry) {
|
||||
splitTarget.value = entry
|
||||
showSplitDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSplit({ splitSeconds, descriptionB }: { splitSeconds: number; descriptionB: string }) {
|
||||
const entry = splitTarget.value!
|
||||
const startMs = new Date(entry.start_time).getTime()
|
||||
const entryA: TimeEntry = {
|
||||
...entry,
|
||||
duration: splitSeconds,
|
||||
end_time: new Date(startMs + splitSeconds * 1000).toISOString(),
|
||||
}
|
||||
const entryB: TimeEntry = {
|
||||
project_id: entry.project_id,
|
||||
task_id: entry.task_id,
|
||||
description: descriptionB,
|
||||
start_time: new Date(startMs + splitSeconds * 1000).toISOString(),
|
||||
end_time: entry.end_time,
|
||||
duration: entry.duration - splitSeconds,
|
||||
billable: entry.billable,
|
||||
}
|
||||
await entriesStore.updateEntry(entryA)
|
||||
const newId = await entriesStore.createEntry(entryB)
|
||||
if (newId && entry.id) {
|
||||
const tags = entryTags.value[entry.id]
|
||||
if (tags?.length) {
|
||||
await tagsStore.setEntryTags(newId, tags)
|
||||
}
|
||||
}
|
||||
toast.success('Entry split into two')
|
||||
showSplitDialog.value = false
|
||||
splitTarget.value = null
|
||||
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
|
||||
await loadEntryTags()
|
||||
}
|
||||
|
||||
// CSV export
|
||||
async function exportEntriesCSV() {
|
||||
const csv = entriesToCSV(
|
||||
entriesStore.entries,
|
||||
(id) => getProjectName(id),
|
||||
(projectId) => {
|
||||
const project = projectsStore.projects.find(p => p.id === projectId)
|
||||
if (!project?.client_id) return ''
|
||||
return ''
|
||||
},
|
||||
(taskId) => getTaskName(taskId),
|
||||
(entryId) => {
|
||||
const tags = entryTags.value[entryId]
|
||||
if (!tags?.length) return ''
|
||||
return tags.map(id => tagsStore.tags.find(t => t.id === id)?.name || '').filter(Boolean).join('; ')
|
||||
},
|
||||
)
|
||||
const ok = await downloadCSV(csv, `zeroclock-entries-${new Date().toISOString().split('T')[0]}.csv`)
|
||||
if (ok) toast.success('Entries exported')
|
||||
}
|
||||
|
||||
// Bulk change project
|
||||
const showBulkProjectPicker = ref(false)
|
||||
|
||||
async function bulkChangeProject(projectId: number | null) {
|
||||
if (!projectId) return
|
||||
const ids = Array.from(selectedIds.value)
|
||||
try {
|
||||
await invoke('bulk_update_entries_project', { ids, projectId })
|
||||
for (const entry of entriesStore.entries) {
|
||||
if (entry.id && ids.includes(entry.id)) entry.project_id = projectId
|
||||
}
|
||||
toast.success(`Updated ${ids.length} entries`)
|
||||
clearSelection()
|
||||
showBulkProjectPicker.value = false
|
||||
} catch {
|
||||
toast.error('Failed to change project')
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// BULK ADD
|
||||
// =============================================
|
||||
const showBulkAdd = ref(false)
|
||||
const bulkRows = ref<Array<{ date: string; project_id: number; description: string; hours: number }>>([])
|
||||
|
||||
function openBulkAdd() {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
bulkRows.value = [
|
||||
{ date: today, project_id: 0, description: '', hours: 0 },
|
||||
{ date: today, project_id: 0, description: '', hours: 0 },
|
||||
{ date: today, project_id: 0, description: '', hours: 0 },
|
||||
]
|
||||
showBulkAdd.value = true
|
||||
}
|
||||
|
||||
async function submitBulkEntries() {
|
||||
const valid = bulkRows.value.filter(r => r.project_id && r.hours > 0)
|
||||
if (valid.length === 0) return
|
||||
let created = 0
|
||||
for (const row of valid) {
|
||||
const rowStart = new Date(`${row.date}T09:00:00`)
|
||||
const durationSeconds = Math.round(row.hours * 3600)
|
||||
try {
|
||||
await entriesStore.createEntry({
|
||||
project_id: row.project_id,
|
||||
description: row.description || undefined,
|
||||
start_time: rowStart.toISOString(),
|
||||
end_time: new Date(rowStart.getTime() + durationSeconds * 1000).toISOString(),
|
||||
duration: durationSeconds,
|
||||
billable: 1,
|
||||
})
|
||||
created++
|
||||
} catch (error) {
|
||||
const msg = String(error)
|
||||
if (msg.includes('locked week')) {
|
||||
toast.error('Cannot add entries to a locked week')
|
||||
} else {
|
||||
toast.error('Failed to create entry')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (created > 0) {
|
||||
toast.success(`Created ${created} entries`)
|
||||
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
|
||||
await loadEntryTags()
|
||||
await loadTaskMap()
|
||||
}
|
||||
showBulkAdd.value = false
|
||||
}
|
||||
|
||||
// Copy yesterday's entries to today
|
||||
async function copyPreviousDay() {
|
||||
const yesterday = new Date()
|
||||
|
||||
@@ -33,6 +33,113 @@
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
id="tab-recurring"
|
||||
role="tab"
|
||||
:aria-selected="view === 'recurring'"
|
||||
aria-controls="tabpanel-recurring"
|
||||
:tabindex="view === 'recurring' ? 0 : -1"
|
||||
@click="view = 'recurring'; loadRecurringInvoices()"
|
||||
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
|
||||
:class="view === 'recurring'
|
||||
? 'text-text-primary border-b-2 border-accent'
|
||||
: 'text-text-tertiary hover:text-text-secondary'"
|
||||
>
|
||||
Recurring
|
||||
</button>
|
||||
<button
|
||||
id="tab-pipeline"
|
||||
role="tab"
|
||||
:aria-selected="view === 'pipeline'"
|
||||
aria-controls="tabpanel-pipeline"
|
||||
:tabindex="view === 'pipeline' ? 0 : -1"
|
||||
@click="view = 'pipeline'"
|
||||
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
|
||||
:class="view === 'pipeline'
|
||||
? 'text-text-primary border-b-2 border-accent'
|
||||
: 'text-text-tertiary hover:text-text-secondary'"
|
||||
>
|
||||
Pipeline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recurring View -->
|
||||
<div v-if="view === 'recurring'" id="tabpanel-recurring" role="tabpanel" aria-labelledby="tab-recurring">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-[0.75rem] text-text-secondary">Automatically create draft invoices on a schedule.</p>
|
||||
<button
|
||||
@click="openRecurringDialog(null)"
|
||||
class="px-3 py-1.5 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="recurringInvoices.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle bg-bg-surface">
|
||||
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Client</th>
|
||||
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Frequency</th>
|
||||
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Next Due</th>
|
||||
<th class="px-4 py-3 text-center text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Status</th>
|
||||
<th class="px-4 py-3 w-20"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="ri in recurringInvoices"
|
||||
:key="ri.id"
|
||||
class="border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<td class="px-4 py-3 text-[0.75rem] text-text-primary">{{ getClientName(ri.client_id) }}</td>
|
||||
<td class="px-4 py-3 text-[0.75rem] text-text-secondary capitalize">{{ ri.recurrence_rule }}</td>
|
||||
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">{{ formatDate(ri.next_due_date) }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 text-[0.6875rem] font-medium rounded-full"
|
||||
:class="ri.enabled ? 'bg-green-500/10 text-green-400' : 'bg-bg-elevated text-text-tertiary'"
|
||||
>
|
||||
{{ ri.enabled ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
@click="openRecurringDialog(ri)"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
aria-label="Edit recurring invoice"
|
||||
v-tooltip="'Edit'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteRecurring(ri.id)"
|
||||
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors"
|
||||
aria-label="Delete recurring invoice"
|
||||
v-tooltip="'Delete'"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-16">
|
||||
<FileText class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
|
||||
<p class="text-sm text-text-secondary mt-4">No recurring invoices</p>
|
||||
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Set up automatic invoice creation for clients on a regular schedule.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline View -->
|
||||
<div v-if="view === 'pipeline'" id="tabpanel-pipeline" role="tabpanel" aria-labelledby="tab-pipeline">
|
||||
<InvoicePipelineView @open="openInvoiceFromPipeline" />
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
@@ -95,25 +202,35 @@
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
v-if="invoice.status === 'draft'"
|
||||
@click="invoicesStore.updateStatus(invoice.id!, 'sent')"
|
||||
@click="markInvoiceSent(invoice.id!)"
|
||||
class="px-2 py-1 text-[0.625rem] font-medium text-blue-400 border border-blue-400/30 rounded hover:bg-blue-400/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
:aria-label="'Mark invoice ' + invoice.invoice_number + ' as sent'"
|
||||
>
|
||||
Mark Sent
|
||||
</button>
|
||||
<button
|
||||
v-if="invoice.status === 'sent' || invoice.status === 'overdue'"
|
||||
@click="invoicesStore.updateStatus(invoice.id!, 'paid')"
|
||||
v-if="invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'partial'"
|
||||
@click="openPaymentDialog(invoice)"
|
||||
class="px-2 py-1 text-[0.625rem] font-medium text-green-400 border border-green-400/30 rounded hover:bg-green-400/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
:aria-label="'Mark invoice ' + invoice.invoice_number + ' as paid'"
|
||||
:aria-label="'Record payment for invoice ' + invoice.invoice_number"
|
||||
>
|
||||
Mark Paid
|
||||
Record Payment
|
||||
</button>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="invoice.status !== 'draft'"
|
||||
@click="emailInvoice(invoice)"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
:aria-label="'Email invoice ' + invoice.invoice_number"
|
||||
v-tooltip="'Email'"
|
||||
>
|
||||
<Mail class="h-3.5 w-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="viewInvoice(invoice)"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
aria-label="View invoice"
|
||||
v-tooltip="'View'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
@@ -124,6 +241,7 @@
|
||||
@click="exportPDF(invoice)"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
aria-label="Export PDF"
|
||||
v-tooltip="'Export PDF'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
@@ -133,6 +251,7 @@
|
||||
@click="confirmDelete(invoice)"
|
||||
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
|
||||
aria-label="Delete invoice"
|
||||
v-tooltip="'Delete'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@@ -338,7 +457,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-if="importPreview" aria-live="polite" class="text-[0.6875rem] text-text-secondary ml-auto">
|
||||
{{ importPreview.count }} entries, {{ importPreview.hours.toFixed(1) }} hours, {{ formatCurrency(importPreview.amount) }}
|
||||
{{ importPreview.count }} entries, {{ formatNumber(importPreview.hours, 1) }} hours, {{ formatCurrency(importPreview.amount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,8 +466,8 @@
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle bg-bg-inset">
|
||||
<th class="px-4 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</th>
|
||||
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-20">Qty</th>
|
||||
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-28">Rate</th>
|
||||
<th class="px-4 py-2 text-center text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-36">Qty</th>
|
||||
<th class="px-4 py-2 text-center text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-40">Rate</th>
|
||||
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-28">Amount</th>
|
||||
<th class="px-4 py-2 w-10"></th>
|
||||
</tr>
|
||||
@@ -369,23 +488,24 @@
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-1.5">
|
||||
<input
|
||||
v-model.number="item.quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary text-right font-mono focus:outline-none focus:bg-bg-inset rounded"
|
||||
aria-label="Quantity"
|
||||
<AppNumberInput
|
||||
v-model="item.quantity"
|
||||
:min="0"
|
||||
:step="0.25"
|
||||
:precision="2"
|
||||
label="Quantity"
|
||||
compact
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-1.5">
|
||||
<input
|
||||
v-model.number="item.unit_price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary text-right font-mono focus:outline-none focus:bg-bg-inset rounded"
|
||||
aria-label="Unit price"
|
||||
<AppNumberInput
|
||||
v-model="item.unit_price"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:precision="2"
|
||||
:prefix="getCurrencySymbol()"
|
||||
label="Unit price"
|
||||
compact
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-1.5 text-right text-[0.75rem] font-mono text-text-primary">
|
||||
@@ -397,6 +517,7 @@
|
||||
@click="lineItems.splice(i, 1)"
|
||||
class="p-1 text-text-tertiary hover:text-status-error transition-colors"
|
||||
:aria-label="'Remove line item ' + (i + 1)"
|
||||
v-tooltip="'Remove'"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -503,6 +624,7 @@
|
||||
@click="handlePickerBack"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
aria-label="Back to invoice list"
|
||||
v-tooltip="'Back'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
@@ -536,7 +658,7 @@
|
||||
<!-- Left: Template list -->
|
||||
<div class="w-56 border-r border-border-subtle overflow-y-auto bg-bg-surface shrink-0">
|
||||
<div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id">
|
||||
<div class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.1em] font-medium px-3 pt-3 pb-1">
|
||||
<div class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium px-3 pt-3 pb-1">
|
||||
{{ cat.label }}
|
||||
</div>
|
||||
<button
|
||||
@@ -591,7 +713,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete"
|
||||
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
class="px-4 py-2 border border-status-error text-status-error-text font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -599,18 +721,207 @@
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Payment Dialog -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showPaymentDialog"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
@click.self="showPaymentDialog = false"
|
||||
>
|
||||
<div 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" role="dialog" aria-modal="true" aria-labelledby="payment-dialog-title">
|
||||
<h2 id="payment-dialog-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-1">Record Payment</h2>
|
||||
<p class="text-[0.75rem] text-text-secondary mb-4">
|
||||
{{ paymentInvoice?.invoice_number }} - {{ formatCurrency(paymentInvoice?.total || 0) }} total, {{ formatCurrency(paymentRemaining) }} remaining
|
||||
</p>
|
||||
|
||||
<!-- Existing payments -->
|
||||
<div v-if="invoicePayments.length > 0" class="mb-4 space-y-1.5">
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Previous payments</p>
|
||||
<div
|
||||
v-for="pmt in invoicePayments"
|
||||
:key="pmt.id"
|
||||
class="flex items-center justify-between px-3 py-1.5 bg-bg-inset rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(pmt.amount) }}</span>
|
||||
<span class="text-[0.625rem] text-text-tertiary">{{ formatDate(pmt.date) }}</span>
|
||||
<span v-if="pmt.method" class="text-[0.625rem] text-text-tertiary capitalize">{{ pmt.method }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="deletePayment(pmt.id)"
|
||||
class="p-1 text-text-tertiary hover:text-status-error transition-colors"
|
||||
:aria-label="'Delete payment of ' + formatCurrency(pmt.amount)"
|
||||
v-tooltip="'Delete'"
|
||||
>
|
||||
<X class="w-3 h-3" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitPayment" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Amount *</label>
|
||||
<AppNumberInput
|
||||
v-model="paymentForm.amount"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:precision="2"
|
||||
:prefix="getCurrencySymbol()"
|
||||
/>
|
||||
<button
|
||||
v-if="paymentRemaining > 0"
|
||||
type="button"
|
||||
@click="paymentForm.amount = paymentRemaining"
|
||||
class="mt-1 text-[0.625rem] text-accent-text hover:underline"
|
||||
>
|
||||
Pay remaining {{ formatCurrency(paymentRemaining) }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Date</label>
|
||||
<AppDatePicker v-model="paymentForm.date" placeholder="Payment date" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Method</label>
|
||||
<AppSelect
|
||||
v-model="paymentForm.method"
|
||||
:options="paymentMethods"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="Select method"
|
||||
:placeholder-value="''"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
|
||||
<input
|
||||
v-model="paymentForm.notes"
|
||||
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="Optional payment note"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="showPaymentDialog = false"
|
||||
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!paymentForm.amount || paymentForm.amount <= 0"
|
||||
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Record Payment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Recurring Invoice Dialog -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showRecurringDialog"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
@click.self="showRecurringDialog = false"
|
||||
>
|
||||
<div 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" role="dialog" aria-modal="true" aria-labelledby="recurring-dialog-title">
|
||||
<h2 id="recurring-dialog-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
|
||||
{{ editingRecurring ? 'Edit Recurring Invoice' : 'New Recurring Invoice' }}
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="submitRecurring" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client *</label>
|
||||
<AppSelect
|
||||
v-model="recurringForm.client_id"
|
||||
:options="clientsStore.clients"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Select a client"
|
||||
:placeholder-value="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Frequency *</label>
|
||||
<AppSelect
|
||||
v-model="recurringForm.recurrence_rule"
|
||||
:options="recurrenceOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="Select frequency"
|
||||
:placeholder-value="''"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Next Due Date *</label>
|
||||
<AppDatePicker v-model="recurringForm.next_due_date" placeholder="Next due date" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tax Rate (%)</label>
|
||||
<AppNumberInput v-model="recurringForm.tax_rate" :min="0" :max="100" :step="0.5" :precision="2" suffix="%" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Discount</label>
|
||||
<AppNumberInput v-model="recurringForm.discount" :min="0" :step="1" :precision="2" :prefix="getCurrencySymbol()" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
|
||||
<input
|
||||
v-model="recurringForm.notes"
|
||||
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="Optional notes for generated invoices"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="recurring-enabled"
|
||||
v-model="recurringForm.enabled"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-border-subtle text-accent focus:ring-accent"
|
||||
/>
|
||||
<label for="recurring-enabled" class="text-[0.8125rem] text-text-primary">Enabled</label>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="showRecurringDialog = false"
|
||||
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!recurringForm.client_id || !recurringForm.recurrence_rule || !recurringForm.next_due_date"
|
||||
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ editingRecurring ? 'Update' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { FileText, X, Receipt, Clock } from 'lucide-vue-next'
|
||||
import { FileText, X, Receipt, Clock, Mail } from 'lucide-vue-next'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import AppNumberInput from '../components/AppNumberInput.vue'
|
||||
import AppSelect from '../components/AppSelect.vue'
|
||||
import AppDatePicker from '../components/AppDatePicker.vue'
|
||||
import InvoicePreview from '../components/InvoicePreview.vue'
|
||||
import InvoicePipelineView from '../components/InvoicePipelineView.vue'
|
||||
import { getTemplateById, getTemplatesByCategory, loadTemplates, TEMPLATE_CATEGORIES } from '../utils/invoiceTemplates'
|
||||
import type { BusinessInfo } from '../utils/invoicePdfRenderer'
|
||||
import { useInvoicesStore, type Invoice, type InvoiceItem } from '../stores/invoices'
|
||||
@@ -620,7 +931,7 @@ import { useSettingsStore } from '../stores/settings'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { generateInvoicePdf } from '../utils/invoicePdf'
|
||||
import { useExpensesStore } from '../stores/expenses'
|
||||
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
|
||||
import { formatDate, formatCurrency, formatNumber, getCurrencySymbol } from '../utils/locale'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
interface LineItem {
|
||||
@@ -640,7 +951,7 @@ const expensesStore = useExpensesStore()
|
||||
const importedExpenseIds = ref<number[]>([])
|
||||
|
||||
// View state
|
||||
const view = ref<'list' | 'create' | 'template-picker'>('list')
|
||||
const view = ref<'list' | 'create' | 'template-picker' | 'pipeline' | 'recurring'>('list')
|
||||
const pickerInvoiceId = ref<number | null>(null)
|
||||
|
||||
// Dialog state
|
||||
@@ -654,6 +965,7 @@ const statusOptions = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Sent', value: 'sent' },
|
||||
{ label: 'Partial', value: 'partial' },
|
||||
{ label: 'Paid', value: 'paid' },
|
||||
{ label: 'Overdue', value: 'overdue' },
|
||||
]
|
||||
@@ -667,6 +979,7 @@ function getStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'draft': return 'bg-bg-elevated text-text-tertiary'
|
||||
case 'sent': return 'bg-blue-500/10 text-blue-400'
|
||||
case 'partial': return 'bg-amber-500/10 text-amber-400'
|
||||
case 'paid': return 'bg-green-500/10 text-green-400'
|
||||
case 'overdue': return 'bg-red-500/10 text-red-400'
|
||||
default: return 'bg-bg-elevated text-text-tertiary'
|
||||
@@ -740,6 +1053,10 @@ watch(view, (newView, oldView) => {
|
||||
})
|
||||
|
||||
// Arrow key navigation for tabs
|
||||
function openInvoiceFromPipeline(_id: number) {
|
||||
view.value = 'list'
|
||||
}
|
||||
|
||||
function onTabKeydown(e: KeyboardEvent) {
|
||||
const tabs: Array<'list' | 'create'> = ['list', 'create']
|
||||
const current = tabs.indexOf(view.value as 'list' | 'create')
|
||||
@@ -827,7 +1144,7 @@ async function importFromProject() {
|
||||
|
||||
if (totalHours > 0) {
|
||||
lineItems.value.push({
|
||||
description: `${project.name} - ${totalHours.toFixed(1)}h tracked`,
|
||||
description: `${project.name} - ${formatNumber(totalHours, 1)}h tracked`,
|
||||
quantity: parseFloat(totalHours.toFixed(2)),
|
||||
unit_price: project.hourly_rate
|
||||
})
|
||||
@@ -888,7 +1205,16 @@ function calculateTotal(): number {
|
||||
return calculateSubtotal() + calculateTax() - createForm.discount
|
||||
}
|
||||
|
||||
// View invoice → open template picker
|
||||
async function markInvoiceSent(id: number) {
|
||||
try {
|
||||
await invoicesStore.updateStatus(id, 'sent')
|
||||
} catch (e) {
|
||||
console.error('Failed to mark invoice as sent:', e)
|
||||
toastStore.error('Failed to mark invoice as sent')
|
||||
}
|
||||
}
|
||||
|
||||
// View invoice -> open template picker
|
||||
async function viewInvoice(invoice: Invoice) {
|
||||
selectedInvoice.value = invoice
|
||||
pickerInvoiceId.value = invoice.id!
|
||||
@@ -897,6 +1223,7 @@ async function viewInvoice(invoice: Invoice) {
|
||||
previewItems.value = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
|
||||
} catch (e) {
|
||||
console.error('Failed to load invoice items:', e)
|
||||
toastStore.error('Failed to load invoice items')
|
||||
previewItems.value = []
|
||||
}
|
||||
view.value = 'template-picker'
|
||||
@@ -917,6 +1244,224 @@ async function handleDelete() {
|
||||
invoiceToDelete.value = null
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// PAYMENTS
|
||||
// =============================================
|
||||
|
||||
interface InvoicePayment {
|
||||
id: number
|
||||
invoice_id: number
|
||||
amount: number
|
||||
date: string
|
||||
method: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
const showPaymentDialog = ref(false)
|
||||
const paymentInvoice = ref<Invoice | null>(null)
|
||||
const invoicePayments = ref<InvoicePayment[]>([])
|
||||
|
||||
const paymentForm = reactive({
|
||||
amount: 0,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
method: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const paymentMethods = [
|
||||
{ label: 'Bank Transfer', value: 'bank_transfer' },
|
||||
{ label: 'Credit Card', value: 'credit_card' },
|
||||
{ label: 'PayPal', value: 'paypal' },
|
||||
{ label: 'Check', value: 'check' },
|
||||
{ label: 'Cash', value: 'cash' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
]
|
||||
|
||||
const paymentRemaining = computed(() => {
|
||||
if (!paymentInvoice.value) return 0
|
||||
const paid = invoicePayments.value.reduce((sum, p) => sum + p.amount, 0)
|
||||
return Math.max(0, paymentInvoice.value.total - paid)
|
||||
})
|
||||
|
||||
async function openPaymentDialog(invoice: Invoice) {
|
||||
paymentInvoice.value = invoice
|
||||
paymentForm.amount = 0
|
||||
paymentForm.date = new Date().toISOString().split('T')[0]
|
||||
paymentForm.method = ''
|
||||
paymentForm.notes = ''
|
||||
|
||||
try {
|
||||
invoicePayments.value = await invoke<InvoicePayment[]>('get_invoice_payments', { invoiceId: invoice.id })
|
||||
} catch {
|
||||
invoicePayments.value = []
|
||||
}
|
||||
|
||||
showPaymentDialog.value = true
|
||||
}
|
||||
|
||||
async function submitPayment() {
|
||||
if (!paymentInvoice.value?.id || !paymentForm.amount || paymentForm.amount <= 0) return
|
||||
|
||||
try {
|
||||
await invoke('add_invoice_payment', {
|
||||
payment: {
|
||||
id: 0,
|
||||
invoice_id: paymentInvoice.value.id,
|
||||
amount: paymentForm.amount,
|
||||
date: paymentForm.date,
|
||||
method: paymentForm.method,
|
||||
notes: paymentForm.notes,
|
||||
}
|
||||
})
|
||||
|
||||
// Refresh payments and invoice list
|
||||
invoicePayments.value = await invoke<InvoicePayment[]>('get_invoice_payments', { invoiceId: paymentInvoice.value.id })
|
||||
await invoicesStore.fetchInvoices()
|
||||
|
||||
// Update local reference
|
||||
const updated = invoicesStore.invoices.find(i => i.id === paymentInvoice.value?.id)
|
||||
if (updated) paymentInvoice.value = updated
|
||||
|
||||
// Reset form for next payment
|
||||
paymentForm.amount = 0
|
||||
paymentForm.notes = ''
|
||||
|
||||
toastStore.success('Payment recorded')
|
||||
} catch (e) {
|
||||
console.error('Failed to record payment:', e)
|
||||
toastStore.error('Failed to record payment')
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePayment(paymentId: number) {
|
||||
if (!paymentInvoice.value?.id) return
|
||||
|
||||
try {
|
||||
await invoke('delete_invoice_payment', { id: paymentId, invoiceId: paymentInvoice.value.id })
|
||||
invoicePayments.value = await invoke<InvoicePayment[]>('get_invoice_payments', { invoiceId: paymentInvoice.value.id })
|
||||
await invoicesStore.fetchInvoices()
|
||||
|
||||
const updated = invoicesStore.invoices.find(i => i.id === paymentInvoice.value?.id)
|
||||
if (updated) paymentInvoice.value = updated
|
||||
|
||||
toastStore.success('Payment deleted')
|
||||
} catch (e) {
|
||||
console.error('Failed to delete payment:', e)
|
||||
toastStore.error('Failed to delete payment')
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// RECURRING INVOICES
|
||||
// =============================================
|
||||
|
||||
interface RecurringInvoice {
|
||||
id: number
|
||||
client_id: number
|
||||
template_id: string
|
||||
line_items_json: string
|
||||
tax_rate: number
|
||||
discount: number
|
||||
notes: string
|
||||
recurrence_rule: string
|
||||
next_due_date: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const recurringInvoices = ref<RecurringInvoice[]>([])
|
||||
const showRecurringDialog = ref(false)
|
||||
const editingRecurring = ref<RecurringInvoice | null>(null)
|
||||
|
||||
const recurringForm = reactive({
|
||||
client_id: 0,
|
||||
recurrence_rule: '',
|
||||
next_due_date: '',
|
||||
tax_rate: 0,
|
||||
discount: 0,
|
||||
notes: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const recurrenceOptions = [
|
||||
{ label: 'Weekly', value: 'weekly' },
|
||||
{ label: 'Biweekly', value: 'biweekly' },
|
||||
{ label: 'Monthly', value: 'monthly' },
|
||||
{ label: 'Quarterly', value: 'quarterly' },
|
||||
{ label: 'Yearly', value: 'yearly' },
|
||||
]
|
||||
|
||||
async function loadRecurringInvoices() {
|
||||
try {
|
||||
recurringInvoices.value = await invoke<RecurringInvoice[]>('get_recurring_invoices')
|
||||
} catch {
|
||||
recurringInvoices.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openRecurringDialog(ri: RecurringInvoice | null) {
|
||||
editingRecurring.value = ri
|
||||
if (ri) {
|
||||
recurringForm.client_id = ri.client_id
|
||||
recurringForm.recurrence_rule = ri.recurrence_rule
|
||||
recurringForm.next_due_date = ri.next_due_date
|
||||
recurringForm.tax_rate = ri.tax_rate
|
||||
recurringForm.discount = ri.discount
|
||||
recurringForm.notes = ri.notes
|
||||
recurringForm.enabled = ri.enabled
|
||||
} else {
|
||||
recurringForm.client_id = 0
|
||||
recurringForm.recurrence_rule = ''
|
||||
recurringForm.next_due_date = ''
|
||||
recurringForm.tax_rate = 0
|
||||
recurringForm.discount = 0
|
||||
recurringForm.notes = ''
|
||||
recurringForm.enabled = true
|
||||
}
|
||||
showRecurringDialog.value = true
|
||||
}
|
||||
|
||||
async function submitRecurring() {
|
||||
if (!recurringForm.client_id || !recurringForm.recurrence_rule || !recurringForm.next_due_date) return
|
||||
|
||||
const payload = {
|
||||
id: editingRecurring.value?.id || 0,
|
||||
client_id: recurringForm.client_id,
|
||||
template_id: editingRecurring.value?.template_id || 'clean',
|
||||
line_items_json: editingRecurring.value?.line_items_json || '[]',
|
||||
tax_rate: recurringForm.tax_rate,
|
||||
discount: recurringForm.discount,
|
||||
notes: recurringForm.notes,
|
||||
recurrence_rule: recurringForm.recurrence_rule,
|
||||
next_due_date: recurringForm.next_due_date,
|
||||
enabled: recurringForm.enabled,
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingRecurring.value) {
|
||||
await invoke('update_recurring_invoice', { invoice: payload })
|
||||
} else {
|
||||
await invoke('create_recurring_invoice', { invoice: payload })
|
||||
}
|
||||
await loadRecurringInvoices()
|
||||
showRecurringDialog.value = false
|
||||
toastStore.success(editingRecurring.value ? 'Recurring invoice updated' : 'Recurring invoice created')
|
||||
} catch (e) {
|
||||
console.error('Failed to save recurring invoice:', e)
|
||||
toastStore.error('Failed to save recurring invoice')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecurring(id: number) {
|
||||
try {
|
||||
await invoke('delete_recurring_invoice', { id })
|
||||
await loadRecurringInvoices()
|
||||
toastStore.success('Recurring invoice deleted')
|
||||
} catch (e) {
|
||||
console.error('Failed to delete recurring invoice:', e)
|
||||
toastStore.error('Failed to delete recurring invoice')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle create
|
||||
async function handleCreate() {
|
||||
if (!createForm.client_id) {
|
||||
@@ -995,12 +1540,17 @@ async function handleCreate() {
|
||||
|
||||
// Template picker actions
|
||||
async function handlePickerSave() {
|
||||
if (pickerInvoiceId.value) {
|
||||
await invoicesStore.updateInvoiceTemplate(pickerInvoiceId.value, selectedTemplateId.value)
|
||||
try {
|
||||
if (pickerInvoiceId.value) {
|
||||
await invoicesStore.updateInvoiceTemplate(pickerInvoiceId.value, selectedTemplateId.value)
|
||||
}
|
||||
pickerInvoiceId.value = null
|
||||
selectedInvoice.value = null
|
||||
view.value = 'list'
|
||||
} catch (e) {
|
||||
console.error('Failed to save template:', e)
|
||||
toastStore.error('Failed to save invoice template')
|
||||
}
|
||||
pickerInvoiceId.value = null
|
||||
selectedInvoice.value = null
|
||||
view.value = 'list'
|
||||
}
|
||||
|
||||
async function handlePickerExport() {
|
||||
@@ -1043,6 +1593,32 @@ async function exportPDF(invoice: Invoice) {
|
||||
}
|
||||
}
|
||||
|
||||
// Email invoice via system mail client
|
||||
function emailInvoice(invoice: Invoice) {
|
||||
const client = clientsStore.clients.find(c => c.id === invoice.client_id)
|
||||
if (!client) {
|
||||
toastStore.error('Client not found for invoice')
|
||||
return
|
||||
}
|
||||
|
||||
if (!client.email) {
|
||||
toastStore.info('No email address set for this client - add one in the Clients page')
|
||||
return
|
||||
}
|
||||
|
||||
const subject = encodeURIComponent(`Invoice ${invoice.invoice_number}`)
|
||||
const body = encodeURIComponent(
|
||||
`Hi ${client.name},\n\n` +
|
||||
`Please find attached invoice ${invoice.invoice_number} for ${formatCurrency(invoice.total)}.\n\n` +
|
||||
(invoice.due_date ? `Payment is due by ${invoice.due_date}.\n\n` : '') +
|
||||
`Thank you for your business.\n\n` +
|
||||
`${settingsStore.settings.business_name || ''}`
|
||||
)
|
||||
|
||||
window.open(`mailto:${client.email}?subject=${subject}&body=${body}`)
|
||||
toastStore.info('Mail client opened - export the PDF and attach it to the email')
|
||||
}
|
||||
|
||||
// Load data on mount
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
@@ -1052,5 +1628,17 @@ onMounted(async () => {
|
||||
settingsStore.fetchSettings(),
|
||||
loadTemplates(),
|
||||
])
|
||||
|
||||
// Auto-create any due recurring invoices
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const created = await invoke<number>('check_recurring_invoices', { today })
|
||||
if (created > 0) {
|
||||
await invoicesStore.fetchInvoices()
|
||||
toastStore.info(`${created} recurring invoice(s) created`)
|
||||
}
|
||||
} catch {
|
||||
// Ignore - recurring check is best-effort
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,25 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useTimerStore } from '../stores/timer'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { emit, listen } from '@tauri-apps/api/event'
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { Square, Maximize2 } from 'lucide-vue-next'
|
||||
import { loadAndApplyTimerFont } from '../utils/fonts'
|
||||
import { loadAndApplyUIFont } from '../utils/uiFonts'
|
||||
import { useAnnouncer } from '../composables/useAnnouncer'
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
interface TimerSyncPayload {
|
||||
projectId: number | null
|
||||
timerState: string
|
||||
elapsedSeconds: number
|
||||
}
|
||||
|
||||
const projectId = ref<number | null>(null)
|
||||
const timerState = ref('STOPPED')
|
||||
const elapsedSeconds = ref(0)
|
||||
const projects = ref<Array<{ id: number; name: string; color: string }>>([])
|
||||
const theme = ref<{ theme_mode?: string; accent_color?: string }>({})
|
||||
|
||||
const { announcement: miniAnnouncement, announce } = useAnnouncer()
|
||||
let previousTimerState = 'STOPPED'
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
const isRunning = computed(() => timerState.value === 'RUNNING')
|
||||
const isPausedManual = computed(() => timerState.value === 'PAUSED_MANUAL')
|
||||
const isActive = computed(() => timerState.value !== 'STOPPED')
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const h = Math.floor(elapsedSeconds.value / 3600)
|
||||
const m = Math.floor((elapsedSeconds.value % 3600) / 60)
|
||||
const s = elapsedSeconds.value % 60
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const projectName = computed(() => {
|
||||
if (!timerStore.selectedProjectId) return 'No project'
|
||||
return projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.name || 'Unknown'
|
||||
if (!projectId.value) return 'No project'
|
||||
return projects.value.find(p => p.id === projectId.value)?.name || 'Unknown'
|
||||
})
|
||||
|
||||
const projectColor = computed(() => {
|
||||
if (!timerStore.selectedProjectId) return '#6B7280'
|
||||
return projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.color || '#6B7280'
|
||||
if (!projectId.value) return '#6B7280'
|
||||
return projects.value.find(p => p.id === projectId.value)?.color || '#6B7280'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Fetch projects and settings for display
|
||||
try {
|
||||
const [p, s] = await Promise.all([
|
||||
invoke<Array<{ id: number; name: string; color: string }>>('get_projects'),
|
||||
invoke<Record<string, string>>('get_settings'),
|
||||
])
|
||||
projects.value = p
|
||||
theme.value = s
|
||||
loadAndApplyTimerFont(s.timer_font || 'JetBrains Mono')
|
||||
const dyslexia = s.dyslexia_mode === 'true'
|
||||
const uiFont = dyslexia ? 'OpenDyslexic' : (s.ui_font || 'Inter')
|
||||
if (uiFont !== 'Inter') loadAndApplyUIFont(uiFont)
|
||||
} catch (e) {
|
||||
console.error('Mini-timer init error:', e)
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
const el = document.documentElement
|
||||
const mode = theme.value.theme_mode || 'dark'
|
||||
if (mode === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
el.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
|
||||
} else {
|
||||
el.setAttribute('data-theme', mode)
|
||||
}
|
||||
el.setAttribute('data-accent', theme.value.accent_color || 'amber')
|
||||
|
||||
// Listen for timer state from main window
|
||||
unlisten = await listen<TimerSyncPayload>('timer-sync', (event) => {
|
||||
const newState = event.payload.timerState
|
||||
if (newState !== previousTimerState) {
|
||||
if (newState === 'RUNNING' && previousTimerState !== 'STOPPED') {
|
||||
announce('Timer resumed')
|
||||
} else if (newState === 'RUNNING') {
|
||||
announce('Timer started')
|
||||
} else if (newState === 'PAUSED_MANUAL') {
|
||||
announce('Timer paused')
|
||||
} else if (newState === 'PAUSED_IDLE') {
|
||||
announce('Idle detected - timer paused')
|
||||
} else if (newState === 'PAUSED_APP') {
|
||||
announce('App not visible - timer paused')
|
||||
} else if (newState === 'STOPPED' && previousTimerState !== 'STOPPED') {
|
||||
announce('Timer stopped')
|
||||
}
|
||||
previousTimerState = newState
|
||||
}
|
||||
projectId.value = event.payload.projectId
|
||||
timerState.value = event.payload.timerState
|
||||
elapsedSeconds.value = event.payload.elapsedSeconds
|
||||
})
|
||||
|
||||
// Request current state
|
||||
await emit('mini-timer-ready')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlisten) unlisten()
|
||||
})
|
||||
|
||||
async function stopTimer() {
|
||||
await timerStore.stop()
|
||||
await emit('mini-timer-stop')
|
||||
}
|
||||
|
||||
async function pauseTimer() {
|
||||
await emit('mini-timer-pause')
|
||||
}
|
||||
|
||||
async function resumeTimer() {
|
||||
await emit('mini-timer-resume')
|
||||
}
|
||||
|
||||
async function expandToMain() {
|
||||
@@ -33,23 +128,54 @@ async function expandToMain() {
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full flex items-center gap-3 px-4 bg-bg-base select-none" style="-webkit-app-region: drag">
|
||||
<span class="w-2.5 h-2.5 rounded-full shrink-0" :style="{ backgroundColor: projectColor }" />
|
||||
<span class="w-2.5 h-2.5 rounded-full shrink-0" :style="{ backgroundColor: projectColor }" aria-hidden="true" />
|
||||
<span class="text-[0.75rem] text-text-primary truncate flex-1">{{ projectName }}</span>
|
||||
<span class="text-[1rem] font-mono text-accent-text font-medium tracking-tight">{{ timerStore.formattedTime }}</span>
|
||||
<span role="timer" :aria-label="'Elapsed time: ' + formattedTime" class="text-[1rem] font-[family-name:var(--font-timer)] text-accent-text font-medium tracking-tight">{{ formattedTime }}</span>
|
||||
<div class="flex items-center gap-1" style="-webkit-app-region: no-drag">
|
||||
<!-- Pause button (when running) -->
|
||||
<button
|
||||
v-if="timerStore.isRunning"
|
||||
v-if="isRunning"
|
||||
@click="pauseTimer"
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-status-warning transition-colors"
|
||||
aria-label="Pause"
|
||||
v-tooltip="'Pause'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3" aria-hidden="true">
|
||||
<rect x="3" y="2" width="3.5" height="12" rx="1" />
|
||||
<rect x="9.5" y="2" width="3.5" height="12" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Resume button (when manually paused) -->
|
||||
<button
|
||||
v-else-if="isPausedManual"
|
||||
@click="resumeTimer"
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-accent-text transition-colors"
|
||||
aria-label="Resume"
|
||||
v-tooltip="'Resume'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3" aria-hidden="true">
|
||||
<path d="M4 2.5a.5.5 0 01.77-.42l9 5.5a.5.5 0 010 .84l-9 5.5A.5.5 0 014 13.5V2.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Stop button -->
|
||||
<button
|
||||
v-if="isActive"
|
||||
@click="stopTimer"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-full bg-status-error hover:bg-status-error/80 transition-colors"
|
||||
aria-label="Stop timer"
|
||||
v-tooltip="'Stop'"
|
||||
>
|
||||
<Square class="w-2.5 h-2.5 text-white" fill="white" />
|
||||
<Square class="w-2.5 h-2.5 text-white" fill="white" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="expandToMain"
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
aria-label="Expand to main window"
|
||||
v-tooltip="'Expand'"
|
||||
>
|
||||
<Maximize2 class="w-3 h-3" />
|
||||
<Maximize2 class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ miniAnnouncement }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
320
src/views/ProjectDetail.vue
Normal file
320
src/views/ProjectDetail.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- Back link + Header -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<router-link
|
||||
to="/projects"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
aria-label="Back to projects"
|
||||
v-tooltip="'Back to projects'"
|
||||
>
|
||||
<ArrowLeft class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
|
||||
</router-link>
|
||||
<div
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: project?.color || '#6B7280' }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary">
|
||||
{{ project?.name || 'Project' }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="project?.archived"
|
||||
class="text-[0.625rem] font-medium text-text-tertiary bg-bg-elevated px-2 py-0.5 rounded-full"
|
||||
>
|
||||
Archived
|
||||
</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
@click="$router.push({ path: '/projects', query: { edit: String(project?.id) } })"
|
||||
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Edit Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!project" class="flex flex-col items-center justify-center py-16">
|
||||
<FolderKanban class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
|
||||
<p class="text-sm text-text-secondary mt-4">Project not found</p>
|
||||
<router-link to="/projects" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
||||
Back to Projects
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Client</p>
|
||||
<p class="text-[0.9375rem] font-semibold text-text-primary">{{ clientName }}</p>
|
||||
</div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Rate</p>
|
||||
<p class="text-[0.9375rem] font-semibold text-text-primary">
|
||||
{{ project.budget_amount ? formatCurrency(project.budget_amount) + ' fixed' : formatCurrency(project.hourly_rate) + '/hr' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
||||
<p class="text-[0.9375rem] font-semibold text-text-primary">{{ formatNumber(totalHours, 1) }}h</p>
|
||||
</div>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Revenue</p>
|
||||
<p class="text-[0.9375rem] font-semibold text-text-primary">{{ formatCurrency(totalRevenue) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget Progress -->
|
||||
<div v-if="project.budget_hours && budgetData" class="bg-bg-surface border border-border-subtle rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-[0.8125rem] font-medium text-text-primary">Budget Progress</p>
|
||||
<span class="text-[0.75rem]" :class="budgetPct > 90 ? 'text-status-error-text' : budgetPct > 75 ? 'text-status-warning' : 'text-text-secondary'">
|
||||
{{ formatNumber(budgetData.hours_used, 1) }}h / {{ project.budget_hours }}h ({{ formatNumber(budgetPct, 0) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-bg-elevated rounded-full h-2" role="progressbar" :aria-valuenow="Math.round(budgetPct)" aria-valuemin="0" aria-valuemax="100" :aria-label="'Budget progress: ' + formatNumber(budgetPct, 0) + '%'">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-500"
|
||||
:class="budgetPct > 90 ? 'bg-status-error' : budgetPct > 75 ? 'bg-status-warning' : 'bg-accent'"
|
||||
:style="{ width: Math.min(budgetPct, 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="budgetData.hours_remaining !== null" class="text-[0.6875rem] text-text-tertiary mt-1.5">
|
||||
{{ formatNumber(budgetData.hours_remaining, 1) }}h remaining
|
||||
<template v-if="budgetData.pace"> - {{ budgetData.pace === 'ahead' ? 'ahead of schedule' : budgetData.pace === 'on_track' ? 'on track' : budgetData.pace === 'behind' ? 'behind schedule' : budgetData.pace }}</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Two-column: Tasks + Notes -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Tasks / Estimates vs Actuals -->
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Tasks</h2>
|
||||
<div v-if="taskActualsData.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="ta in taskActualsData"
|
||||
:key="ta.task_id"
|
||||
class="flex items-center gap-3 px-3 py-2 bg-bg-inset rounded-lg"
|
||||
>
|
||||
<span class="text-[0.75rem] text-text-primary truncate flex-1 min-w-0">{{ ta.task_name }}</span>
|
||||
<span class="text-[0.6875rem] font-mono text-accent-text shrink-0">{{ formatNumber(ta.actual_hours, 1) }}h</span>
|
||||
<template v-if="ta.estimated_hours">
|
||||
<span class="text-[0.6875rem] text-text-tertiary shrink-0">of {{ formatNumber(ta.estimated_hours, 1) }}h</span>
|
||||
<div class="w-16 bg-bg-elevated rounded-full h-1.5 shrink-0" role="progressbar" :aria-valuenow="Math.round(ta.progress || 0)" aria-valuemin="0" aria-valuemax="100">
|
||||
<div
|
||||
class="h-1.5 rounded-full"
|
||||
:class="(ta.progress || 0) > 100 ? 'bg-status-error' : 'bg-accent'"
|
||||
:style="{ width: Math.min(ta.progress || 0, 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<span
|
||||
v-if="ta.hourly_rate"
|
||||
class="text-[0.625rem] text-text-tertiary shrink-0"
|
||||
>{{ formatCurrency(ta.hourly_rate) }}/hr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="tasks.length > 0" class="space-y-1.5">
|
||||
<div v-for="task in tasks" :key="task.id" class="flex items-center gap-2 px-3 py-2 bg-bg-inset rounded-lg">
|
||||
<span class="text-[0.75rem] text-text-primary truncate flex-1">{{ task.name }}</span>
|
||||
<span v-if="task.estimated_hours" class="text-[0.625rem] text-text-tertiary">{{ task.estimated_hours }}h est</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-[0.75rem] text-text-tertiary">No tasks defined for this project.</p>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Notes</h2>
|
||||
<p v-if="project.notes" class="text-[0.75rem] text-text-secondary whitespace-pre-wrap leading-relaxed">{{ project.notes }}</p>
|
||||
<p v-else class="text-[0.75rem] text-text-tertiary">No notes for this project.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Entries -->
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary">Recent Entries</h2>
|
||||
<span class="text-[0.6875rem] text-text-tertiary">{{ projectEntries.length }} entries</span>
|
||||
</div>
|
||||
<div v-if="projectEntries.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle">
|
||||
<th class="px-3 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Date</th>
|
||||
<th class="px-3 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Task</th>
|
||||
<th class="px-3 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</th>
|
||||
<th class="px-3 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in projectEntries.slice(0, 50)"
|
||||
:key="entry.id"
|
||||
class="border-b border-border-subtle last:border-0 hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
<td class="px-3 py-2 text-[0.75rem] text-text-primary whitespace-nowrap">{{ formatDate(entry.start_time) }}</td>
|
||||
<td class="px-3 py-2 text-[0.75rem] text-text-secondary">{{ getTaskName(entry.task_id) || '-' }}</td>
|
||||
<td class="px-3 py-2 text-[0.75rem] text-text-secondary truncate max-w-[300px]">{{ entry.description || '-' }}</td>
|
||||
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text whitespace-nowrap">{{ formatDuration(entry.duration) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="text-[0.75rem] text-text-tertiary">No time entries for this project yet.</p>
|
||||
</div>
|
||||
|
||||
<!-- Tracked Apps -->
|
||||
<div v-if="trackedApps.length > 0" class="bg-bg-surface border border-border-subtle rounded-lg p-4">
|
||||
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Tracked Apps</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="app in trackedApps"
|
||||
:key="app.id"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-bg-inset rounded-lg"
|
||||
>
|
||||
<img v-if="app.icon" :src="app.icon" class="w-4 h-4 shrink-0" alt="" />
|
||||
<div v-else class="w-4 h-4 shrink-0 rounded bg-bg-elevated" />
|
||||
<span class="text-[0.75rem] text-text-primary">{{ app.display_name || app.exe_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ArrowLeft, FolderKanban } from 'lucide-vue-next'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useProjectsStore, type Project, type Task } from '../stores/projects'
|
||||
import { useClientsStore } from '../stores/clients'
|
||||
import { formatCurrency, formatNumber, formatDate } from '../utils/locale'
|
||||
|
||||
const route = useRoute()
|
||||
const projectsStore = useProjectsStore()
|
||||
const clientsStore = useClientsStore()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const projectEntries = ref<Array<{ id: number; task_id?: number; description?: string; start_time: string; duration: number; billable: number }>>([])
|
||||
const trackedApps = ref<Array<{ id: number; exe_name: string; exe_path?: string; display_name?: string; icon?: string }>>([])
|
||||
|
||||
interface TaskActual {
|
||||
task_id: number
|
||||
task_name: string
|
||||
estimated_hours: number | null
|
||||
hourly_rate: number | null
|
||||
actual_hours: number
|
||||
variance: number | null
|
||||
progress: number | null
|
||||
}
|
||||
|
||||
interface BudgetStatus {
|
||||
hours_used: number
|
||||
amount_used: number
|
||||
budget_hours: number | null
|
||||
budget_amount: number | null
|
||||
percent_hours: number | null
|
||||
percent_amount: number | null
|
||||
daily_average_hours: number
|
||||
estimated_completion_days: number | null
|
||||
hours_remaining: number | null
|
||||
pace: string | null
|
||||
}
|
||||
|
||||
const taskActualsData = ref<TaskActual[]>([])
|
||||
const budgetData = ref<BudgetStatus | null>(null)
|
||||
|
||||
const clientName = computed(() => {
|
||||
if (!project.value?.client_id) return 'No client'
|
||||
const client = clientsStore.clients.find(c => c.id === project.value!.client_id)
|
||||
return client?.name || 'Unknown client'
|
||||
})
|
||||
|
||||
const totalHours = computed(() => {
|
||||
return projectEntries.value.reduce((sum, e) => sum + e.duration, 0) / 3600
|
||||
})
|
||||
|
||||
const totalRevenue = computed(() => {
|
||||
if (!project.value) return 0
|
||||
if (project.value.budget_amount) return project.value.budget_amount
|
||||
return totalHours.value * project.value.hourly_rate
|
||||
})
|
||||
|
||||
const budgetPct = computed(() => {
|
||||
if (!project.value?.budget_hours || !budgetData.value) return 0
|
||||
return (budgetData.value.hours_used / project.value.budget_hours) * 100
|
||||
})
|
||||
|
||||
const taskMap = ref<Map<number, string>>(new Map())
|
||||
|
||||
function getTaskName(taskId?: number): string {
|
||||
if (!taskId) return ''
|
||||
return taskMap.value.get(taskId) || ''
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const projectId = Number(route.params.id)
|
||||
if (!projectId || isNaN(projectId)) return
|
||||
|
||||
await Promise.all([
|
||||
projectsStore.fetchProjects(),
|
||||
clientsStore.fetchClients(),
|
||||
])
|
||||
|
||||
project.value = projectsStore.projects.find(p => p.id === projectId) || null
|
||||
if (!project.value) return
|
||||
|
||||
// Load tasks
|
||||
tasks.value = await projectsStore.fetchTasks(projectId)
|
||||
const map = new Map<number, string>()
|
||||
for (const t of tasks.value) {
|
||||
if (t.id) map.set(t.id, t.name)
|
||||
}
|
||||
taskMap.value = map
|
||||
|
||||
// Load entries for this project
|
||||
try {
|
||||
const allEntries = await invoke<any[]>('get_time_entries', {})
|
||||
projectEntries.value = allEntries
|
||||
.filter((e: any) => e.project_id === projectId)
|
||||
.sort((a: any, b: any) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime())
|
||||
} catch {
|
||||
projectEntries.value = []
|
||||
}
|
||||
|
||||
// Load task actuals
|
||||
try {
|
||||
taskActualsData.value = await invoke<TaskActual[]>('get_task_actuals', { projectId })
|
||||
} catch {
|
||||
taskActualsData.value = []
|
||||
}
|
||||
|
||||
// Load budget status
|
||||
if (project.value.budget_hours) {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
budgetData.value = await invoke<BudgetStatus>('get_project_budget_status', { projectId, today })
|
||||
} catch {
|
||||
budgetData.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Load tracked apps
|
||||
try {
|
||||
trackedApps.value = await invoke<any[]>('get_tracked_apps', { projectId })
|
||||
} catch {
|
||||
trackedApps.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,12 @@
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
@click="exportPDF"
|
||||
class="px-4 py-2 border border-border-visible text-text-primary text-xs rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Buttons -->
|
||||
@@ -220,17 +226,25 @@
|
||||
<template v-if="activeTab === 'profitability'">
|
||||
<div id="tabpanel-profitability" role="tabpanel" aria-labelledby="tab-profitability" tabindex="0">
|
||||
<!-- Summary Stats -->
|
||||
<dl class="grid grid-cols-3 gap-6 mb-8">
|
||||
<dl class="grid grid-cols-3 lg:grid-cols-5 gap-6 mb-4">
|
||||
<div>
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Revenue</dt>
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Revenue</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitTotalRevenue) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ profitTotalHours.toFixed(1) }}h</dd>
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Expenses</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-status-error-text font-medium">{{ formatCurrency(profitTotalExpenses) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Avg Hourly Rate</dt>
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Net Profit</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] font-medium" :class="profitTotalNet >= 0 ? 'text-status-running' : 'text-status-error-text'">{{ formatCurrency(profitTotalNet) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Hours</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatNumber(profitTotalHours, 1) }}h</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Avg Rate</dt>
|
||||
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitAvgRate) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -255,13 +269,15 @@
|
||||
<div v-if="profitabilityData.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle">
|
||||
<th class="text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Project</th>
|
||||
<th class="text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Client</th>
|
||||
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Hours</th>
|
||||
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Rate</th>
|
||||
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Revenue</th>
|
||||
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2">Budget %</th>
|
||||
<tr class="border-b border-border-subtle bg-bg-surface">
|
||||
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Project</th>
|
||||
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Client</th>
|
||||
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Hours</th>
|
||||
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Rate</th>
|
||||
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Revenue</th>
|
||||
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Expenses</th>
|
||||
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Net Profit</th>
|
||||
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Budget %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -270,13 +286,15 @@
|
||||
:key="row.project_name"
|
||||
class="border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<td class="py-3 pr-4 text-[0.8125rem] text-text-primary">{{ row.project_name }}</td>
|
||||
<td class="py-3 pr-4 text-[0.75rem] text-text-secondary">{{ row.client_name || '-' }}</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-primary">{{ row.total_hours.toFixed(1) }}h</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency(row.hourly_rate) }}</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(row.revenue) }}</td>
|
||||
<td class="py-3 text-right text-[0.75rem] font-mono" :class="row.budget_used_pct != null && row.budget_used_pct > 100 ? 'text-status-error' : 'text-text-secondary'">
|
||||
{{ row.budget_used_pct != null ? row.budget_used_pct.toFixed(0) + '%' : '-' }}
|
||||
<td class="px-4 py-3 text-[0.8125rem] text-text-primary">{{ row.project_name }}</td>
|
||||
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">{{ row.client_name || '-' }}</td>
|
||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-text-primary">{{ formatNumber(row.total_hours, 1) }}h</td>
|
||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency(row.hourly_rate) }}</td>
|
||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(row.revenue) }}</td>
|
||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-text-secondary">{{ row.expenses > 0 ? formatCurrency(row.expenses) : '-' }}</td>
|
||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono" :class="row.net_profit >= 0 ? 'text-status-running' : 'text-status-error-text'">{{ formatCurrency(row.net_profit) }}</td>
|
||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono" :class="row.budget_used_pct != null && row.budget_used_pct > 100 ? 'text-status-error-text' : 'text-text-secondary'">
|
||||
{{ row.budget_used_pct != null ? formatNumber(row.budget_used_pct, 0) + '%' : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -334,12 +352,12 @@
|
||||
v-for="(value, hourIdx) in dayData"
|
||||
:key="'c-' + dayIdx + '-' + hourIdx"
|
||||
role="gridcell"
|
||||
:aria-label="dayNames[dayIdx] + ' ' + hourIdx + ':00 - ' + (value > 0 ? value.toFixed(1) + ' hours' : 'no activity')"
|
||||
:aria-label="dayNames[dayIdx] + ' ' + hourIdx + ':00 - ' + (value > 0 ? formatNumber(value, 1) + ' hours' : 'no activity')"
|
||||
class="p-1 text-center text-[0.5625rem] rounded-sm transition-colors"
|
||||
:style="{ backgroundColor: getHeatColor(value) }"
|
||||
:class="value > 0 ? 'text-text-primary' : 'text-transparent'"
|
||||
>
|
||||
{{ value > 0 ? value.toFixed(1) : '' }}
|
||||
{{ value > 0 ? formatNumber(value, 1) : '' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -359,7 +377,7 @@
|
||||
<th scope="row" class="px-2 py-1 text-left text-text-secondary">{{ dayNames[dayIdx] }}</th>
|
||||
<td v-for="(value, hourIdx) in dayData" :key="hourIdx" class="px-1 py-1 text-center"
|
||||
:class="value > 0 ? 'text-accent-text' : 'text-text-tertiary'">
|
||||
{{ value > 0 ? value.toFixed(1) : '-' }}
|
||||
{{ value > 0 ? formatNumber(value, 1) : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -368,13 +386,13 @@
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="patternsLoaded && !hasPatternData" class="py-12 text-center">
|
||||
<Grid3x3 class="w-10 h-10 text-text-tertiary mx-auto mb-3" :stroke-width="1.5" aria-hidden="true" />
|
||||
<Grid3x3 class="w-12 h-12 text-text-tertiary mx-auto animate-float mb-4" :stroke-width="1.5" aria-hidden="true" />
|
||||
<p class="text-[0.8125rem] text-text-secondary">No time data for the selected period</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state before patterns computed -->
|
||||
<div v-if="!patternsLoaded" class="py-12 text-center">
|
||||
<Grid3x3 class="w-10 h-10 text-text-tertiary mx-auto mb-3" :stroke-width="1.5" aria-hidden="true" />
|
||||
<Grid3x3 class="w-12 h-12 text-text-tertiary mx-auto animate-float mb-4" :stroke-width="1.5" aria-hidden="true" />
|
||||
<p class="text-[0.8125rem] text-text-secondary">Select a date range and click Generate to see patterns</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,7 +438,7 @@
|
||||
<span class="text-[0.8125rem] text-text-primary capitalize">{{ cat.category }}</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(cat.amount) }}</span>
|
||||
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ cat.percentage.toFixed(0) }}%</span>
|
||||
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ formatNumber(cat.percentage, 0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
|
||||
@@ -463,7 +481,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(proj.amount) }}</span>
|
||||
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ proj.percentage.toFixed(0) }}%</span>
|
||||
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ formatNumber(proj.percentage, 0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
|
||||
@@ -514,11 +532,22 @@ import { useEntriesStore } from '../stores/entries'
|
||||
import { useExpensesStore, type Expense } from '../stores/expenses'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { formatCurrency } from '../utils/locale'
|
||||
import { formatCurrency, formatNumber } from '../utils/locale'
|
||||
import { getChartTheme, buildBarChartOptions } from '../utils/chartTheme'
|
||||
import { useOnboardingStore } from '../stores/onboarding'
|
||||
import { useTourStore } from '../stores/tour'
|
||||
import { TOURS } from '../utils/tours'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import {
|
||||
generateHoursReportPdf,
|
||||
generateProfitabilityReportPdf,
|
||||
generateExpensesReportPdf,
|
||||
generatePatternsReportPdf,
|
||||
type HoursReportData,
|
||||
type ProfitabilityReportData,
|
||||
type ExpenseReportData,
|
||||
type PatternsReportData,
|
||||
} from '../utils/reportPdf'
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
@@ -550,6 +579,8 @@ interface ProfitabilityRow {
|
||||
revenue: number
|
||||
budget_hours: number | null
|
||||
budget_used_pct: number | null
|
||||
expenses: number
|
||||
net_profit: number
|
||||
}
|
||||
|
||||
const activeTab = ref<'hours' | 'profitability' | 'expenses' | 'patterns'>('hours')
|
||||
@@ -756,7 +787,7 @@ const chartOptions = computed(() => {
|
||||
callbacks: {
|
||||
label: (context: { raw: unknown }) => {
|
||||
const hours = context.raw as number
|
||||
return `${hours.toFixed(1)} hours`
|
||||
return `${formatNumber(hours, 1)} hours`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,6 +879,8 @@ async function fetchReport() {
|
||||
|
||||
// Profitability summary stats
|
||||
const profitTotalRevenue = computed(() => profitabilityData.value.reduce((sum, r) => sum + r.revenue, 0))
|
||||
const profitTotalExpenses = computed(() => profitabilityData.value.reduce((sum, r) => sum + (r.expenses || 0), 0))
|
||||
const profitTotalNet = computed(() => profitabilityData.value.reduce((sum, r) => sum + (r.net_profit ?? r.revenue), 0))
|
||||
const profitTotalHours = computed(() => profitabilityData.value.reduce((sum, r) => sum + r.total_hours, 0))
|
||||
const profitAvgRate = computed(() => profitTotalHours.value > 0 ? profitTotalRevenue.value / profitTotalHours.value : 0)
|
||||
|
||||
@@ -1105,6 +1138,97 @@ function exportCSV() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Export to PDF
|
||||
async function exportPDF() {
|
||||
try {
|
||||
let doc
|
||||
const dateRange = { start: startDate.value, end: endDate.value }
|
||||
|
||||
if (activeTab.value === 'hours') {
|
||||
if (!reportData.value.byProject || reportData.value.byProject.length === 0) {
|
||||
toastStore.info('No data to export')
|
||||
return
|
||||
}
|
||||
const data: HoursReportData = {
|
||||
dateRange,
|
||||
totalHours: filteredTotalSeconds.value / 3600,
|
||||
totalEarnings: filteredTotalEarnings.value,
|
||||
billableHours: billableSeconds.value / 3600,
|
||||
nonBillableHours: nonBillableSeconds.value / 3600,
|
||||
projects: filteredByProject.value.map((p: ProjectReport) => ({
|
||||
name: getProjectName(p.project_id),
|
||||
color: getProjectColor(p.project_id),
|
||||
hours: p.total_seconds / 3600,
|
||||
rate: getProjectRate(p.project_id),
|
||||
earnings: (p.total_seconds / 3600) * getProjectRate(p.project_id),
|
||||
percentage: getProjectPercentage(p.total_seconds),
|
||||
})),
|
||||
}
|
||||
doc = generateHoursReportPdf(data)
|
||||
} else if (activeTab.value === 'profitability') {
|
||||
if (profitabilityData.value.length === 0) {
|
||||
toastStore.info('No data to export')
|
||||
return
|
||||
}
|
||||
const data: ProfitabilityReportData = {
|
||||
dateRange,
|
||||
totalRevenue: profitTotalRevenue.value,
|
||||
totalExpenses: profitTotalExpenses.value,
|
||||
totalNet: profitTotalNet.value,
|
||||
totalHours: profitTotalHours.value,
|
||||
avgRate: profitAvgRate.value,
|
||||
rows: profitabilityData.value,
|
||||
}
|
||||
doc = generateProfitabilityReportPdf(data)
|
||||
} else if (activeTab.value === 'expenses') {
|
||||
if (expensesData.value.length === 0) {
|
||||
toastStore.info('No data to export')
|
||||
return
|
||||
}
|
||||
const data: ExpenseReportData = {
|
||||
dateRange,
|
||||
totalAmount: expTotalAmount.value,
|
||||
invoicedAmount: expInvoicedAmount.value,
|
||||
uninvoicedAmount: expUninvoicedAmount.value,
|
||||
byCategory: expByCategory.value,
|
||||
byProject: expByProject.value.map(p => ({
|
||||
name: getProjectName(p.project_id),
|
||||
color: getProjectColor(p.project_id),
|
||||
amount: p.amount,
|
||||
percentage: p.percentage,
|
||||
})),
|
||||
}
|
||||
doc = generateExpensesReportPdf(data)
|
||||
} else {
|
||||
if (!hasPatternData.value) {
|
||||
toastStore.info('No data to export')
|
||||
return
|
||||
}
|
||||
const data: PatternsReportData = {
|
||||
dateRange,
|
||||
heatmap: heatmapData.value,
|
||||
peakDay: peakDay.value || '-',
|
||||
peakHour: peakHour.value || '-',
|
||||
}
|
||||
doc = generatePatternsReportPdf(data)
|
||||
}
|
||||
|
||||
const tabName = activeTab.value
|
||||
const filePath = await save({
|
||||
defaultPath: `${tabName}-report-${startDate.value}-to-${endDate.value}.pdf`,
|
||||
filters: [{ name: 'PDF', extensions: ['pdf'] }],
|
||||
})
|
||||
if (!filePath) return
|
||||
|
||||
const pdfBytes = doc.output('arraybuffer')
|
||||
await invoke('save_binary_file', { path: filePath, data: Array.from(new Uint8Array(pdfBytes)) })
|
||||
toastStore.success('Report PDF exported')
|
||||
} catch (e) {
|
||||
console.error('Failed to export PDF:', e)
|
||||
toastStore.error('Failed to export PDF')
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate charts on theme or accent change
|
||||
watch(() => settingsStore.settings.theme_mode, () => {
|
||||
nextTick(() => { refreshChartTheme() })
|
||||
|
||||
@@ -32,85 +32,93 @@
|
||||
|
||||
<!-- Content pane -->
|
||||
<div class="flex-1 p-6 overflow-y-auto">
|
||||
<div class="max-w-2xl">
|
||||
<Transition name="fade" mode="out-in" :duration="{ enter: 200, leave: 150 }">
|
||||
<div :key="activeTab">
|
||||
<!-- General -->
|
||||
<div v-if="activeTab === 'general'" id="tabpanel-general" role="tabpanel" aria-labelledby="tab-general" tabindex="0">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">General</h2>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">UI Scale</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Adjust the interface zoom level</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="decreaseZoom"
|
||||
aria-label="Decrease zoom"
|
||||
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:disabled="zoomLevel <= 80"
|
||||
>
|
||||
<Minus class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="w-12 text-center text-sm font-mono text-text-primary" role="status" aria-live="polite">{{ zoomLevel }}%</span>
|
||||
<button
|
||||
@click="increaseZoom"
|
||||
aria-label="Increase zoom"
|
||||
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:disabled="zoomLevel >= 150"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-5">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Locale</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Date and number formatting</p>
|
||||
</div>
|
||||
<div class="w-56">
|
||||
<AppSelect
|
||||
v-model="locale"
|
||||
:options="LOCALES"
|
||||
label-key="name"
|
||||
value-key="code"
|
||||
placeholder="System Default"
|
||||
:placeholder-value="'system'"
|
||||
:searchable="true"
|
||||
@update:model-value="saveLocaleSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-5">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Currency</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Currency symbol and formatting</p>
|
||||
</div>
|
||||
<div class="w-56">
|
||||
<AppSelect
|
||||
v-model="currency"
|
||||
:options="currencyOptions"
|
||||
label-key="name"
|
||||
value-key="code"
|
||||
placeholder="US Dollar"
|
||||
:placeholder-value="'USD'"
|
||||
:searchable="true"
|
||||
@update:model-value="saveLocaleSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle mt-5 pt-5">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Theme</p>
|
||||
<p class="text-[0.8125rem] text-text-primary">UI Scale</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Adjust the interface zoom level</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-tooltip="'Decrease zoom'"
|
||||
@click="decreaseZoom"
|
||||
aria-label="Decrease zoom"
|
||||
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:disabled="zoomLevel <= 80"
|
||||
>
|
||||
<Minus class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="w-12 text-center text-sm font-mono text-text-primary" role="status" aria-live="polite">{{ zoomLevel }}%</span>
|
||||
<button
|
||||
v-tooltip="'Increase zoom'"
|
||||
@click="increaseZoom"
|
||||
aria-label="Increase zoom"
|
||||
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:disabled="zoomLevel >= 150"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p id="label-locale" class="text-[0.8125rem] text-text-primary">Locale</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Date and number formatting</p>
|
||||
</div>
|
||||
<div class="w-56">
|
||||
<AppSelect
|
||||
v-model="locale"
|
||||
aria-labelledby="label-locale"
|
||||
:options="LOCALES"
|
||||
label-key="name"
|
||||
value-key="code"
|
||||
placeholder="System Default"
|
||||
:placeholder-value="'system'"
|
||||
:searchable="true"
|
||||
@update:model-value="saveLocaleSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p id="label-currency" class="text-[0.8125rem] text-text-primary">Currency</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Currency symbol and formatting</p>
|
||||
</div>
|
||||
<div class="w-56">
|
||||
<AppSelect
|
||||
v-model="currency"
|
||||
aria-labelledby="label-currency"
|
||||
:options="currencyOptions"
|
||||
label-key="name"
|
||||
value-key="code"
|
||||
placeholder="US Dollar"
|
||||
:placeholder-value="'USD'"
|
||||
:searchable="true"
|
||||
@update:model-value="saveLocaleSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p id="label-theme" class="text-[0.8125rem] text-text-primary">Theme</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Light or dark appearance</p>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<div class="w-48">
|
||||
<AppSelect
|
||||
v-model="themeMode"
|
||||
aria-labelledby="label-theme"
|
||||
:options="themeModes"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@@ -121,7 +129,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Accent Color</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Primary interface color</p>
|
||||
@@ -139,17 +147,18 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle mt-5 pt-5">
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Reduce motion</p>
|
||||
<p id="label-reduce-motion" class="text-[0.8125rem] text-text-primary">Reduce motion</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Control animation behavior</p>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<div class="w-48">
|
||||
<AppSelect
|
||||
v-model="reduceMotion"
|
||||
aria-labelledby="label-reduce-motion"
|
||||
:options="reduceMotionOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@@ -159,9 +168,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle mt-5 pt-5">
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Dyslexia-friendly mode</p>
|
||||
@@ -186,14 +195,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">UI Font</p>
|
||||
<p id="label-ui-font" class="text-[0.8125rem] text-text-primary">UI Font</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Choose a font for the interface</p>
|
||||
</div>
|
||||
<div class="w-56">
|
||||
<AppSelect
|
||||
v-model="uiFont"
|
||||
aria-labelledby="label-ui-font"
|
||||
:options="UI_FONTS"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@@ -215,9 +225,9 @@
|
||||
</AppSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle mt-5 pt-5">
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Sound effects</p>
|
||||
@@ -242,15 +252,16 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="soundEnabled" class="space-y-5 mt-5">
|
||||
<div v-if="soundEnabled" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Sound mode</p>
|
||||
<p id="label-sound-mode" class="text-[0.8125rem] text-text-primary">Sound mode</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">How sounds are generated</p>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<AppSelect
|
||||
v-model="soundMode"
|
||||
aria-labelledby="label-sound-mode"
|
||||
:options="soundModes"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@@ -310,9 +321,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle mt-5 pt-5">
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Getting Started checklist</p>
|
||||
@@ -346,9 +357,61 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle mt-5 pt-5">
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Close to tray</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Hide in system tray instead of quitting when closing the window</p>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleCloseToTray"
|
||||
role="switch"
|
||||
:aria-checked="closeToTray"
|
||||
aria-label="Close to tray"
|
||||
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
||||
closeToTray ? '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',
|
||||
closeToTray ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Minimize to tray</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Hide in system tray instead of the taskbar when minimizing</p>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleMinimizeToTray"
|
||||
role="switch"
|
||||
:aria-checked="minimizeToTray"
|
||||
aria-label="Minimize to tray"
|
||||
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
||||
minimizeToTray ? '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',
|
||||
minimizeToTray ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Persistent notifications</p>
|
||||
@@ -380,7 +443,7 @@
|
||||
<div v-if="activeTab === 'timer'" id="tabpanel-timer" role="tabpanel" aria-labelledby="tab-timer" tabindex="0">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Timer</h2>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="space-y-4">
|
||||
<!-- Idle Detection toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -407,7 +470,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Idle sub-settings - progressive disclosure -->
|
||||
<div v-if="idleDetection" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1">
|
||||
<div v-if="idleDetection" class="space-y-4 pl-4 border-l-2 border-border-subtle ml-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Idle Timeout</p>
|
||||
@@ -446,12 +509,13 @@
|
||||
<!-- App Tracking Mode -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">App Tracking Mode</p>
|
||||
<p id="label-app-tracking" class="text-[0.8125rem] text-text-primary">App Tracking Mode</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">What happens when a tracked app leaves focus</p>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<AppSelect
|
||||
v-model="appTrackingMode"
|
||||
aria-labelledby="label-app-tracking"
|
||||
:options="appTrackingModes"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@@ -513,12 +577,13 @@
|
||||
<!-- Timer Font -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Timer Font</p>
|
||||
<p id="label-timer-font" class="text-[0.8125rem] text-text-primary">Timer Font</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Monospace font for timer display</p>
|
||||
</div>
|
||||
<div class="w-56">
|
||||
<AppSelect
|
||||
v-model="timerFont"
|
||||
aria-labelledby="label-timer-font"
|
||||
:options="timerFontOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@@ -623,6 +688,75 @@
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<!-- Reminders -->
|
||||
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Reminders</h3>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">End-of-day reminder</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Get a notification to log your time at the end of the day</p>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleEodReminder"
|
||||
role="switch"
|
||||
:aria-checked="eodReminderEnabled"
|
||||
aria-label="End-of-day reminder"
|
||||
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
||||
eodReminderEnabled ? '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',
|
||||
eodReminderEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="eodReminderEnabled" class="flex items-center justify-between pl-4 border-l-2 border-border-subtle ml-1">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Reminder time</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">When to show the reminder</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="eodReminderTime"
|
||||
type="time"
|
||||
class="px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
||||
@change="saveReminderSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Weekly summary</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Show a summary of last week's hours on Monday morning</p>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleWeeklySummary"
|
||||
role="switch"
|
||||
:aria-checked="weeklySummaryEnabled"
|
||||
aria-label="Weekly summary"
|
||||
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
||||
weeklySummaryEnabled ? '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',
|
||||
weeklySummaryEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<!-- Recurring Entries -->
|
||||
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Recurring Entries</h3>
|
||||
|
||||
@@ -737,10 +871,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Time of Day</label>
|
||||
<input
|
||||
v-model="recTimeOfDay"
|
||||
type="time"
|
||||
class="w-full px-3 py-2 bg-bg-base border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
||||
<AppTimePicker
|
||||
v-model:hour="recTimeHour"
|
||||
v-model:minute="recTimeMinute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -847,89 +980,88 @@
|
||||
<div v-if="activeTab === 'billing'" id="tabpanel-billing" role="tabpanel" aria-labelledby="tab-billing" tabindex="0">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Billing</h2>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Default Hourly Rate</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Applied to new projects</p>
|
||||
</div>
|
||||
<AppNumberInput
|
||||
v-model="hourlyRate"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:precision="2"
|
||||
:prefix="getCurrencySymbol()"
|
||||
@update:model-value="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-border-subtle mt-5 pt-5" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Time Rounding</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round time entries for billing</p>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleRounding"
|
||||
role="switch"
|
||||
:aria-checked="roundingEnabled"
|
||||
aria-label="Time rounding"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
||||
roundingEnabled ? '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',
|
||||
roundingEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
]"
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Default Hourly Rate</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Applied to new projects</p>
|
||||
</div>
|
||||
<AppNumberInput
|
||||
v-model="hourlyRate"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:precision="2"
|
||||
:prefix="getCurrencySymbol()"
|
||||
@update:model-value="saveSettings"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<div v-if="roundingEnabled" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1 mt-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Increment</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round to nearest increment</p>
|
||||
<p class="text-[0.8125rem] text-text-primary">Time Rounding</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round time entries for billing</p>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<AppSelect
|
||||
v-model="roundingIncrement"
|
||||
:options="roundingIncrements"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@update:model-value="saveRoundingSettings"
|
||||
<button
|
||||
@click="toggleRounding"
|
||||
role="switch"
|
||||
:aria-checked="roundingEnabled"
|
||||
aria-label="Time rounding"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
||||
roundingEnabled ? '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',
|
||||
roundingEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="roundingEnabled" class="space-y-4 pl-4 border-l-2 border-border-subtle ml-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Increment</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round to nearest increment</p>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<AppSelect
|
||||
v-model="roundingIncrement"
|
||||
:options="roundingIncrements"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@update:model-value="saveRoundingSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Method</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Rounding direction</p>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<AppSelect
|
||||
v-model="roundingMethod"
|
||||
:options="roundingMethods"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@update:model-value="saveRoundingSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Method</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Rounding direction</p>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<AppSelect
|
||||
v-model="roundingMethod"
|
||||
:options="roundingMethods"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
@update:model-value="saveRoundingSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-border-subtle mt-5 pt-5" />
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<!-- Business Identity -->
|
||||
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-4">Business Identity</h3>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mb-4">This information appears on your invoices.</p>
|
||||
<!-- Business Identity -->
|
||||
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Business Identity</h3>
|
||||
<p class="text-[0.6875rem] text-text-tertiary">This information appears on your invoices.</p>
|
||||
|
||||
<div class="space-y-4 max-w-md">
|
||||
<div class="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label for="biz-name" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Business Name</label>
|
||||
<input
|
||||
@@ -995,7 +1127,7 @@
|
||||
<button
|
||||
v-if="businessLogo"
|
||||
@click="removeLogo"
|
||||
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-status-error rounded-lg hover:bg-status-error/10 transition-colors"
|
||||
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-status-error-text rounded-lg hover:bg-status-error/10 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
@@ -1003,6 +1135,7 @@
|
||||
</div>
|
||||
<p class="text-[0.625rem] text-text-tertiary mt-1">PNG or JPG, max 200x80px. Appears on invoice header.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1183,7 +1316,7 @@
|
||||
<div v-if="activeTab === 'data'" id="tabpanel-data" role="tabpanel" aria-labelledby="tab-data" tabindex="0">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Data</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<!-- Export -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -1235,11 +1368,44 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Backup frequency and retention -->
|
||||
<div v-if="autoBackupEnabled" class="pl-4 border-l-2 border-border-subtle ml-1 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Frequency</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">How often to run backups</p>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<AppSelect
|
||||
v-model="backupFrequency"
|
||||
:options="[{id:'daily',name:'Daily'},{id:'weekly',name:'Weekly'}]"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
@update:model-value="saveBackupFrequency"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Retention</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Keep last N backups</p>
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<AppNumberInput v-model="backupRetention" :min="1" :max="30" @update:model-value="saveBackupRetention" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border-subtle" />
|
||||
|
||||
<!-- Import Data -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-[0.8125rem] font-medium text-text-primary mb-4">Import Data</h3>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-[0.8125rem] font-medium text-text-primary">Import Data</h3>
|
||||
<button @click="showJsonImportWizard = true" class="px-3 py-1.5 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors">
|
||||
Restore from Backup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-3 mb-4">
|
||||
<div class="w-48">
|
||||
@@ -1288,31 +1454,47 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="importStatus" class="text-[0.75rem] mt-2" :class="importStatus.startsWith('Error') ? 'text-status-error' : 'text-status-running'">
|
||||
<p v-if="importStatus" class="text-[0.75rem] mt-2" :class="importStatus.startsWith('Error') ? 'text-status-error-text' : 'text-status-running'">
|
||||
{{ importStatus }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="mt-8 rounded-xl border border-status-error/20 p-5">
|
||||
<h3 class="text-xs text-status-error uppercase tracking-[0.08em] font-medium mb-4">Danger Zone</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Clear All Data</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Permanently delete all entries, projects, clients, and invoices</p>
|
||||
<div class="rounded-xl border border-status-error/20 p-5">
|
||||
<h3 class="text-xs text-status-error-text uppercase tracking-[0.08em] font-medium mb-4">Danger Zone</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Load Sample Data</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Populate with demo data for screenshots (clears existing data first)</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showSeedDialog = true"
|
||||
:disabled="isSeedingData"
|
||||
class="px-4 py-1.5 border border-status-warning text-status-warning text-[0.8125rem] font-medium rounded-lg hover:bg-status-warning/10 transition-colors duration-150 disabled:opacity-40"
|
||||
>
|
||||
{{ isSeedingData ? 'Loading...' : 'Load Demo' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[0.8125rem] text-text-primary">Clear All Data</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Permanently delete all entries, projects, clients, and invoices</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showClearDataDialog = true"
|
||||
class="px-4 py-1.5 border border-status-error text-status-error-text text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Clear Data
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="showClearDataDialog = true"
|
||||
class="px-4 py-1.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Clear Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Data Confirmation Dialog -->
|
||||
@@ -1327,7 +1509,7 @@
|
||||
<p id="clear-data-desc" class="text-[0.75rem] text-text-secondary mb-4">
|
||||
Are you sure? This action cannot be undone.
|
||||
</p>
|
||||
<p class="text-[0.6875rem] text-status-error mb-6">
|
||||
<p class="text-[0.6875rem] text-status-error-text mb-6">
|
||||
All time entries, projects, clients, and invoices will be permanently deleted.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
@@ -1347,6 +1529,45 @@
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Seed Data Confirmation Dialog -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showSeedDialog"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
@click.self="showSeedDialog = false"
|
||||
>
|
||||
<div role="alertdialog" aria-modal="true" aria-labelledby="seed-data-title" aria-describedby="seed-data-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||
<h2 id="seed-data-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Load Sample Data</h2>
|
||||
<p id="seed-data-desc" class="text-[0.75rem] text-text-secondary mb-4">
|
||||
This will clear all existing data and replace it with demo content.
|
||||
</p>
|
||||
<p class="text-[0.6875rem] text-status-warning mb-6">
|
||||
All current entries, projects, clients, and invoices will be replaced.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="showSeedDialog = false"
|
||||
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="seedSampleData"
|
||||
class="px-4 py-2 bg-status-warning text-white font-medium rounded-lg hover:bg-status-warning/80 transition-colors duration-150"
|
||||
>
|
||||
Load Demo Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<JsonImportWizard
|
||||
:show="showJsonImportWizard"
|
||||
@close="showJsonImportWizard = false"
|
||||
@imported="showJsonImportWizard = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1363,13 +1584,16 @@ import { useEntryTemplatesStore } from '../stores/entryTemplates'
|
||||
import AppNumberInput from '../components/AppNumberInput.vue'
|
||||
import AppSelect from '../components/AppSelect.vue'
|
||||
import AppShortcutRecorder from '../components/AppShortcutRecorder.vue'
|
||||
import AppTimePicker from '../components/AppTimePicker.vue'
|
||||
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
|
||||
import { parseCSV, mapTogglCSV, mapGenericCSV, type ImportEntry } from '../utils/import'
|
||||
import { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts'
|
||||
import { UI_FONTS, loadUIFont } from '../utils/uiFonts'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
import JsonImportWizard from '../components/JsonImportWizard.vue'
|
||||
import { audioEngine, SOUND_EVENTS, DEFAULT_EVENTS } from '../utils/audio'
|
||||
import type { SoundEvent } from '../utils/audio'
|
||||
import { resetPositionCache } from '../utils/dropdown'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const toastStore = useToastStore()
|
||||
@@ -1389,6 +1613,8 @@ const recPattern = ref('daily')
|
||||
const recWeeklyDays = ref<string[]>([])
|
||||
const recMonthlyDay = ref(1)
|
||||
const recTimeOfDay = ref('09:00')
|
||||
const recTimeHour = ref(9)
|
||||
const recTimeMinute = ref(0)
|
||||
const recMode = ref('prompt')
|
||||
const recProjects = ref<Array<{ id: number; name: string; color: string }>>([])
|
||||
const recTasks = ref<Array<{ id: number; name: string }>>([])
|
||||
@@ -1473,6 +1699,10 @@ const reduceMotion = ref('system')
|
||||
const dyslexiaMode = ref(false)
|
||||
const uiFont = ref('Inter')
|
||||
|
||||
// Tray behavior settings
|
||||
const closeToTray = ref(false)
|
||||
const minimizeToTray = ref(false)
|
||||
|
||||
// Notification settings
|
||||
const persistentNotifications = ref(false)
|
||||
|
||||
@@ -1493,6 +1723,27 @@ const soundModes = [
|
||||
const dailyGoalHours = ref(8)
|
||||
const weeklyGoalHours = ref(40)
|
||||
|
||||
// Reminder settings
|
||||
const eodReminderEnabled = ref(false)
|
||||
const eodReminderTime = ref('17:00')
|
||||
const weeklySummaryEnabled = ref(false)
|
||||
|
||||
async function toggleEodReminder() {
|
||||
eodReminderEnabled.value = !eodReminderEnabled.value
|
||||
await saveReminderSettings()
|
||||
}
|
||||
|
||||
async function toggleWeeklySummary() {
|
||||
weeklySummaryEnabled.value = !weeklySummaryEnabled.value
|
||||
await saveReminderSettings()
|
||||
}
|
||||
|
||||
async function saveReminderSettings() {
|
||||
await settingsStore.updateSetting('eod_reminder_enabled', eodReminderEnabled.value ? 'true' : 'false')
|
||||
await settingsStore.updateSetting('eod_reminder_time', eodReminderTime.value)
|
||||
await settingsStore.updateSetting('weekly_summary_enabled', weeklySummaryEnabled.value ? 'true' : 'false')
|
||||
}
|
||||
|
||||
// Rounding settings
|
||||
const roundingEnabled = ref(false)
|
||||
const roundingIncrement = ref(15)
|
||||
@@ -1714,6 +1965,9 @@ const showClearDataDialog = ref(false)
|
||||
const clearDialogRef = ref<HTMLElement | null>(null)
|
||||
const { activate: activateClearTrap, deactivate: deactivateClearTrap } = useFocusTrap()
|
||||
|
||||
const showSeedDialog = ref(false)
|
||||
const isSeedingData = ref(false)
|
||||
|
||||
watch(showClearDataDialog, (val) => {
|
||||
if (val) {
|
||||
setTimeout(() => {
|
||||
@@ -1724,10 +1978,23 @@ watch(showClearDataDialog, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
// JSON import wizard
|
||||
const showJsonImportWizard = ref(false)
|
||||
|
||||
// Auto-backup state
|
||||
const autoBackupEnabled = ref(false)
|
||||
const backupPath = ref('')
|
||||
const lastExported = ref('')
|
||||
const backupFrequency = ref('daily')
|
||||
const backupRetention = ref(7)
|
||||
|
||||
async function saveBackupFrequency(val: string) {
|
||||
await settingsStore.updateSetting('auto_backup_frequency', val)
|
||||
}
|
||||
|
||||
async function saveBackupRetention(val: number) {
|
||||
await settingsStore.updateSetting('auto_backup_retention', String(val))
|
||||
}
|
||||
|
||||
const lastExportedFormatted = computed(() => {
|
||||
if (!lastExported.value) return ''
|
||||
@@ -1797,6 +2064,7 @@ function applyZoom() {
|
||||
if (app) {
|
||||
(app.style as any).zoom = `${zoomLevel.value}%`
|
||||
}
|
||||
resetPositionCache()
|
||||
settingsStore.updateSetting('ui_zoom', zoomLevel.value.toString())
|
||||
}
|
||||
|
||||
@@ -1881,6 +2149,18 @@ async function togglePersistentNotifications() {
|
||||
await settingsStore.updateSetting('persistent_notifications', persistentNotifications.value ? 'true' : 'false')
|
||||
}
|
||||
|
||||
// Toggle close to tray
|
||||
async function toggleCloseToTray() {
|
||||
closeToTray.value = !closeToTray.value
|
||||
await settingsStore.updateSetting('close_to_tray', closeToTray.value ? 'true' : 'false')
|
||||
}
|
||||
|
||||
// Toggle minimize to tray
|
||||
async function toggleMinimizeToTray() {
|
||||
minimizeToTray.value = !minimizeToTray.value
|
||||
await settingsStore.updateSetting('minimize_to_tray', minimizeToTray.value ? 'true' : 'false')
|
||||
}
|
||||
|
||||
// Save sound settings
|
||||
async function saveSoundSettings() {
|
||||
await settingsStore.updateSetting('sound_mode', soundMode.value)
|
||||
@@ -1904,6 +2184,11 @@ async function saveGoalSettings() {
|
||||
await settingsStore.updateSetting('weekly_goal_hours', weeklyGoalHours.value.toString())
|
||||
}
|
||||
|
||||
// Sync time picker hour/minute with recTimeOfDay string
|
||||
watch([recTimeHour, recTimeMinute], ([h, m]) => {
|
||||
recTimeOfDay.value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')
|
||||
})
|
||||
|
||||
// Watch recurring project to fetch tasks
|
||||
watch(recProjectId, async (pid) => {
|
||||
if (pid) {
|
||||
@@ -1993,6 +2278,8 @@ function resetRecurringForm() {
|
||||
recWeeklyDays.value = []
|
||||
recMonthlyDay.value = 1
|
||||
recTimeOfDay.value = '09:00'
|
||||
recTimeHour.value = 9
|
||||
recTimeMinute.value = 0
|
||||
recMode.value = 'prompt'
|
||||
}
|
||||
|
||||
@@ -2003,6 +2290,9 @@ function editRecurringEntry(rec: RecurringEntry) {
|
||||
recDescription.value = rec.description || ''
|
||||
recDurationMinutes.value = Math.round(rec.duration / 60)
|
||||
recTimeOfDay.value = rec.time_of_day
|
||||
const [h, m] = rec.time_of_day.split(':').map(Number)
|
||||
recTimeHour.value = h || 0
|
||||
recTimeMinute.value = m || 0
|
||||
recMode.value = rec.mode
|
||||
|
||||
// Parse recurrence rule
|
||||
@@ -2192,6 +2482,21 @@ async function clearAllData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function seedSampleData() {
|
||||
isSeedingData.value = true
|
||||
showSeedDialog.value = false
|
||||
try {
|
||||
await invoke('seed_sample_data')
|
||||
toastStore.success('Sample data loaded successfully')
|
||||
setTimeout(() => { window.location.reload() }, 500)
|
||||
} catch (error) {
|
||||
console.error('Failed to load sample data:', error)
|
||||
toastStore.error('Failed to load sample data')
|
||||
} finally {
|
||||
isSeedingData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load settings on mount
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchSettings()
|
||||
@@ -2212,6 +2517,9 @@ onMounted(async () => {
|
||||
shortcutQuickEntry.value = settingsStore.settings.shortcut_quick_entry || 'CmdOrCtrl+Shift+N'
|
||||
dailyGoalHours.value = parseFloat(settingsStore.settings.daily_goal_hours) || 8
|
||||
weeklyGoalHours.value = parseFloat(settingsStore.settings.weekly_goal_hours) || 40
|
||||
eodReminderEnabled.value = settingsStore.settings.eod_reminder_enabled === 'true'
|
||||
eodReminderTime.value = settingsStore.settings.eod_reminder_time || '17:00'
|
||||
weeklySummaryEnabled.value = settingsStore.settings.weekly_summary_enabled === 'true'
|
||||
roundingEnabled.value = settingsStore.settings.rounding_enabled === 'true'
|
||||
roundingIncrement.value = parseInt(settingsStore.settings.rounding_increment) || 15
|
||||
roundingMethod.value = settingsStore.settings.rounding_method || 'nearest'
|
||||
@@ -2226,6 +2534,10 @@ onMounted(async () => {
|
||||
dyslexiaMode.value = settingsStore.settings.dyslexia_mode === 'true'
|
||||
uiFont.value = settingsStore.settings.ui_font || 'Inter'
|
||||
|
||||
// Tray behavior settings
|
||||
closeToTray.value = settingsStore.settings.close_to_tray === 'true'
|
||||
minimizeToTray.value = settingsStore.settings.minimize_to_tray === 'true'
|
||||
|
||||
// Notification settings
|
||||
persistentNotifications.value = settingsStore.settings.persistent_notifications === 'true'
|
||||
|
||||
@@ -2233,6 +2545,8 @@ onMounted(async () => {
|
||||
autoBackupEnabled.value = settingsStore.settings.auto_backup === 'true'
|
||||
backupPath.value = settingsStore.settings.backup_path || ''
|
||||
lastExported.value = settingsStore.settings.last_exported || ''
|
||||
backupFrequency.value = settingsStore.settings.auto_backup_frequency || 'daily'
|
||||
backupRetention.value = parseInt(settingsStore.settings.auto_backup_retention || '7')
|
||||
|
||||
// Sound settings
|
||||
soundEnabled.value = settingsStore.settings.sound_enabled === 'true'
|
||||
|
||||
@@ -1,140 +1,354 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- Hero timer display -->
|
||||
<div class="text-center pt-4 pb-8">
|
||||
<div class="relative inline-block">
|
||||
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2 transition-colors duration-300" :class="[timerPulseClass, timerStore.isPaused ? 'opacity-60' : '']">
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.hours }}</span>
|
||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.minutes }}</span>
|
||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.seconds }}</span>
|
||||
</p>
|
||||
<!-- Paused badge - absolutely positioned so it doesn't shift layout -->
|
||||
<span
|
||||
v-if="timerStore.isPaused"
|
||||
class="absolute -bottom-1 left-1/2 -translate-x-1/2 text-[0.625rem] font-semibold uppercase tracking-[0.15em] px-2.5 py-0.5 rounded-full transition-opacity duration-200"
|
||||
:class="timerStore.timerState === 'PAUSED_IDLE' ? 'text-status-warning bg-status-warning/10' : timerStore.timerState === 'PAUSED_MANUAL' ? 'text-status-warning bg-status-warning/10' : 'text-status-info bg-status-info/10'"
|
||||
>
|
||||
{{ timerStore.timerState === 'PAUSED_IDLE' ? 'Idle' : timerStore.timerState === 'PAUSED_MANUAL' ? 'Paused' : 'App hidden' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!timerStore.isStopped"
|
||||
@click="openMiniTimer"
|
||||
class="absolute -right-8 top-1/2 -translate-y-1/2 p-2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
title="Pop out mini timer"
|
||||
>
|
||||
<ExternalLink class="w-4 h-4" :stroke-width="1.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 grid grid-cols-1 lg:grid-cols-[1fr_280px] lg:gap-8 lg:items-start">
|
||||
<!-- Left column -->
|
||||
<div class="w-full max-w-[38rem] mx-auto">
|
||||
<h1 class="sr-only">Timer</h1>
|
||||
<!-- Sticky timer hero -->
|
||||
<div class="sticky top-0 z-10 bg-bg-base pb-2">
|
||||
<div class="text-center pt-4 pb-8">
|
||||
<div class="relative inline-block">
|
||||
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-timer)] tracking-tighter mb-2 transition-colors duration-300" :class="[timerPulseClass, timerStore.isPaused ? 'opacity-60' : '']" role="timer" aria-live="off" :aria-label="'Elapsed time: ' + timerStore.formattedTime">
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.hours }}</span>
|
||||
<span aria-hidden="true" :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.minutes }}</span>
|
||||
<span aria-hidden="true" :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.seconds }}</span>
|
||||
</p>
|
||||
<!-- Paused badge - absolutely positioned so it doesn't shift layout -->
|
||||
<span
|
||||
v-if="timerStore.isPaused"
|
||||
role="status"
|
||||
class="absolute -bottom-1 left-1/2 -translate-x-1/2 text-[0.625rem] font-semibold uppercase tracking-[0.15em] px-2.5 py-0.5 rounded-full transition-opacity duration-200"
|
||||
:class="timerStore.timerState === 'PAUSED_IDLE' ? 'text-status-warning bg-status-warning/10' : timerStore.timerState === 'PAUSED_MANUAL' ? 'text-status-warning bg-status-warning/10' : 'text-status-info bg-status-info/10'"
|
||||
>
|
||||
{{ timerStore.timerState === 'PAUSED_IDLE' ? 'Idle' : timerStore.timerState === 'PAUSED_MANUAL' ? 'Paused' : 'App hidden' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!timerStore.isStopped"
|
||||
@click="openMiniTimer"
|
||||
class="absolute -right-8 top-1/2 -translate-y-1/2 p-2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
aria-label="Pop out mini timer"
|
||||
v-tooltip="'Pop out mini timer'"
|
||||
>
|
||||
<ExternalLink class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-6" />
|
||||
<div class="h-6" />
|
||||
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<!-- Pause / Resume button (visible when timer is active) -->
|
||||
<button
|
||||
v-if="timerStore.isRunning"
|
||||
@click="timerStore.pauseManual()"
|
||||
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-status-warning/15 text-status-warning hover:bg-status-warning/25"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
|
||||
@click="timerStore.resumeFromPause()"
|
||||
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-accent text-bg-base hover:bg-accent-hover"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<!-- Pause / Resume button (visible when timer is active) -->
|
||||
<button
|
||||
v-if="timerStore.isRunning"
|
||||
@click="timerStore.pauseManual()"
|
||||
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-status-warning/15 text-status-warning hover:bg-status-warning/25"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
|
||||
@click="timerStore.resumeFromPause()"
|
||||
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-accent text-bg-base hover:bg-accent-hover"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
|
||||
<!-- Start / Stop button -->
|
||||
<button
|
||||
@click="toggleTimer"
|
||||
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
|
||||
:class="buttonClass"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick-switch button -->
|
||||
<button
|
||||
v-if="!timerStore.isStopped"
|
||||
@click="showSwitchPicker = !showSwitchPicker"
|
||||
class="px-4 py-3 text-sm font-medium rounded-lg transition-colors duration-150 border border-border-subtle text-text-secondary hover:text-text-primary hover:bg-bg-elevated"
|
||||
aria-label="Switch project"
|
||||
v-tooltip="'Switch project'"
|
||||
>
|
||||
<ArrowLeftRight class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<!-- Favorites strip -->
|
||||
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">
|
||||
<TransitionGroup tag="div" name="chip" class="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<button
|
||||
v-for="(fav, favIndex) in favorites"
|
||||
:key="fav.id"
|
||||
@click="applyFavorite(fav)"
|
||||
:disabled="!timerStore.isStopped"
|
||||
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors disabled:opacity-40"
|
||||
:style="{ transitionDelay: `${favIndex * 50}ms` }"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: getProjectColor(fav.project_id) }" />
|
||||
{{ getProjectName(fav.project_id) }}
|
||||
<span v-if="fav.description" class="text-text-tertiary">· {{ fav.description }}</span>
|
||||
</button>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Inputs -->
|
||||
<div class="max-w-[36rem] mx-auto mb-8">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project</label>
|
||||
<AppSelect
|
||||
v-model="selectedProject"
|
||||
:options="activeProjects"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Select project"
|
||||
:placeholder-value="null"
|
||||
:disabled="!timerStore.isStopped"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Task</label>
|
||||
<AppSelect
|
||||
v-model="selectedTask"
|
||||
:options="projectTasks"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Select task"
|
||||
:placeholder-value="null"
|
||||
:disabled="!timerStore.isStopped || !selectedProject"
|
||||
/>
|
||||
<!-- Start / Stop button -->
|
||||
<button
|
||||
data-tour-id="timer-start"
|
||||
@click="toggleTimer"
|
||||
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
|
||||
:class="buttonClass"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Quick-switch project picker -->
|
||||
<div v-if="showSwitchPicker" class="mt-3 max-w-xs mx-auto">
|
||||
<AppSelect
|
||||
:model-value="null"
|
||||
:options="projectsStore.activeProjects"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Switch to project..."
|
||||
searchable
|
||||
@update:model-value="switchToProject($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="description"
|
||||
type="text"
|
||||
:disabled="!timerStore.isStopped"
|
||||
class="flex-1 px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
placeholder="What are you working on?"
|
||||
/>
|
||||
<button
|
||||
v-if="timerStore.isStopped && selectedProject"
|
||||
@click="saveAsFavorite"
|
||||
class="p-2 text-text-tertiary hover:text-accent-text transition-colors"
|
||||
title="Save as favorite"
|
||||
|
||||
<!-- Favorites strip -->
|
||||
<div v-if="favorites.length > 0" class="mb-4">
|
||||
<div class="flex flex-wrap items-center gap-y-2" @pointerleave="onFavDragCancel">
|
||||
<template v-for="(fav, favIndex) in favorites" :key="fav.id">
|
||||
<!-- Drop indicator / spacer before each chip -->
|
||||
<div
|
||||
class="transition-all duration-150 self-stretch flex items-center justify-center"
|
||||
:class="dragFavIndex !== null && dropTargetIndex === favIndex && dragFavIndex !== favIndex
|
||||
? 'w-3'
|
||||
: 'w-1.5'"
|
||||
@pointerenter="onFavDragOver(favIndex)"
|
||||
@pointerup="onFavDragEnd"
|
||||
>
|
||||
<div
|
||||
class="w-0.5 h-5 rounded-full transition-opacity duration-150"
|
||||
:class="dragFavIndex !== null && dropTargetIndex === favIndex && dragFavIndex !== favIndex
|
||||
? 'bg-accent opacity-100 shadow-[0_0_6px_var(--color-accent)]'
|
||||
: 'opacity-0'"
|
||||
/>
|
||||
</div>
|
||||
<!-- Chip -->
|
||||
<div
|
||||
class="flex items-center rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors"
|
||||
:class="[
|
||||
dragFavIndex === favIndex ? 'opacity-40' : '',
|
||||
dragFavIndex !== null && dragFavIndex !== favIndex ? 'cursor-grabbing' : 'cursor-grab'
|
||||
]"
|
||||
@pointerdown.prevent="onFavDragStart(favIndex)"
|
||||
@pointerenter="onFavDragOver(favIndex)"
|
||||
@pointerup="onFavDragEnd"
|
||||
@keydown.ctrl.left.prevent="moveFavorite(favIndex, -1)"
|
||||
@keydown.ctrl.right.prevent="moveFavorite(favIndex, 1)"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 pl-2.5 py-1.5 select-none">
|
||||
<span class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: getProjectColor(fav.project_id) }" aria-hidden="true" />
|
||||
{{ getProjectName(fav.project_id) }}
|
||||
<span v-if="fav.description" class="text-text-tertiary">· {{ fav.description }}</span>
|
||||
</span>
|
||||
<button
|
||||
@click.stop="applyFavorite(fav)"
|
||||
:disabled="!timerStore.isStopped"
|
||||
class="flex items-center justify-center w-6 h-6 rounded-full text-text-tertiary hover:text-accent disabled:opacity-40 cursor-pointer"
|
||||
aria-label="Load fields"
|
||||
v-tooltip="'Load fields'"
|
||||
>
|
||||
<ArrowDownToLine class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click.stop="quickStartFavorite(fav)"
|
||||
:disabled="!timerStore.isStopped"
|
||||
class="flex items-center justify-center w-6 h-6 rounded-full text-accent hover:text-accent-hover disabled:opacity-40 cursor-pointer"
|
||||
aria-label="Start timer"
|
||||
v-tooltip="'Start timer'"
|
||||
>
|
||||
<Play class="w-3 h-3" :stroke-width="2" fill="currentColor" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click.stop="removeFavorite(fav.id!)"
|
||||
class="flex items-center justify-center w-5 h-5 mr-1 rounded-full text-text-tertiary hover:text-status-error cursor-pointer"
|
||||
aria-label="Delete favorite"
|
||||
v-tooltip="'Delete favorite'"
|
||||
>
|
||||
<X class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Drop indicator after last chip -->
|
||||
<div
|
||||
class="transition-all duration-150 self-stretch flex items-center justify-center"
|
||||
:class="dragFavIndex !== null && dropTargetIndex === favorites.length
|
||||
? 'w-3'
|
||||
: 'w-0'"
|
||||
@pointerenter="onFavDragOver(favorites.length)"
|
||||
@pointerup="onFavDragEnd"
|
||||
>
|
||||
<Star class="w-4 h-4" :stroke-width="1.5" />
|
||||
</button>
|
||||
<div
|
||||
class="w-0.5 h-5 rounded-full transition-opacity duration-150"
|
||||
:class="dragFavIndex !== null && dropTargetIndex === favorites.length
|
||||
? 'bg-accent opacity-100 shadow-[0_0_6px_var(--color-accent)]'
|
||||
: 'opacity-0'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tags</label>
|
||||
<AppTagInput v-model="selectedTags" />
|
||||
|
||||
<!-- Inputs -->
|
||||
<div class="mb-8">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div data-tour-id="timer-project">
|
||||
<label id="label-timer-project" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project</label>
|
||||
<AppSelect
|
||||
v-model="selectedProject"
|
||||
aria-labelledby="label-timer-project"
|
||||
:options="activeProjects"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Select project"
|
||||
:placeholder-value="null"
|
||||
:disabled="!timerStore.isStopped"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label id="label-timer-task" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Task</label>
|
||||
<AppSelect
|
||||
v-model="selectedTask"
|
||||
aria-labelledby="label-timer-task"
|
||||
:options="projectTasks"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Select task"
|
||||
:placeholder-value="null"
|
||||
:disabled="!timerStore.isStopped || !selectedProject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Budget progress indicator -->
|
||||
<div v-if="projectBudgetStatus" class="mt-1.5">
|
||||
<div class="flex items-center justify-between mb-0.5">
|
||||
<span class="text-[0.5625rem] text-text-tertiary">{{ formatNumber(projectBudgetStatus.hoursUsed, 0) }}h / {{ projectBudgetStatus.budgetHours }}h</span>
|
||||
<span class="text-[0.5625rem]" :class="projectBudgetStatus.pct > 90 ? 'text-status-error-text' : projectBudgetStatus.pct > 75 ? 'text-status-warning' : 'text-text-tertiary'">{{ formatNumber(projectBudgetStatus.pct, 0) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-bg-elevated rounded-full h-0.5">
|
||||
<div
|
||||
class="h-0.5 rounded-full progress-bar"
|
||||
:class="projectBudgetStatus.pct > 90 ? 'bg-status-error' : projectBudgetStatus.pct > 75 ? 'bg-status-warning' : 'bg-accent'"
|
||||
:style="{ width: Math.min(projectBudgetStatus.pct, 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div data-tour-id="timer-description">
|
||||
<label for="timer-description" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
id="timer-description"
|
||||
v-model="description"
|
||||
type="text"
|
||||
:disabled="!timerStore.isStopped"
|
||||
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
placeholder="What are you working on?"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="showDescSuggestions"
|
||||
aria-controls="desc-suggestions"
|
||||
:aria-activedescendant="activeDescIndex >= 0 ? 'desc-opt-' + activeDescIndex : undefined"
|
||||
@focus="onDescFocus"
|
||||
@blur="onDescBlur"
|
||||
@keydown="onDescKeydown"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ul
|
||||
v-if="showDescSuggestions && filteredDescriptions.length > 0"
|
||||
id="desc-suggestions"
|
||||
role="listbox"
|
||||
class="absolute z-20 left-0 right-0 top-full mt-1 max-h-48 overflow-y-auto bg-bg-elevated border border-border-subtle rounded-lg shadow-lg"
|
||||
>
|
||||
<li
|
||||
v-for="(desc, i) in filteredDescriptions"
|
||||
:id="'desc-opt-' + i"
|
||||
:key="desc"
|
||||
role="option"
|
||||
:aria-selected="i === activeDescIndex"
|
||||
class="px-3 py-2 text-[0.8125rem] text-text-primary cursor-pointer transition-colors"
|
||||
:class="i === activeDescIndex ? 'bg-accent/10 text-accent-text' : 'hover:bg-bg-surface'"
|
||||
@mousedown.prevent="selectDescription(desc)"
|
||||
>
|
||||
{{ desc }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
@click="timerStore.setBillable(timerStore.billable === 1 ? 0 : 1)"
|
||||
:aria-label="timerStore.billable === 1 ? 'Mark as non-billable' : 'Mark as billable'"
|
||||
v-tooltip="'Toggle billable'"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full transition-colors"
|
||||
:class="timerStore.billable === 1
|
||||
? 'bg-accent-muted text-accent-text'
|
||||
: 'bg-bg-elevated text-text-tertiary'"
|
||||
>
|
||||
<DollarSign class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="timerStore.isStopped && selectedProject"
|
||||
@click="saveAsFavorite"
|
||||
class="p-2 text-text-tertiary hover:text-accent-text transition-colors"
|
||||
aria-label="Save as favorite"
|
||||
v-tooltip="'Save as favorite'"
|
||||
>
|
||||
<Star class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="timer-tags" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tags</label>
|
||||
<AppTagInput v-model="selectedTags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Panel -->
|
||||
<div v-if="timelineEnabled" class="mb-8 border border-border-subtle rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleTimelinePanel"
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
<span>Timeline</span>
|
||||
<ChevronDown
|
||||
class="w-4 h-4 text-text-tertiary transition-transform duration-200"
|
||||
:class="{ 'rotate-180': timelineExpanded }"
|
||||
:stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="timelineExpanded" class="border-t border-border-subtle px-3 py-3">
|
||||
<div v-if="timelineEvents.length > 0">
|
||||
<!-- Timeline bar -->
|
||||
<div class="relative w-full h-8 bg-bg-inset rounded overflow-hidden mb-2">
|
||||
<div
|
||||
v-for="seg in timelineSegments"
|
||||
:key="seg.id"
|
||||
class="absolute top-0 h-full rounded-sm cursor-default transition-opacity hover:opacity-80"
|
||||
:style="{
|
||||
left: seg.left + '%',
|
||||
width: Math.max(seg.width, 0.3) + '%',
|
||||
backgroundColor: seg.color,
|
||||
}"
|
||||
role="img"
|
||||
:aria-label="seg.tooltip"
|
||||
/>
|
||||
</div>
|
||||
<!-- Time labels -->
|
||||
<div class="flex justify-between text-[0.5625rem] text-text-tertiary mb-3">
|
||||
<span>{{ timelineStartLabel }}</span>
|
||||
<span>{{ timelineEndLabel }}</span>
|
||||
</div>
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mb-3">
|
||||
<div v-for="app in timelineApps" :key="app.name" class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-sm shrink-0" :style="{ backgroundColor: app.color }" />
|
||||
<span class="text-[0.625rem] text-text-secondary truncate max-w-[120px]">{{ app.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="clearTodayTimeline"
|
||||
class="flex items-center gap-1.5 text-[0.6875rem] text-text-tertiary hover:text-status-error transition-colors"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" :stroke-width="1.5" aria-hidden="true" />
|
||||
Clear today
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-[0.6875rem] text-text-tertiary">No timeline events recorded today. Events appear as the timer runs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent entries -->
|
||||
<div class="max-w-[36rem] mx-auto">
|
||||
<!-- Right column -->
|
||||
<div class="lg:sticky lg:top-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Recent</h2>
|
||||
<router-link v-if="recentEntries.length > 0" to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors">View all</router-link>
|
||||
<router-link v-if="recentEntries.length > 0" to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors" aria-label="View all time entries">View all</router-link>
|
||||
</div>
|
||||
|
||||
<TransitionGroup v-if="recentEntries.length > 0" name="list" tag="div">
|
||||
@@ -145,17 +359,18 @@
|
||||
:class="index === 0 ? 'border-l-2 border-l-accent pl-3' : ''"
|
||||
:style="{ transitionDelay: `${index * 40}ms` }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary">{{ entry.description || 'No description' }}</p>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[0.75rem] text-text-primary truncate">{{ getProjectName(entry.project_id) }}</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary truncate">{{ entry.description || 'No description' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="text-right">
|
||||
<p class="text-[0.75rem] font-mono text-text-primary">{{ formatDuration(entry.duration) }}</p>
|
||||
<p class="text-[0.6875rem] text-text-tertiary">{{ formatDateTime(entry.start_time) }}</p>
|
||||
@@ -164,9 +379,10 @@
|
||||
v-if="timerStore.isStopped"
|
||||
@click="repeatEntry(entry)"
|
||||
class="p-1 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
title="Repeat"
|
||||
aria-label="Repeat entry"
|
||||
v-tooltip="'Replay entry'"
|
||||
>
|
||||
<RotateCcw class="w-3 h-3" :stroke-width="2" />
|
||||
<RotateCcw class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +390,7 @@
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex flex-col items-center py-8">
|
||||
<TimerIcon class="w-10 h-10 text-text-tertiary animate-float" :stroke-width="1.5" />
|
||||
<TimerIcon class="w-10 h-10 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
|
||||
<p class="text-sm text-text-secondary mt-3">No entries today</p>
|
||||
<p class="text-xs text-text-tertiary mt-1">Select a project and hit Start to begin tracking</p>
|
||||
</div>
|
||||
@@ -205,13 +421,13 @@ import { useProjectsStore, type Task } from '../stores/projects'
|
||||
import { useEntriesStore } from '../stores/entries'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { Timer as TimerIcon, RotateCcw, ExternalLink, Star } from 'lucide-vue-next'
|
||||
import { Timer as TimerIcon, RotateCcw, ExternalLink, Star, DollarSign, ChevronDown, Trash2, Play, ArrowLeftRight, X, ArrowDownToLine } from 'lucide-vue-next'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import AppSelect from '../components/AppSelect.vue'
|
||||
import AppTagInput from '../components/AppTagInput.vue'
|
||||
import IdlePromptDialog from '../components/IdlePromptDialog.vue'
|
||||
import AppTrackingPromptDialog from '../components/AppTrackingPromptDialog.vue'
|
||||
import { formatDateTime } from '../utils/locale'
|
||||
import { formatDateTime, formatNumber } from '../utils/locale'
|
||||
import { useFavoritesStore, type Favorite } from '../stores/favorites'
|
||||
import { useTagsStore } from '../stores/tags'
|
||||
|
||||
@@ -228,10 +444,192 @@ const favorites = computed(() => favoritesStore.favorites)
|
||||
const selectedProject = ref<number | null>(timerStore.selectedProjectId)
|
||||
const selectedTask = ref<number | null>(timerStore.selectedTaskId)
|
||||
const description = ref(timerStore.description)
|
||||
const recentDescriptions = ref<string[]>([])
|
||||
const showDescSuggestions = ref(false)
|
||||
const activeDescIndex = ref(-1)
|
||||
|
||||
const filteredDescriptions = computed(() => {
|
||||
const q = description.value.toLowerCase().trim()
|
||||
if (q.length < 2) return recentDescriptions.value.slice(0, 10)
|
||||
return recentDescriptions.value.filter(d => d.toLowerCase().includes(q)).slice(0, 10)
|
||||
})
|
||||
|
||||
async function loadRecentDescriptions() {
|
||||
try {
|
||||
recentDescriptions.value = await invoke<string[]>('get_recent_descriptions')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function onDescFocus() {
|
||||
if (recentDescriptions.value.length > 0 && timerStore.isStopped) {
|
||||
showDescSuggestions.value = true
|
||||
activeDescIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
function onDescBlur() {
|
||||
// Delay so mousedown on option fires first
|
||||
setTimeout(() => { showDescSuggestions.value = false }, 150)
|
||||
}
|
||||
|
||||
function onDescKeydown(e: KeyboardEvent) {
|
||||
if (!showDescSuggestions.value || filteredDescriptions.value.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeDescIndex.value = Math.min(activeDescIndex.value + 1, filteredDescriptions.value.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeDescIndex.value = Math.max(activeDescIndex.value - 1, -1)
|
||||
} else if (e.key === 'Enter' && activeDescIndex.value >= 0) {
|
||||
e.preventDefault()
|
||||
selectDescription(filteredDescriptions.value[activeDescIndex.value])
|
||||
} else if (e.key === 'Escape') {
|
||||
showDescSuggestions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectDescription(desc: string) {
|
||||
description.value = desc
|
||||
showDescSuggestions.value = false
|
||||
activeDescIndex.value = -1
|
||||
}
|
||||
|
||||
const selectedTags = ref<number[]>([])
|
||||
const projectTasks = ref<Task[]>([])
|
||||
const projectBudgetStatus = ref<{ hoursUsed: number; budgetHours: number; pct: number } | null>(null)
|
||||
const timerPulseClass = ref('')
|
||||
|
||||
// Timeline state
|
||||
interface TimelineEvent {
|
||||
id: number
|
||||
project_id: number
|
||||
exe_name: string | null
|
||||
exe_path: string | null
|
||||
window_title: string | null
|
||||
started_at: string
|
||||
ended_at: string | null
|
||||
duration: number | null
|
||||
}
|
||||
|
||||
const timelineExpanded = ref(false)
|
||||
const timelineEvents = ref<TimelineEvent[]>([])
|
||||
|
||||
const timelineEnabled = computed(() => {
|
||||
const globalOn = settingsStore.settings.timeline_recording === 'on'
|
||||
if (selectedProject.value) {
|
||||
const project = projectsStore.projects.find(p => p.id === selectedProject.value)
|
||||
if (project?.timeline_override === 'on') return true
|
||||
if (project?.timeline_override === 'off') return false
|
||||
}
|
||||
return globalOn
|
||||
})
|
||||
|
||||
function hashColor(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const hue = Math.abs(hash) % 360
|
||||
return `hsl(${hue}, 55%, 50%)`
|
||||
}
|
||||
|
||||
const timelineSegments = computed(() => {
|
||||
if (timelineEvents.value.length === 0) return []
|
||||
const events = timelineEvents.value
|
||||
const firstStart = new Date(events[0].started_at).getTime()
|
||||
const lastEnd = events.reduce((max, e) => {
|
||||
const end = e.ended_at ? new Date(e.ended_at).getTime() : Date.now()
|
||||
return Math.max(max, end)
|
||||
}, firstStart)
|
||||
const totalSpan = lastEnd - firstStart
|
||||
if (totalSpan <= 0) return []
|
||||
return events.map(e => {
|
||||
const start = new Date(e.started_at).getTime()
|
||||
const end = e.ended_at ? new Date(e.ended_at).getTime() : Date.now()
|
||||
const left = ((start - firstStart) / totalSpan) * 100
|
||||
const width = ((end - start) / totalSpan) * 100
|
||||
const appName = e.exe_name || 'Unknown'
|
||||
const dur = e.duration ?? Math.floor((end - start) / 1000)
|
||||
const durMin = Math.floor(dur / 60)
|
||||
const durSec = dur % 60
|
||||
const timeRange = formatTimeHM(e.started_at) + ' - ' + (e.ended_at ? formatTimeHM(e.ended_at) : 'now')
|
||||
return {
|
||||
id: e.id,
|
||||
left,
|
||||
width,
|
||||
color: hashColor(appName),
|
||||
tooltip: `${appName}\n${e.window_title || ''}\n${timeRange}\n${durMin}m ${durSec}s`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const timelineStartLabel = computed(() => {
|
||||
if (timelineEvents.value.length === 0) return ''
|
||||
return formatTimeHM(timelineEvents.value[0].started_at)
|
||||
})
|
||||
|
||||
const timelineEndLabel = computed(() => {
|
||||
if (timelineEvents.value.length === 0) return ''
|
||||
const last = timelineEvents.value[timelineEvents.value.length - 1]
|
||||
return last.ended_at ? formatTimeHM(last.ended_at) : 'now'
|
||||
})
|
||||
|
||||
const timelineApps = computed(() => {
|
||||
const seen = new Map<string, string>()
|
||||
for (const e of timelineEvents.value) {
|
||||
const name = e.exe_name || 'Unknown'
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, hashColor(name))
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries()).map(([name, color]) => ({ name, color }))
|
||||
})
|
||||
|
||||
function formatTimeHM(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function toggleTimelinePanel() {
|
||||
timelineExpanded.value = !timelineExpanded.value
|
||||
if (timelineExpanded.value) {
|
||||
await fetchTimelineEvents()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTimelineEvents() {
|
||||
if (!selectedProject.value) {
|
||||
timelineEvents.value = []
|
||||
return
|
||||
}
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
try {
|
||||
timelineEvents.value = await invoke<TimelineEvent[]>('get_timeline_events', {
|
||||
projectId: selectedProject.value,
|
||||
date: today,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch timeline events:', e)
|
||||
timelineEvents.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function clearTodayTimeline() {
|
||||
if (!selectedProject.value) return
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
try {
|
||||
await invoke('delete_timeline_events', {
|
||||
projectId: selectedProject.value,
|
||||
date: today,
|
||||
})
|
||||
timelineEvents.value = []
|
||||
toastStore.success('Timeline cleared')
|
||||
} catch (e) {
|
||||
console.error('Failed to clear timeline:', e)
|
||||
toastStore.error('Failed to clear timeline')
|
||||
}
|
||||
}
|
||||
|
||||
// Split timer into parts for colon animation
|
||||
const timerParts = computed(() => {
|
||||
const time = timerStore.formattedTime
|
||||
@@ -285,9 +683,24 @@ watch(selectedProject, async (newProjectId) => {
|
||||
timerStore.setProject(newProjectId)
|
||||
selectedTask.value = null
|
||||
projectTasks.value = []
|
||||
projectBudgetStatus.value = null
|
||||
|
||||
if (newProjectId) {
|
||||
projectTasks.value = await projectsStore.fetchTasks(newProjectId)
|
||||
const project = projectsStore.projects.find(p => p.id === newProjectId)
|
||||
if (project?.budget_hours) {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const status = await invoke<{ hours_used: number }>('get_project_budget_status', { projectId: newProjectId, today })
|
||||
projectBudgetStatus.value = {
|
||||
hoursUsed: status.hours_used,
|
||||
budgetHours: project.budget_hours,
|
||||
pct: (status.hours_used / project.budget_hours) * 100,
|
||||
}
|
||||
} catch {
|
||||
projectBudgetStatus.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -364,6 +777,21 @@ async function toggleTimer() {
|
||||
}
|
||||
}
|
||||
selectedTags.value = []
|
||||
|
||||
// Check for overlapping entries (non-blocking warning)
|
||||
const latestEntry = entriesStore.entries[0]
|
||||
if (latestEntry?.start_time && latestEntry?.end_time) {
|
||||
try {
|
||||
const overlaps = await invoke<Array<{ project_name: string }>>('check_entry_overlap', {
|
||||
startTime: latestEntry.start_time,
|
||||
endTime: latestEntry.end_time,
|
||||
excludeId: latestEntry.id,
|
||||
})
|
||||
if (overlaps.length > 0) {
|
||||
toastStore.info(`This entry overlaps with ${overlaps.length} other ${overlaps.length === 1 ? 'entry' : 'entries'}`)
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +833,91 @@ function applyFavorite(fav: Favorite) {
|
||||
description.value = fav.description || ''
|
||||
}
|
||||
|
||||
// Quick-start a favorite (starts timer immediately)
|
||||
async function quickStartFavorite(fav: Favorite) {
|
||||
if (!timerStore.isStopped) {
|
||||
timerStore.stop()
|
||||
await entriesStore.fetchEntries()
|
||||
}
|
||||
selectedProject.value = fav.project_id
|
||||
selectedTask.value = fav.task_id || null
|
||||
description.value = fav.description || ''
|
||||
timerStore.setProject(fav.project_id)
|
||||
timerStore.setTask(fav.task_id || null)
|
||||
timerStore.setDescription(fav.description || '')
|
||||
timerStore.start()
|
||||
toastStore.success('Timer started for ' + getProjectName(fav.project_id))
|
||||
}
|
||||
|
||||
// Quick-switch state
|
||||
const showSwitchPicker = ref(false)
|
||||
|
||||
async function switchToProject(projectId: number | null) {
|
||||
if (!projectId) return
|
||||
showSwitchPicker.value = false
|
||||
timerStore.stop()
|
||||
await entriesStore.fetchEntries()
|
||||
selectedProject.value = projectId
|
||||
selectedTask.value = null
|
||||
description.value = ''
|
||||
timerStore.setProject(projectId)
|
||||
timerStore.setTask(null)
|
||||
timerStore.setDescription('')
|
||||
timerStore.start()
|
||||
toastStore.success('Switched to ' + getProjectName(projectId))
|
||||
}
|
||||
|
||||
// Drag-reorder favorites
|
||||
const dragFavIndex = ref<number | null>(null)
|
||||
const dropTargetIndex = ref<number | null>(null)
|
||||
|
||||
function onFavDragStart(index: number) {
|
||||
dragFavIndex.value = index
|
||||
window.addEventListener('pointerup', onFavDragEnd, { once: true })
|
||||
}
|
||||
|
||||
function onFavDragOver(index: number) {
|
||||
if (dragFavIndex.value !== null) {
|
||||
dropTargetIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
function onFavDragCancel() {
|
||||
if (dragFavIndex.value !== null) {
|
||||
dropTargetIndex.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onFavDragEnd() {
|
||||
const from = dragFavIndex.value
|
||||
const to = dropTargetIndex.value
|
||||
if (from !== null && to !== null && from !== to) {
|
||||
const reordered = [...favoritesStore.favorites]
|
||||
const [moved] = reordered.splice(from, 1)
|
||||
// Adjust insertion index: if dropping after the dragged item's original position,
|
||||
// the index shifts down by 1 since we already removed the item
|
||||
const insertAt = to > from ? to - 1 : to
|
||||
reordered.splice(insertAt, 0, moved)
|
||||
favoritesStore.reorderFavorites(reordered.map(f => f.id!))
|
||||
}
|
||||
dragFavIndex.value = null
|
||||
dropTargetIndex.value = null
|
||||
}
|
||||
|
||||
function moveFavorite(index: number, direction: -1 | 1) {
|
||||
const newIndex = index + direction
|
||||
if (newIndex < 0 || newIndex >= favoritesStore.favorites.length) return
|
||||
const reordered = [...favoritesStore.favorites]
|
||||
const [moved] = reordered.splice(index, 1)
|
||||
reordered.splice(newIndex, 0, moved)
|
||||
favoritesStore.reorderFavorites(reordered.map(f => f.id!))
|
||||
}
|
||||
|
||||
// Delete a favorite
|
||||
async function removeFavorite(id: number) {
|
||||
await favoritesStore.deleteFavorite(id)
|
||||
}
|
||||
|
||||
// Save current inputs as a favorite
|
||||
async function saveAsFavorite() {
|
||||
if (!selectedProject.value) return
|
||||
@@ -445,7 +958,8 @@ onMounted(async () => {
|
||||
entriesStore.fetchEntries(),
|
||||
settingsStore.fetchSettings(),
|
||||
favoritesStore.fetchFavorites(),
|
||||
tagsStore.fetchTags()
|
||||
tagsStore.fetchTags(),
|
||||
loadRecentDescriptions()
|
||||
])
|
||||
|
||||
// Restore timer state
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="prevWeek"
|
||||
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||
v-tooltip="'Previous week'"
|
||||
class="p-1.5 rounded-lg text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors duration-150"
|
||||
aria-label="Previous week"
|
||||
>
|
||||
<ChevronLeft class="w-5 h-5" :stroke-width="2" aria-hidden="true" />
|
||||
<ChevronLeft class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -30,13 +31,14 @@
|
||||
|
||||
<button
|
||||
@click="nextWeek"
|
||||
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||
v-tooltip="'Next week'"
|
||||
class="p-1.5 rounded-lg text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors duration-150"
|
||||
aria-label="Next week"
|
||||
>
|
||||
<ChevronRight class="w-5 h-5" :stroke-width="2" aria-hidden="true" />
|
||||
<ChevronRight class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<span class="text-[0.8125rem] text-text-primary font-medium ml-2" role="status" aria-live="polite">
|
||||
<span class="text-[0.75rem] text-text-secondary font-medium ml-2" role="status" aria-live="polite">
|
||||
{{ weekRangeLabel }}
|
||||
</span>
|
||||
|
||||
@@ -70,8 +72,8 @@
|
||||
<div :key="weekStart" class="bg-bg-surface rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle">
|
||||
<th class="px-3 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-56">
|
||||
<tr class="border-b border-border-subtle bg-bg-surface">
|
||||
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-56">
|
||||
<span class="flex items-center gap-1.5">
|
||||
Project / Task
|
||||
<Lock v-if="isCurrentWeekLocked" class="w-3 h-3 text-amber-500" :stroke-width="2" aria-label="Week is locked" />
|
||||
@@ -80,12 +82,12 @@
|
||||
<th
|
||||
v-for="(day, i) in dayHeaders"
|
||||
:key="i"
|
||||
class="px-3 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium"
|
||||
class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium"
|
||||
:class="{ 'text-text-secondary': isToday(i) }"
|
||||
>
|
||||
{{ day }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">
|
||||
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
@@ -97,9 +99,9 @@
|
||||
:key="rowIndex"
|
||||
class="border-b border-border-subtle transition-colors duration-150"
|
||||
:class="isCurrentWeekLocked ? 'bg-bg-inset' : 'hover:bg-bg-elevated'"
|
||||
:title="isCurrentWeekLocked ? 'This week is locked' : undefined"
|
||||
:aria-label="isCurrentWeekLocked ? 'This week is locked' : undefined"
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
@@ -118,7 +120,7 @@
|
||||
tabindex="0"
|
||||
role="gridcell"
|
||||
:aria-label="'Hours for ' + row.project_name + ' on ' + getDayLabel(dayIndex) + ': ' + formatHM(seconds)"
|
||||
class="px-3 py-2 text-right text-[0.75rem] font-mono transition-colors duration-150"
|
||||
class="px-4 py-3 text-right text-[0.75rem] font-mono transition-colors duration-150"
|
||||
:class="[
|
||||
seconds > 0 ? 'text-accent-text' : 'text-text-tertiary',
|
||||
!isCurrentWeekLocked ? 'cursor-pointer hover:bg-bg-elevated' : ''
|
||||
@@ -140,14 +142,14 @@
|
||||
{{ formatHM(seconds) }}
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text font-medium">
|
||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text font-medium">
|
||||
{{ formatHM(rowTotal(row)) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Add Row (inline) -->
|
||||
<tr v-if="showAddRow" class="border-b border-border-subtle bg-bg-inset">
|
||||
<td class="px-3 py-2" colspan="9">
|
||||
<td class="px-4 py-3" colspan="9">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-48">
|
||||
<AppSelect
|
||||
@@ -193,12 +195,12 @@
|
||||
|
||||
<!-- Empty state -->
|
||||
<tr v-if="rows.length === 0 && !showAddRow">
|
||||
<td colspan="9" class="px-3 py-12">
|
||||
<td colspan="9" class="px-4 py-12">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<ClockIcon class="w-10 h-10 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
|
||||
<p class="text-sm text-text-secondary mt-3">No timesheet data for this week</p>
|
||||
<p class="text-xs text-text-tertiary mt-1">Start tracking time to see your weekly timesheet.</p>
|
||||
<router-link to="/timer" class="mt-3 px-4 py-1.5 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
||||
<ClockIcon class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
|
||||
<p class="text-sm text-text-secondary mt-4">No timesheet data for this week</p>
|
||||
<p class="text-xs text-text-tertiary mt-2">Start tracking time to see your weekly timesheet.</p>
|
||||
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
||||
Go to Timer
|
||||
</router-link>
|
||||
<button
|
||||
@@ -215,17 +217,17 @@
|
||||
<tfoot>
|
||||
<!-- Column totals -->
|
||||
<tr class="border-t border-border-visible bg-bg-elevated">
|
||||
<td class="px-3 py-2 text-[0.6875rem] text-text-secondary uppercase tracking-[0.08em] font-medium">
|
||||
<td class="px-4 py-3 text-[0.6875rem] text-text-secondary uppercase tracking-[0.08em] font-medium">
|
||||
Total
|
||||
</td>
|
||||
<td
|
||||
v-for="(total, i) in columnTotals"
|
||||
:key="i"
|
||||
class="px-3 py-2 text-right text-[0.75rem] font-mono text-text-primary font-medium"
|
||||
class="px-4 py-3 text-right text-[0.75rem] font-mono text-text-primary font-medium"
|
||||
>
|
||||
{{ formatHM(total) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text font-bold">
|
||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text font-bold">
|
||||
{{ formatHM(grandTotal) }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -240,7 +242,7 @@
|
||||
@click="startAddRow"
|
||||
:disabled="isCurrentWeekLocked"
|
||||
class="mt-3 flex items-center gap-1.5 text-text-secondary text-xs hover:text-text-primary transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:title="isCurrentWeekLocked ? 'Cannot add rows to a locked week' : undefined"
|
||||
:aria-label="isCurrentWeekLocked ? 'Cannot add rows to a locked week' : 'Add row'"
|
||||
>
|
||||
<Plus class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
|
||||
Add Row
|
||||
@@ -297,7 +299,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="unlockWeek"
|
||||
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
class="px-4 py-2 border border-status-error text-status-error-text font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user