feat: timesheet row persistence and copy last week

This commit is contained in:
Your Name
2026-02-20 15:17:01 +02:00
parent a3ea37baa1
commit cb1c6c9b5d

View File

@@ -8,29 +8,60 @@
<button
@click="prevWeek"
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
title="Previous week"
aria-label="Previous week"
>
<ChevronLeft class="w-5 h-5" :stroke-width="2" />
<ChevronLeft class="w-5 h-5" :stroke-width="2" aria-hidden="true" />
</button>
<button
@click="goToThisWeek"
class="px-3 py-1.5 text-xs text-text-secondary border border-border-subtle rounded-lg hover:text-text-primary hover:bg-bg-elevated transition-colors duration-150"
class="px-3 py-1.5 text-xs text-text-secondary border border-border-subtle rounded-lg hover:text-text-primary hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
This Week
</button>
<button
@click="nextWeek"
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
title="Next week"
@click="copyLastWeekStructure"
:disabled="isCurrentWeekLocked"
class="px-3 py-1.5 text-xs text-text-secondary border border-border-subtle rounded-lg hover:text-text-primary hover:bg-bg-elevated transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<ChevronRight class="w-5 h-5" :stroke-width="2" />
Copy Last Week
</button>
<span class="text-[0.8125rem] text-text-primary font-medium ml-2">
<button
@click="nextWeek"
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
aria-label="Next week"
>
<ChevronRight class="w-5 h-5" :stroke-width="2" aria-hidden="true" />
</button>
<span class="text-[0.8125rem] text-text-primary font-medium ml-2" role="status" aria-live="polite">
{{ weekRangeLabel }}
</span>
<!-- Lock status + button -->
<template v-if="isCurrentWeekLocked">
<span class="flex items-center gap-1 text-[0.6875rem] text-amber-500 ml-3">
<Lock class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
Locked
</span>
<button
@click="showUnlockConfirm = true"
class="ml-1 px-2.5 py-1 text-[0.6875rem] text-text-secondary border border-border-subtle rounded-lg hover:text-text-primary hover:bg-bg-elevated transition-colors duration-150"
>
Unlock
</button>
</template>
<template v-else-if="canLock">
<button
@click="showLockConfirm = true"
class="ml-3 flex items-center gap-1 px-2.5 py-1 text-[0.6875rem] text-text-secondary border border-border-subtle rounded-lg hover:text-text-primary hover:bg-bg-elevated transition-colors duration-150"
>
<Lock class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
Lock Week
</button>
</template>
</div>
</div>
@@ -41,7 +72,10 @@
<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">
<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" />
</span>
</th>
<th
v-for="(day, i) in dayHeaders"
@@ -61,13 +95,16 @@
<tr
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
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"
>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: row.color }"
aria-hidden="true"
/>
<span class="text-[0.75rem] text-text-primary truncate">{{ row.project_name }}</span>
<span v-if="row.task_name" class="text-[0.75rem] text-text-tertiary truncate">/ {{ row.task_name }}</span>
@@ -76,10 +113,32 @@
<td
v-for="(seconds, dayIndex) in row.days"
:key="dayIndex"
class="px-3 py-2 text-right text-[0.75rem] font-mono"
:class="seconds > 0 ? 'text-accent-text' : 'text-text-tertiary'"
@click="startEdit(rowIndex, dayIndex)"
@keydown.enter="startEdit(rowIndex, dayIndex)"
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="[
seconds > 0 ? 'text-accent-text' : 'text-text-tertiary',
!isCurrentWeekLocked ? 'cursor-pointer hover:bg-bg-elevated' : ''
]"
>
<template v-if="editingCell?.rowIdx === rowIndex && editingCell?.dayIdx === dayIndex">
<input
ref="editInputRef"
v-model="editValue"
@blur="saveEdit()"
@keydown.enter.prevent="saveEdit()"
@keydown.escape.prevent="cancelEdit()"
@keydown.tab="saveEdit()"
class="w-full text-right text-[0.75rem] font-mono bg-transparent border-b-2 border-accent outline-none text-text-primary"
aria-label="Enter hours (decimal or H:MM)"
/>
</template>
<template v-else>
{{ formatHM(seconds) }}
</template>
</td>
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text font-medium">
{{ formatHM(rowTotal(row)) }}
@@ -99,6 +158,7 @@
placeholder="Select project"
:placeholder-value="null"
:searchable="true"
aria-label="Select project"
/>
</div>
<div class="w-48">
@@ -111,6 +171,7 @@
:placeholder-value="null"
:searchable="true"
:disabled="!newRowProjectId"
aria-label="Select task"
/>
</div>
<button
@@ -132,8 +193,22 @@
<!-- Empty state -->
<tr v-if="rows.length === 0 && !showAddRow">
<td colspan="9" class="px-3 py-8 text-center">
<p class="text-[0.75rem] text-text-tertiary">No timesheet data for this week</p>
<td colspan="9" class="px-3 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">
Go to Timer
</router-link>
<button
v-if="onboardingStore.isVisible"
@click="tourStore.start(TOURS.timer)"
class="mt-2 text-[0.6875rem] text-accent-text hover:underline"
>
Show me how
</button>
</div>
</td>
</tr>
</tbody>
@@ -163,22 +238,93 @@
<button
v-if="!showAddRow"
@click="startAddRow"
class="mt-3 flex items-center gap-1.5 text-text-secondary text-xs hover:text-text-primary transition-colors duration-150"
: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"
>
<Plus class="w-4 h-4" :stroke-width="2" />
<Plus class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
Add Row
</button>
<!-- Lock Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showLockConfirm"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showLockConfirm = 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-sm p-6" role="alertdialog" aria-modal="true" aria-labelledby="lock-title" aria-describedby="lock-desc">
<h2 id="lock-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Lock Week</h2>
<p id="lock-desc" class="text-[0.75rem] text-text-secondary mb-6">
Locking this week will prevent any changes to time entries within it. You can unlock it later if needed.
</p>
<div class="flex justify-end gap-3">
<button
@click="showLockConfirm = 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="lockWeek"
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Lock
</button>
</div>
</div>
</div>
</Transition>
<!-- Unlock Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showUnlockConfirm"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showUnlockConfirm = 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-sm p-6" role="alertdialog" aria-modal="true" aria-labelledby="unlock-title" aria-describedby="unlock-desc">
<h2 id="unlock-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Unlock Week</h2>
<p id="unlock-desc" class="text-[0.75rem] text-text-secondary mb-6">
Unlocking this week will allow changes to time entries. Are you sure?
</p>
<div class="flex justify-end gap-3">
<button
@click="showUnlockConfirm = 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="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"
>
Unlock
</button>
</div>
</div>
</div>
</Transition>
<div class="sr-only" aria-live="polite" aria-atomic="true">{{ liveAnnouncement }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { ChevronLeft, ChevronRight, Plus, Lock, Clock as ClockIcon } from 'lucide-vue-next'
import { invoke } from '@tauri-apps/api/core'
import AppSelect from '../components/AppSelect.vue'
import { useProjectsStore, type Task } from '../stores/projects'
import { useToastStore } from '../stores/toast'
import { useOnboardingStore } from '../stores/onboarding'
import { useTourStore } from '../stores/tour'
import { TOURS } from '../utils/tours'
const projectsStore = useProjectsStore()
const toast = useToastStore()
const onboardingStore = useOnboardingStore()
const tourStore = useTourStore()
// ── Types ──────────────────────────────────────────────────────────────
@@ -296,9 +442,90 @@ async function fetchTimesheetData() {
}
}
// Persist current row structure to backend
async function persistRows() {
try {
const rowData = rows.value.map((r, i) => ({
project_id: r.project_id,
task_id: r.task_id,
sort_order: i,
}))
await invoke('save_timesheet_rows', { weekStart: weekStart.value, rows: rowData })
} catch {
// Silent failure - row persistence is a convenience feature
}
}
// Load previous week's row structure if current week has no rows
async function maybeLoadPreviousStructure() {
if (rows.value.length > 0) return
try {
const prevRows = await invoke<any[]>('get_previous_week_structure', { currentWeekStart: weekStart.value })
if (prevRows.length > 0) {
for (const pr of prevRows) {
const project = projectsStore.projects.find(p => p.id === pr.project_id)
if (!project) continue
let taskName: string | null = null
if (pr.task_id) {
const tasks = await projectsStore.fetchTasks(pr.project_id)
taskName = tasks.find(t => t.id === pr.task_id)?.name ?? null
}
rows.value.push({
project_id: pr.project_id,
project_name: project.name,
color: project.color,
task_id: pr.task_id || null,
task_name: taskName,
days: [0, 0, 0, 0, 0, 0, 0],
})
}
}
} catch {
// No previous structure
}
}
// Copy previous week's row structure into current week
async function copyLastWeekStructure() {
try {
const prevRows = await invoke<any[]>('get_previous_week_structure', { currentWeekStart: weekStart.value })
if (prevRows.length === 0) {
toast.info('No rows found in previous week')
return
}
for (const pr of prevRows) {
const project = projectsStore.projects.find(p => p.id === pr.project_id)
if (!project) continue
// Skip if already exists
const exists = rows.value.some(
r => r.project_id === pr.project_id && r.task_id === (pr.task_id || null)
)
if (exists) continue
let taskName: string | null = null
if (pr.task_id) {
const tasks = await projectsStore.fetchTasks(pr.project_id)
taskName = tasks.find(t => t.id === pr.task_id)?.name ?? null
}
rows.value.push({
project_id: pr.project_id,
project_name: project.name,
color: project.color,
task_id: pr.task_id || null,
task_name: taskName,
days: [0, 0, 0, 0, 0, 0, 0],
})
}
await persistRows()
toast.success('Copied row structure from last week')
} catch {
toast.error('Failed to copy last week structure')
}
}
// Re-fetch whenever weekStart changes
watch(weekStart, () => {
fetchTimesheetData()
watch(weekStart, async () => {
await fetchTimesheetData()
await maybeLoadPreviousStructure()
})
// ── Totals ─────────────────────────────────────────────────────────────
@@ -330,6 +557,91 @@ function formatHM(seconds: number): string {
return `${h}:${String(m).padStart(2, '0')}`
}
// ── Inline Editing ──────────────────────────────────────────────────────
const editingCell = ref<{ rowIdx: number; dayIdx: number } | null>(null)
const editValue = ref('')
const editInputRef = ref<HTMLInputElement | null>(null)
const liveAnnouncement = ref('')
function startEdit(rowIdx: number, dayIdx: number) {
if (isCurrentWeekLocked.value) return
const row = rows.value[rowIdx]
if (!row) return
editingCell.value = { rowIdx, dayIdx }
const seconds = row.days[dayIdx]
editValue.value = seconds > 0 ? formatHoursDecimal(seconds) : ''
nextTick(() => {
editInputRef.value?.focus()
editInputRef.value?.select()
})
}
function cancelEdit() {
editingCell.value = null
editValue.value = ''
}
function formatHoursDecimal(seconds: number): string {
return (seconds / 3600).toFixed(2)
}
function parseTimeInput(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return 0
if (trimmed.includes(':')) {
const [h, m] = trimmed.split(':').map(Number)
if (isNaN(h) || isNaN(m) || h < 0 || m < 0 || m > 59) return null
const seconds = h * 3600 + m * 60
if (seconds > 86400) return null
return seconds
}
const num = parseFloat(trimmed)
if (isNaN(num) || num < 0 || num > 24) return null
return Math.round(num * 3600)
}
function getDayDate(dayIdx: number): string {
const dates = weekDates.value
if (dates[dayIdx]) return formatISODate(dates[dayIdx])
return ''
}
function getDayLabel(dayIdx: number): string {
const labels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
return labels[dayIdx] || ''
}
async function saveEdit() {
if (!editingCell.value) return
const { rowIdx, dayIdx } = editingCell.value
const row = rows.value[rowIdx]
if (!row) { cancelEdit(); return }
const seconds = parseTimeInput(editValue.value)
if (seconds === null) { cancelEdit(); return }
const date = getDayDate(dayIdx)
if (!date) { cancelEdit(); return }
try {
await invoke('upsert_timesheet_entry', {
projectId: row.project_id,
taskId: row.task_id,
date,
durationSeconds: seconds,
})
row.days[dayIdx] = seconds
liveAnnouncement.value = `Saved ${formatHoursDecimal(seconds)} hours for ${row.project_name} on ${getDayLabel(dayIdx)}`
} catch (e) {
console.error('Failed to save timesheet entry:', e)
toast.error('Failed to save entry')
}
cancelEdit()
}
// ── Add Row ────────────────────────────────────────────────────────────
const showAddRow = ref(false)
@@ -397,13 +709,68 @@ function confirmAddRow() {
days: [0, 0, 0, 0, 0, 0, 0],
})
persistRows()
cancelAddRow()
}
// ── Timesheet locks ───────────────────────────────────────────────────
interface TimesheetLock {
id: number
week_start: string
status: string
locked_at: string
}
const lockedWeeks = ref<Set<string>>(new Set())
const showLockConfirm = ref(false)
const showUnlockConfirm = ref(false)
const isCurrentWeekLocked = computed(() => lockedWeeks.value.has(weekStart.value))
const isPastWeek = computed(() => {
const currentMonday = formatISODate(getMonday(new Date()))
return weekStart.value < currentMonday
})
const canLock = computed(() => isPastWeek.value && !isCurrentWeekLocked.value)
async function fetchLocks() {
try {
const locks = await invoke<TimesheetLock[]>('get_timesheet_locks')
lockedWeeks.value = new Set(locks.map(l => l.week_start))
} catch (error) {
console.error('Failed to fetch locks:', error)
}
}
async function lockWeek() {
try {
await invoke('lock_timesheet_week', { weekStart: weekStart.value })
lockedWeeks.value.add(weekStart.value)
toast.success('Week locked')
} catch (error) {
toast.error('Failed to lock week')
}
showLockConfirm.value = false
}
async function unlockWeek() {
try {
await invoke('unlock_timesheet_week', { weekStart: weekStart.value })
lockedWeeks.value.delete(weekStart.value)
toast.success('Week unlocked')
} catch (error) {
toast.error('Failed to unlock week')
}
showUnlockConfirm.value = false
}
// ── Init ───────────────────────────────────────────────────────────────
onMounted(async () => {
await projectsStore.fetchProjects()
goToThisWeek() // triggers watch fetchTimesheetData
goToThisWeek() // triggers watch -> fetchTimesheetData
fetchLocks()
})
</script>