feat: timesheet row persistence and copy last week
This commit is contained in:
@@ -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">
|
||||
Project / Task
|
||||
<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' : ''
|
||||
]"
|
||||
>
|
||||
{{ formatHM(seconds) }}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user