chore: tidy up project structure and normalize formatting
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user