feat: timesheet row persistence and copy last week
This commit is contained in:
@@ -8,29 +8,60 @@
|
|||||||
<button
|
<button
|
||||||
@click="prevWeek"
|
@click="prevWeek"
|
||||||
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="goToThisWeek"
|
@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
|
This Week
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="nextWeek"
|
@click="copyLastWeekStructure"
|
||||||
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
|
:disabled="isCurrentWeekLocked"
|
||||||
title="Next week"
|
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>
|
</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 }}
|
{{ weekRangeLabel }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,7 +72,10 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-border-subtle">
|
<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">
|
<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
|
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>
|
||||||
<th
|
<th
|
||||||
v-for="(day, i) in dayHeaders"
|
v-for="(day, i) in dayHeaders"
|
||||||
@@ -61,13 +95,16 @@
|
|||||||
<tr
|
<tr
|
||||||
v-for="(row, rowIndex) in rows"
|
v-for="(row, rowIndex) in rows"
|
||||||
:key="rowIndex"
|
: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">
|
<td class="px-3 py-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="w-2 h-2 rounded-full shrink-0"
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
:style="{ backgroundColor: row.color }"
|
:style="{ backgroundColor: row.color }"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="text-[0.75rem] text-text-primary truncate">{{ row.project_name }}</span>
|
<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>
|
<span v-if="row.task_name" class="text-[0.75rem] text-text-tertiary truncate">/ {{ row.task_name }}</span>
|
||||||
@@ -76,10 +113,32 @@
|
|||||||
<td
|
<td
|
||||||
v-for="(seconds, dayIndex) in row.days"
|
v-for="(seconds, dayIndex) in row.days"
|
||||||
:key="dayIndex"
|
:key="dayIndex"
|
||||||
class="px-3 py-2 text-right text-[0.75rem] font-mono"
|
@click="startEdit(rowIndex, dayIndex)"
|
||||||
:class="seconds > 0 ? 'text-accent-text' : 'text-text-tertiary'"
|
@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) }}
|
{{ formatHM(seconds) }}
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text font-medium">
|
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text font-medium">
|
||||||
{{ formatHM(rowTotal(row)) }}
|
{{ formatHM(rowTotal(row)) }}
|
||||||
@@ -99,6 +158,7 @@
|
|||||||
placeholder="Select project"
|
placeholder="Select project"
|
||||||
:placeholder-value="null"
|
:placeholder-value="null"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
|
aria-label="Select project"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-48">
|
<div class="w-48">
|
||||||
@@ -111,6 +171,7 @@
|
|||||||
:placeholder-value="null"
|
:placeholder-value="null"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:disabled="!newRowProjectId"
|
:disabled="!newRowProjectId"
|
||||||
|
aria-label="Select task"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -132,8 +193,22 @@
|
|||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<tr v-if="rows.length === 0 && !showAddRow">
|
<tr v-if="rows.length === 0 && !showAddRow">
|
||||||
<td colspan="9" class="px-3 py-8 text-center">
|
<td colspan="9" class="px-3 py-12">
|
||||||
<p class="text-[0.75rem] text-text-tertiary">No timesheet data for this week</p>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -163,22 +238,93 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!showAddRow"
|
v-if="!showAddRow"
|
||||||
@click="startAddRow"
|
@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
|
Add Row
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
|
import { ChevronLeft, ChevronRight, Plus, Lock, Clock as ClockIcon } from 'lucide-vue-next'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import AppSelect from '../components/AppSelect.vue'
|
import AppSelect from '../components/AppSelect.vue'
|
||||||
import { useProjectsStore, type Task } from '../stores/projects'
|
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 projectsStore = useProjectsStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
const onboardingStore = useOnboardingStore()
|
||||||
|
const tourStore = useTourStore()
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────
|
// ── 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
|
// Re-fetch whenever weekStart changes
|
||||||
watch(weekStart, () => {
|
watch(weekStart, async () => {
|
||||||
fetchTimesheetData()
|
await fetchTimesheetData()
|
||||||
|
await maybeLoadPreviousStructure()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Totals ─────────────────────────────────────────────────────────────
|
// ── Totals ─────────────────────────────────────────────────────────────
|
||||||
@@ -330,6 +557,91 @@ function formatHM(seconds: number): string {
|
|||||||
return `${h}:${String(m).padStart(2, '0')}`
|
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 ────────────────────────────────────────────────────────────
|
// ── Add Row ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const showAddRow = ref(false)
|
const showAddRow = ref(false)
|
||||||
@@ -397,13 +709,68 @@ function confirmAddRow() {
|
|||||||
days: [0, 0, 0, 0, 0, 0, 0],
|
days: [0, 0, 0, 0, 0, 0, 0],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
persistRows()
|
||||||
cancelAddRow()
|
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 ───────────────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await projectsStore.fetchProjects()
|
await projectsStore.fetchProjects()
|
||||||
goToThisWeek() // triggers watch → fetchTimesheetData
|
goToThisWeek() // triggers watch -> fetchTimesheetData
|
||||||
|
fetchLocks()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user