31 KiB
Custom Dropdowns & Date Pickers Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace all 6 native <select> and 6 native <input type="date"> elements with custom Vue 3 components matching ZeroClock's dark UI.
Architecture: Two new components (AppSelect, AppDatePicker) using <Teleport to="body"> for overflow-safe positioning. Each renders a styled trigger + a floating panel positioned via getBoundingClientRect(). Click-outside detection and keyboard navigation built in. Then swap every native element across 5 views.
Tech Stack: Vue 3 Composition API (<script setup lang="ts">), Tailwind CSS v4 with @theme tokens, Lucide Vue icons, <Teleport>.
Task 1: Create AppSelect Component
Files:
- Create:
src/components/AppSelect.vue
Context: This component replaces all 6 native <select> elements. It needs to support: v-model with any value type (number, string, null, undefined), an array of option objects with configurable label/value keys, a placeholder, and a disabled state. The dropdown panel uses <Teleport to="body"> to avoid overflow clipping from modals and scrollable containers.
Existing patterns to follow:
- Input styling:
bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-text-primary - Focus:
border-color: var(--color-accent)withbox-shadow: 0 0 0 2px var(--color-accent-muted)(defined inmain.css:60-64) - Icons: Import from
lucide-vue-next(seeTimer.vue:121) - Animations: 200ms ease-out (see
main.css:107-119for modal-enter pattern)
Step 1: Create the component file
Write src/components/AppSelect.vue with this complete implementation:
<template>
<div ref="containerRef" class="relative">
<!-- Trigger button -->
<button
ref="triggerRef"
type="button"
@click="toggle"
@keydown="handleTriggerKeydown"
:disabled="disabled"
class="w-full flex items-center justify-between px-3 py-2 bg-bg-inset border rounded-xl text-[0.8125rem] text-left transition-colors duration-150 focus:outline-none"
:class="[
isOpen ? 'border-accent shadow-[0_0_0_2px_var(--color-accent-muted)]' : 'border-border-subtle',
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer hover:border-border-visible'
]"
>
<span :class="selectedLabel ? 'text-text-primary' : 'text-text-tertiary'">
{{ selectedLabel || placeholder }}
</span>
<ChevronDown
class="w-3.5 h-3.5 text-text-tertiary shrink-0 ml-2 transition-transform duration-150"
:class="{ 'rotate-180': isOpen }"
:stroke-width="2"
/>
</button>
<!-- Dropdown panel (teleported to body) -->
<Teleport to="body">
<div
v-if="isOpen"
ref="panelRef"
class="fixed z-[100] py-1 bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-y-auto animate-dropdown-enter"
:style="panelStyle"
@keydown="handlePanelKeydown"
>
<button
v-for="(option, index) in allOptions"
:key="getOptionValue(option)"
type="button"
@click="selectOption(option)"
@mouseenter="highlightedIndex = index"
class="w-full flex items-center justify-between px-3 py-2 text-[0.8125rem] text-left transition-colors duration-100"
:class="[
index === highlightedIndex ? 'bg-bg-elevated' : '',
isSelected(option) ? 'text-accent-text' : 'text-text-primary'
]"
>
<span>{{ getOptionLabel(option) }}</span>
<Check
v-if="isSelected(option)"
class="w-3.5 h-3.5 text-accent shrink-0 ml-2"
:stroke-width="2"
/>
</button>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'
interface Props {
modelValue: any
options: any[]
labelKey?: string
valueKey?: string
placeholder?: string
disabled?: boolean
placeholderValue?: any
}
const props = withDefaults(defineProps<Props>(), {
labelKey: 'name',
valueKey: 'id',
placeholder: 'Select...',
disabled: false,
placeholderValue: undefined
})
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLButtonElement>()
const panelRef = ref<HTMLElement>()
const isOpen = ref(false)
const highlightedIndex = ref(0)
const panelStyle = ref<Record<string, string>>({})
// Build full options list (placeholder option + real options)
const allOptions = computed(() => {
if (props.placeholder) {
return [{ __placeholder: true, [props.labelKey]: props.placeholder, [props.valueKey]: props.placeholderValue }, ...props.options]
}
return props.options
})
// Get option value
function getOptionValue(option: any): any {
if (option.__placeholder) return props.placeholderValue
return option[props.valueKey]
}
// Get option label
function getOptionLabel(option: any): string {
return option[props.labelKey] || ''
}
// Check if option is selected
function isSelected(option: any): boolean {
return getOptionValue(option) === props.modelValue
}
// Selected label for trigger display
const selectedLabel = computed(() => {
if (props.modelValue === null || props.modelValue === undefined || props.modelValue === props.placeholderValue) {
return ''
}
const found = props.options.find(o => o[props.valueKey] === props.modelValue)
return found ? found[props.labelKey] : ''
})
// Position the panel below the trigger
function positionPanel() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
panelStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
maxHeight: '240px'
}
}
// Toggle open/close
function toggle() {
if (props.disabled) return
if (isOpen.value) {
close()
} else {
open()
}
}
function open() {
isOpen.value = true
// Set highlighted to current selection
const idx = allOptions.value.findIndex(o => isSelected(o))
highlightedIndex.value = idx >= 0 ? idx : 0
nextTick(() => {
positionPanel()
document.addEventListener('click', handleClickOutside, true)
document.addEventListener('scroll', positionPanel, true)
window.addEventListener('resize', positionPanel)
})
}
function close() {
isOpen.value = false
document.removeEventListener('click', handleClickOutside, true)
document.removeEventListener('scroll', positionPanel, true)
window.removeEventListener('resize', positionPanel)
}
function selectOption(option: any) {
emit('update:modelValue', getOptionValue(option))
close()
triggerRef.value?.focus()
}
// Click outside detection
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node
if (
containerRef.value && !containerRef.value.contains(target) &&
panelRef.value && !panelRef.value.contains(target)
) {
close()
}
}
// Keyboard handling on trigger
function handleTriggerKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (!isOpen.value) {
open()
}
}
if (e.key === 'Escape') {
close()
}
}
// Keyboard handling on panel
function handlePanelKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
highlightedIndex.value = Math.min(highlightedIndex.value + 1, allOptions.value.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
selectOption(allOptions.value[highlightedIndex.value])
} else if (e.key === 'Escape') {
close()
triggerRef.value?.focus()
}
}
// Cleanup on unmount
onBeforeUnmount(() => {
close()
})
</script>
Step 2: Add dropdown animation to main.css
In src/styles/main.css, add after the existing animate-modal-enter block (after line 120):
/* Dropdown animations */
@keyframes dropdown-enter {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-dropdown-enter {
animation: dropdown-enter 150ms ease-out;
}
Step 3: Verify it builds
Run: npx vite build
Expected: Build succeeds (component isn't used yet, but it must compile)
Step 4: Commit
git add src/components/AppSelect.vue src/styles/main.css
git commit -m "feat: add AppSelect custom dropdown component"
Task 2: Create AppDatePicker Component
Files:
- Create:
src/components/AppDatePicker.vue
Context: This component replaces all 6 native <input type="date"> elements. The v-model is a YYYY-MM-DD string (same format as native date input). The calendar popover is teleported to body. It needs month navigation, today highlighting, and selected-day highlighting.
Step 1: Create the component file
Write src/components/AppDatePicker.vue with this complete implementation:
<template>
<div ref="containerRef" class="relative">
<!-- Trigger -->
<button
ref="triggerRef"
type="button"
@click="toggle"
@keydown.escape="close"
class="w-full flex items-center justify-between px-3 py-2 bg-bg-inset border rounded-xl text-[0.8125rem] text-left transition-colors duration-150 focus:outline-none"
:class="[
isOpen ? 'border-accent shadow-[0_0_0_2px_var(--color-accent-muted)]' : 'border-border-subtle',
'cursor-pointer hover:border-border-visible'
]"
>
<span :class="modelValue ? 'text-text-primary' : 'text-text-tertiary'">
{{ displayValue || placeholder }}
</span>
<CalendarIcon
class="w-3.5 h-3.5 text-text-tertiary shrink-0 ml-2"
:stroke-width="2"
/>
</button>
<!-- Calendar popover (teleported to body) -->
<Teleport to="body">
<div
v-if="isOpen"
ref="panelRef"
class="fixed z-[100] p-3 bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] animate-dropdown-enter"
:style="panelStyle"
>
<!-- Month/Year header -->
<div class="flex items-center justify-between mb-3">
<button
type="button"
@click="prevMonth"
class="p-1 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-bg-elevated transition-colors"
>
<ChevronLeft class="w-4 h-4" :stroke-width="2" />
</button>
<span class="text-[0.8125rem] font-medium text-text-primary">
{{ monthNames[viewMonth] }} {{ viewYear }}
</span>
<button
type="button"
@click="nextMonth"
class="p-1 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-bg-elevated transition-colors"
>
<ChevronRight class="w-4 h-4" :stroke-width="2" />
</button>
</div>
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 mb-1">
<div
v-for="day in dayHeaders"
:key="day"
class="text-center text-[0.625rem] text-text-tertiary uppercase tracking-wider py-1"
>
{{ day }}
</div>
</div>
<!-- Day grid -->
<div class="grid grid-cols-7">
<button
v-for="(cell, index) in calendarCells"
:key="index"
type="button"
@click="cell.currentMonth ? selectDay(cell.day) : undefined"
class="w-8 h-8 flex items-center justify-center text-[0.75rem] rounded-lg transition-colors duration-100"
:class="getDayCellClasses(cell)"
:disabled="!cell.currentMonth"
>
{{ cell.day }}
</button>
</div>
<!-- Today shortcut -->
<div class="mt-2 pt-2 border-t border-border-subtle">
<button
type="button"
@click="selectToday"
class="w-full text-center text-[0.75rem] text-text-secondary hover:text-accent-text py-1 transition-colors"
>
Today
</button>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-vue-next'
interface Props {
modelValue: string // YYYY-MM-DD format
placeholder?: string
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select date'
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLButtonElement>()
const panelRef = ref<HTMLElement>()
const isOpen = ref(false)
const panelStyle = ref<Record<string, string>>({})
// Calendar view state
const today = new Date()
const viewYear = ref(today.getFullYear())
const viewMonth = ref(today.getMonth())
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
]
const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
// Display value for trigger
const displayValue = computed(() => {
if (!props.modelValue) return ''
const [year, month, day] = props.modelValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
// When modelValue changes, update the view to show that month
watch(() => props.modelValue, (val) => {
if (val) {
const [year, month] = val.split('-').map(Number)
viewYear.value = year
viewMonth.value = month - 1
}
}, { immediate: true })
interface CalendarCell {
day: number
currentMonth: boolean
dateStr: string
}
// Generate calendar grid cells
const calendarCells = computed((): CalendarCell[] => {
const cells: CalendarCell[] = []
const year = viewYear.value
const month = viewMonth.value
// First day of the month (0=Sun, 1=Mon, ...)
const firstDay = new Date(year, month, 1).getDay()
// Convert to Monday-start (Mon=0, Tue=1, ..., Sun=6)
const startOffset = firstDay === 0 ? 6 : firstDay - 1
// Days in current month
const daysInMonth = new Date(year, month + 1, 0).getDate()
// Days in previous month
const daysInPrevMonth = new Date(year, month, 0).getDate()
// Previous month padding
for (let i = startOffset - 1; i >= 0; i--) {
const day = daysInPrevMonth - i
const prevMonth = month === 0 ? 11 : month - 1
const prevYear = month === 0 ? year - 1 : year
cells.push({
day,
currentMonth: false,
dateStr: formatDateStr(prevYear, prevMonth, day)
})
}
// Current month days
for (let day = 1; day <= daysInMonth; day++) {
cells.push({
day,
currentMonth: true,
dateStr: formatDateStr(year, month, day)
})
}
// Next month padding (fill to 42 cells = 6 rows)
const remaining = 42 - cells.length
for (let day = 1; day <= remaining; day++) {
const nextMonth = month === 11 ? 0 : month + 1
const nextYear = month === 11 ? year + 1 : year
cells.push({
day,
currentMonth: false,
dateStr: formatDateStr(nextYear, nextMonth, day)
})
}
return cells
})
function formatDateStr(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
// Get CSS classes for a day cell
function getDayCellClasses(cell: CalendarCell): string {
const classes: string[] = []
if (!cell.currentMonth) {
classes.push('text-text-tertiary/40 cursor-default')
return classes.join(' ')
}
// Selected
if (cell.dateStr === props.modelValue) {
classes.push('bg-accent text-bg-base font-medium')
return classes.join(' ')
}
// Today
const todayStr = formatDateStr(today.getFullYear(), today.getMonth(), today.getDate())
if (cell.dateStr === todayStr) {
classes.push('ring-1 ring-accent text-accent-text')
} else {
classes.push('text-text-primary')
}
classes.push('hover:bg-bg-elevated cursor-pointer')
return classes.join(' ')
}
// Month navigation
function prevMonth() {
if (viewMonth.value === 0) {
viewMonth.value = 11
viewYear.value--
} else {
viewMonth.value--
}
}
function nextMonth() {
if (viewMonth.value === 11) {
viewMonth.value = 0
viewYear.value++
} else {
viewMonth.value++
}
}
// Select a day
function selectDay(day: number) {
const dateStr = formatDateStr(viewYear.value, viewMonth.value, day)
emit('update:modelValue', dateStr)
close()
}
// Select today shortcut
function selectToday() {
const dateStr = formatDateStr(today.getFullYear(), today.getMonth(), today.getDate())
emit('update:modelValue', dateStr)
viewYear.value = today.getFullYear()
viewMonth.value = today.getMonth()
close()
}
// Position the panel below the trigger
function positionPanel() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const panelWidth = 280
// Ensure panel doesn't overflow right edge
let left = rect.left
if (left + panelWidth > window.innerWidth - 8) {
left = window.innerWidth - panelWidth - 8
}
panelStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${left}px`,
width: `${panelWidth}px`
}
}
function toggle() {
if (isOpen.value) {
close()
} else {
open()
}
}
function open() {
isOpen.value = true
nextTick(() => {
positionPanel()
document.addEventListener('click', handleClickOutside, true)
document.addEventListener('scroll', positionPanel, true)
window.addEventListener('resize', positionPanel)
})
}
function close() {
isOpen.value = false
document.removeEventListener('click', handleClickOutside, true)
document.removeEventListener('scroll', positionPanel, true)
window.removeEventListener('resize', positionPanel)
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node
if (
containerRef.value && !containerRef.value.contains(target) &&
panelRef.value && !panelRef.value.contains(target)
) {
close()
}
}
onBeforeUnmount(() => {
close()
})
</script>
Step 2: Verify it builds
Run: npx vite build
Expected: Build succeeds
Step 3: Commit
git add src/components/AppDatePicker.vue
git commit -m "feat: add AppDatePicker custom calendar component"
Task 3: Replace Selects in Timer.vue
Files:
- Modify:
src/views/Timer.vue:29-59(template selects),src/views/Timer.vue:121(imports)
Context: Timer.vue has 2 native selects:
- Project selector (line 29-42):
v-model="selectedProject", options fromactiveProjects, disabled whentimerStore.isRunning - Task selector (line 46-59):
v-model="selectedTask", options fromprojectTasks, disabled whentimerStore.isRunning || !selectedProject
Step 1: Add import
In the <script setup> imports section (line 121), add AppSelect:
Change:
import { Timer as TimerIcon } from 'lucide-vue-next'
To:
import { Timer as TimerIcon } from 'lucide-vue-next'
import AppSelect from '../components/AppSelect.vue'
Step 2: Replace project select (lines 29-42)
Replace:
<select
v-model="selectedProject"
:disabled="timerStore.isRunning"
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 disabled:opacity-40 disabled:cursor-not-allowed"
>
<option :value="null">Select project</option>
<option
v-for="project in activeProjects"
:key="project.id"
:value="project.id"
>
{{ project.name }}
</option>
</select>
With:
<AppSelect
v-model="selectedProject"
:options="activeProjects"
label-key="name"
value-key="id"
placeholder="Select project"
:placeholder-value="null"
:disabled="timerStore.isRunning"
/>
Step 3: Replace task select (lines 46-59)
Replace:
<select
v-model="selectedTask"
:disabled="timerStore.isRunning || !selectedProject"
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 disabled:opacity-40 disabled:cursor-not-allowed"
>
<option :value="null">Select task</option>
<option
v-for="task in projectTasks"
:key="task.id"
:value="task.id"
>
{{ task.name }}
</option>
</select>
With:
<AppSelect
v-model="selectedTask"
:options="projectTasks"
label-key="name"
value-key="id"
placeholder="Select task"
:placeholder-value="null"
:disabled="timerStore.isRunning || !selectedProject"
/>
Step 4: Verify it builds
Run: npx vite build
Expected: Build succeeds
Step 5: Commit
git add src/views/Timer.vue
git commit -m "feat: replace native selects with AppSelect in Timer view"
Task 4: Replace Select in Projects.vue
Files:
- Modify:
src/views/Projects.vue:95-107(template),src/views/Projects.vue:203(imports)
Context: Projects.vue has 1 native select: Client selector in the create/edit dialog (line 95-107). v-model="formData.client_id", optional with "No client" placeholder, value is undefined when no client.
Step 1: Add import
After line 203 (import { FolderKanban } from 'lucide-vue-next'), add:
import AppSelect from '../components/AppSelect.vue'
Step 2: Replace client select (lines 95-107)
Replace:
<select
v-model="formData.client_id"
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"
>
<option :value="undefined">No client</option>
<option
v-for="client in clientsStore.clients"
:key="client.id"
:value="client.id"
>
{{ client.name }}
</option>
</select>
With:
<AppSelect
v-model="formData.client_id"
:options="clientsStore.clients"
label-key="name"
value-key="id"
placeholder="No client"
:placeholder-value="undefined"
/>
Step 3: Verify and commit
Run: npx vite build
git add src/views/Projects.vue
git commit -m "feat: replace native select with AppSelect in Projects view"
Task 5: Replace Selects and Date Pickers in Entries.vue
Files:
- Modify:
src/views/Entries.vue:9-13,17-21,25-37,142-154(template),src/views/Entries.vue:241(imports)
Context: Entries.vue has:
- 2 date pickers: Start Date filter (line 9-13), End Date filter (line 17-21)
- 1 project filter select (line 25-37):
v-model="filterProject", nullable, "All Projects" placeholder - 1 project select in edit dialog (line 142-154):
v-model="editForm.project_id", required (no placeholder option)
Step 1: Add imports
After line 241 (import { List as ListIcon } from 'lucide-vue-next'), add:
import AppSelect from '../components/AppSelect.vue'
import AppDatePicker from '../components/AppDatePicker.vue'
Step 2: Replace Start Date input (lines 9-13)
Replace:
<input
v-model="startDate"
type="date"
class="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"
/>
With:
<AppDatePicker
v-model="startDate"
placeholder="Start date"
/>
Step 3: Replace End Date input (lines 17-21)
Replace:
<input
v-model="endDate"
type="date"
class="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"
/>
With:
<AppDatePicker
v-model="endDate"
placeholder="End date"
/>
Step 4: Replace project filter select (lines 25-37)
Replace:
<select
v-model="filterProject"
class="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"
>
<option :value="null">All Projects</option>
<option
v-for="project in projectsStore.projects"
:key="project.id"
:value="project.id"
>
{{ project.name }}
</option>
</select>
With:
<AppSelect
v-model="filterProject"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="All Projects"
:placeholder-value="null"
/>
Step 5: Replace project select in edit dialog (lines 142-154)
Replace:
<select
v-model="editForm.project_id"
required
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"
>
<option
v-for="project in projectsStore.projects"
:key="project.id"
:value="project.id"
>
{{ project.name }}
</option>
</select>
With:
<AppSelect
v-model="editForm.project_id"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="Select project"
/>
Step 6: Verify and commit
Run: npx vite build
git add src/views/Entries.vue
git commit -m "feat: replace native selects and date pickers with custom components in Entries view"
Task 6: Replace Date Pickers in Reports.vue
Files:
- Modify:
src/views/Reports.vue:9-13,17-21(template),src/views/Reports.vue:108(imports)
Context: Reports.vue has 2 date pickers: Start Date (line 9-13) and End Date (line 17-21). Both bind to startDate/endDate refs.
Step 1: Add import
After line 108 (import { ref, computed, onMounted } from 'vue'), on a separate line in the imports area, add:
import AppDatePicker from '../components/AppDatePicker.vue'
Step 2: Replace Start Date input (lines 9-13)
Replace:
<input
v-model="startDate"
type="date"
class="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"
/>
With:
<AppDatePicker
v-model="startDate"
placeholder="Start date"
/>
Step 3: Replace End Date input (lines 17-21)
Replace:
<input
v-model="endDate"
type="date"
class="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"
/>
With:
<AppDatePicker
v-model="endDate"
placeholder="End date"
/>
Step 4: Verify and commit
Run: npx vite build
git add src/views/Reports.vue
git commit -m "feat: replace native date pickers with AppDatePicker in Reports view"
Task 7: Replace Select and Date Pickers in Invoices.vue
Files:
- Modify:
src/views/Invoices.vue:129-142,149-154,158-162(template),src/views/Invoices.vue:342-343(imports)
Context: Invoices.vue has:
- 1 client select (line 129-142):
v-model="createForm.client_id", required, "Select a client" with value0 - 1 invoice date (line 149-154):
v-model="createForm.date", required - 1 due date (line 158-162):
v-model="createForm.due_date", optional
Step 1: Add imports
After line 343 (import { FileText } from 'lucide-vue-next'), add:
import AppSelect from '../components/AppSelect.vue'
import AppDatePicker from '../components/AppDatePicker.vue'
Step 2: Replace client select (lines 129-142)
Replace:
<select
v-model="createForm.client_id"
required
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"
>
<option :value="0">Select a client</option>
<option
v-for="client in clientsStore.clients"
:key="client.id"
:value="client.id"
>
{{ client.name }}
</option>
</select>
With:
<AppSelect
v-model="createForm.client_id"
:options="clientsStore.clients"
label-key="name"
value-key="id"
placeholder="Select a client"
:placeholder-value="0"
/>
Step 3: Replace invoice date input (lines 149-154)
Replace:
<input
v-model="createForm.date"
type="date"
required
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"
/>
With:
<AppDatePicker
v-model="createForm.date"
placeholder="Invoice date"
/>
Step 4: Replace due date input (lines 158-162)
Replace:
<input
v-model="createForm.due_date"
type="date"
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"
/>
With:
<AppDatePicker
v-model="createForm.due_date"
placeholder="Due date"
/>
Step 5: Verify and commit
Run: npx vite build
git add src/views/Invoices.vue
git commit -m "feat: replace native select and date pickers with custom components in Invoices view"
Task 8: Final Build Verification
Files: None (verification only)
Step 1: Full build
Run: npx vite build
Expected: Clean build, no errors, no warnings about unused imports.
Step 2: Verify no remaining native selects or date inputs
Run grep to confirm zero remaining instances:
grep -rn '<select' src/views/ src/components/
grep -rn 'type="date"' src/views/ src/components/
Expected: No matches (the datetime-local in Entries.vue is type="datetime-local", not type="date", so it won't match).
Step 3: Final commit if any cleanup needed
If all clean, no commit needed. The feature is complete.