chore: tidy up project structure and normalize formatting

This commit is contained in:
Your Name
2026-02-19 22:43:14 +02:00
parent 47eb1af7ab
commit 3dcbd4a888
29 changed files with 385 additions and 11624 deletions

View File

@@ -25,7 +25,7 @@
<div class="flex items-start justify-between">
<div>
<h3 class="text-[0.8125rem] font-semibold text-text-primary">{{ project.name }}</h3>
<p class="text-xs text-text-secondary mt-0.5">{{ getClientName(project.client_id) }} · {{ formatCurrency(project.hourly_rate) }}/hr</p>
<p class="text-xs text-text-secondary mt-0.5">{{ getClientName(project.client_id) }} · {{ project.budget_amount ? formatCurrency(project.budget_amount) + ' fixed' : formatCurrency(project.hourly_rate) + '/hr' }}</p>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-100">
<button
@@ -86,82 +86,174 @@
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="tryCloseDialog"
>
<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 max-h-[calc(100vh-2rem)] overflow-y-auto">
<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-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
{{ editingProject ? 'Edit Project' : 'Create Project' }}
</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Name -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Name *</label>
<input
v-model="formData.name"
type="text"
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"
placeholder="Project name"
/>
</div>
<!-- Two-column layout: Identity | Billing -->
<div class="grid grid-cols-2 gap-6">
<!-- Left column: Identity -->
<div class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Name *</label>
<input
v-model="formData.name"
type="text"
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"
placeholder="Project name"
/>
</div>
<!-- Client -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client</label>
<AppSelect
v-model="formData.client_id"
:options="clientsStore.clients"
label-key="name"
value-key="id"
placeholder="No client"
:placeholder-value="undefined"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client</label>
<AppSelect
v-model="formData.client_id"
:options="clientsStore.clients"
label-key="name"
value-key="id"
placeholder="No client"
:placeholder-value="undefined"
/>
</div>
<!-- Hourly Rate -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Hourly Rate ({{ getCurrencySymbol() }})</label>
<AppNumberInput
v-model="formData.hourly_rate"
:min="0"
:step="1"
:precision="2"
:prefix="getCurrencySymbol()"
/>
</div>
<!-- Color -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Color</label>
<AppColorPicker
v-model="formData.color"
:presets="colorPresets"
/>
</div>
<!-- Budget -->
<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">Budget Hours</label>
<AppNumberInput
:model-value="formData.budget_hours ?? 0"
@update:model-value="formData.budget_hours = $event || null"
:min="0"
:step="1"
:precision="0"
placeholder="No limit"
/>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Color</label>
<AppColorPicker
v-model="formData.color"
:presets="colorPresets"
/>
</div>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Amount</label>
<AppNumberInput
:model-value="formData.budget_amount ?? 0"
@update:model-value="formData.budget_amount = $event || null"
:min="0"
:step="100"
:precision="2"
prefix="$"
placeholder="No limit"
<!-- Right column: Billing -->
<div class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Billing Type</label>
<div class="flex rounded-lg border border-border-subtle overflow-hidden">
<button
type="button"
@click="billingType = 'hourly'"
class="flex-1 px-3 py-1.5 text-[0.75rem] font-medium transition-colors duration-150"
:class="billingType === 'hourly' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:bg-bg-elevated'"
>
Hourly
</button>
<button
type="button"
@click="billingType = 'fixed'"
class="flex-1 px-3 py-1.5 text-[0.75rem] font-medium transition-colors duration-150 border-l border-border-subtle"
:class="billingType === 'fixed' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:bg-bg-elevated'"
>
Fixed Budget
</button>
</div>
</div>
<!-- Hourly billing fields -->
<template v-if="billingType === 'hourly'">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Hourly Rate ({{ getCurrencySymbol() }})</label>
<AppNumberInput
v-model="formData.hourly_rate"
:min="0"
:step="1"
:precision="2"
:prefix="getCurrencySymbol()"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Hours</label>
<AppNumberInput
:model-value="formData.budget_hours ?? 0"
@update:model-value="formData.budget_hours = $event || null"
:min="0"
:step="1"
:precision="0"
placeholder="No limit"
/>
</div>
</template>
<!-- Fixed budget fields -->
<template v-if="billingType === 'fixed'">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Amount ({{ getCurrencySymbol() }})</label>
<AppNumberInput
:model-value="formData.budget_amount ?? 0"
@update:model-value="formData.budget_amount = $event || null"
:min="0"
:step="100"
:precision="2"
:prefix="getCurrencySymbol()"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Hours</label>
<AppNumberInput
:model-value="formData.budget_hours ?? 0"
@update:model-value="formData.budget_hours = $event || null"
:min="0"
:step="1"
:precision="0"
placeholder="No limit"
/>
</div>
</template>
</div>
</div>
<!-- Tasks -->
<div class="border border-border-subtle rounded-lg overflow-hidden">
<button
type="button"
@click="tasksExpanded = !tasksExpanded"
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>Tasks ({{ allTasks.length }})</span>
<ChevronDown
class="w-4 h-4 text-text-tertiary transition-transform duration-200"
:class="{ 'rotate-180': tasksExpanded }"
:stroke-width="1.5"
/>
</button>
<div v-if="tasksExpanded" class="border-t border-border-subtle px-3 py-3 space-y-2.5">
<div v-if="allTasks.length > 0" class="space-y-1.5">
<div
v-for="(task, i) in allTasks"
:key="task.id ?? `pending-${i}`"
class="flex items-center justify-between gap-2 px-2.5 py-1.5 bg-bg-inset rounded-lg"
>
<span class="text-[0.75rem] text-text-primary truncate flex-1">{{ task.name }}</span>
<button
type="button"
@click="removeTask(task, i)"
class="p-1 text-text-tertiary hover:text-status-error transition-colors shrink-0"
>
<X class="w-3.5 h-3.5" :stroke-width="1.5" />
</button>
</div>
</div>
<p v-else class="text-[0.6875rem] text-text-tertiary">No tasks. Add tasks to break down project work.</p>
<div class="flex items-center gap-2 pt-1">
<input
v-model="newTaskName"
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="New task name..."
@keydown.enter.prevent="addTask"
/>
<button
type="button"
@click="addTask"
:disabled="!newTaskName.trim()"
class="px-3 py-1.5 bg-accent text-bg-base text-[0.75rem] font-medium rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
</div>
</div>
</div>
@@ -288,7 +380,7 @@ import AppSelect from '../components/AppSelect.vue'
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
import RunningAppsPicker from '../components/RunningAppsPicker.vue'
import AppColorPicker from '../components/AppColorPicker.vue'
import { useProjectsStore, type Project } from '../stores/projects'
import { useProjectsStore, type Project, type Task } from '../stores/projects'
import { useClientsStore } from '../stores/clients'
import { useSettingsStore } from '../stores/settings'
import { formatCurrency, getCurrencySymbol } from '../utils/locale'
@@ -326,6 +418,54 @@ const showDeleteDialog = ref(false)
const editingProject = ref<Project | null>(null)
const projectToDelete = ref<Project | null>(null)
// Billing type
const billingType = ref<'hourly' | 'fixed'>('hourly')
// Task state
const projectTasksList = ref<Task[]>([])
const pendingNewTasks = ref<string[]>([])
const pendingRemoveTaskIds = ref<number[]>([])
const newTaskName = ref('')
const tasksExpanded = ref(false)
const allTasks = computed(() => {
const existing = projectTasksList.value.filter(t => !pendingRemoveTaskIds.value.includes(t.id!))
const pending = pendingNewTasks.value.map(name => ({ name, project_id: 0 } as Task))
return [...existing, ...pending]
})
async function loadProjectTasks(projectId: number) {
projectTasksList.value = await projectsStore.fetchTasks(projectId)
}
function addTask() {
const name = newTaskName.value.trim()
if (!name) return
pendingNewTasks.value.push(name)
newTaskName.value = ''
}
function removeTask(task: Task, index: number) {
if (task.id) {
pendingRemoveTaskIds.value.push(task.id)
} else {
// It's a pending task - index offset by existing tasks count
const existingCount = projectTasksList.value.filter(t => !pendingRemoveTaskIds.value.includes(t.id!)).length
pendingNewTasks.value.splice(index - existingCount, 1)
}
}
async function saveTasks(projectId: number) {
for (const id of pendingRemoveTaskIds.value) {
await projectsStore.deleteTask(id)
}
for (const name of pendingNewTasks.value) {
await projectsStore.createTask({ project_id: projectId, name })
}
pendingNewTasks.value = []
pendingRemoveTaskIds.value = []
}
// Tracked apps state
const trackedApps = ref<TrackedApp[]>([])
const pendingAddApps = ref<TrackedApp[]>([])
@@ -455,10 +595,16 @@ function openCreateDialog() {
formData.archived = false
formData.budget_hours = null
formData.budget_amount = null
billingType.value = 'hourly'
trackedApps.value = []
pendingAddApps.value = []
pendingRemoveIds.value = []
trackedAppsExpanded.value = false
projectTasksList.value = []
pendingNewTasks.value = []
pendingRemoveTaskIds.value = []
newTaskName.value = ''
tasksExpanded.value = false
snapshotForm(getFormData())
showDialog.value = true
}
@@ -474,12 +620,21 @@ async function openEditDialog(project: Project) {
formData.archived = project.archived
formData.budget_hours = project.budget_hours ?? null
formData.budget_amount = project.budget_amount ?? null
// Infer billing type from existing data
billingType.value = (project.budget_amount && project.budget_amount > 0) ? 'fixed' : 'hourly'
pendingAddApps.value = []
pendingRemoveIds.value = []
trackedAppsExpanded.value = false
projectTasksList.value = []
pendingNewTasks.value = []
pendingRemoveTaskIds.value = []
newTaskName.value = ''
tasksExpanded.value = false
if (project.id) {
await loadTrackedApps(project.id)
await loadProjectTasks(project.id)
if (trackedApps.value.length > 0) trackedAppsExpanded.value = true
if (projectTasksList.value.length > 0) tasksExpanded.value = true
} else {
trackedApps.value = []
}
@@ -495,6 +650,13 @@ function closeDialog() {
// Handle form submit
async function handleSubmit() {
// Clear irrelevant billing fields based on type
if (billingType.value === 'hourly') {
formData.budget_amount = null
} else {
formData.hourly_rate = 0
}
let projectId: number | undefined
if (editingProject.value) {
await projectsStore.updateProject({ ...formData })
@@ -504,6 +666,7 @@ async function handleSubmit() {
}
if (projectId) {
await saveTrackedApps(projectId)
await saveTasks(projectId)
}
closeDialog()
}