chore: tidy up project structure and normalize formatting
This commit is contained in:
@@ -217,7 +217,7 @@
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MOTION SYSTEM — Transitions & Animations
|
||||
MOTION SYSTEM - Transitions & Animations
|
||||
============================================ */
|
||||
|
||||
/* Page transitions */
|
||||
|
||||
@@ -220,7 +220,7 @@ function pageBreak(doc: jsPDF, y: number, threshold: number = 262): boolean {
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 1. CLEAN — Swiss minimalism, single blue accent
|
||||
// 1. CLEAN - Swiss minimalism, single blue accent
|
||||
// ===========================================================================
|
||||
|
||||
function renderClean(
|
||||
@@ -316,7 +316,7 @@ function renderClean(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 2. PROFESSIONAL — Navy header band, corporate polish
|
||||
// 2. PROFESSIONAL - Navy header band, corporate polish
|
||||
// ===========================================================================
|
||||
|
||||
function renderProfessional(
|
||||
@@ -402,7 +402,7 @@ function renderProfessional(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 3. BOLD — Large indigo block with oversized typography
|
||||
// 3. BOLD - Large indigo block with oversized typography
|
||||
// ===========================================================================
|
||||
|
||||
function renderBold(
|
||||
@@ -475,7 +475,7 @@ function renderBold(
|
||||
drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX)
|
||||
y += rowH
|
||||
|
||||
// Rows — no borders, no stripes, generous height
|
||||
// Rows - no borders, no stripes, generous height
|
||||
for (const item of items) {
|
||||
if (pageBreak(doc, y)) y = 20
|
||||
y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX)
|
||||
@@ -494,7 +494,7 @@ function renderBold(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 4. MINIMAL — Pure monochrome, everything centered
|
||||
// 4. MINIMAL - Pure monochrome, everything centered
|
||||
// ===========================================================================
|
||||
|
||||
function renderMinimal(
|
||||
@@ -562,7 +562,7 @@ function renderMinimal(
|
||||
drawHeaderText(doc, layout, y, rowH, c.primary, padX)
|
||||
y += rowH
|
||||
|
||||
// Rows — whitespace separation only
|
||||
// Rows - whitespace separation only
|
||||
for (const item of items) {
|
||||
if (pageBreak(doc, y)) y = 20
|
||||
y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX)
|
||||
@@ -601,7 +601,7 @@ function renderMinimal(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 5. CLASSIC — Traditional layout, burgundy accents, bordered grid
|
||||
// 5. CLASSIC - Traditional layout, burgundy accents, bordered grid
|
||||
// ===========================================================================
|
||||
|
||||
function renderClassic(
|
||||
@@ -712,7 +712,7 @@ function renderClassic(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 6. MODERN — Teal accents, borderless, teal header text
|
||||
// 6. MODERN - Teal accents, borderless, teal header text
|
||||
// ===========================================================================
|
||||
|
||||
function renderModern(
|
||||
@@ -837,7 +837,7 @@ function renderModern(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 7. ELEGANT — Gold double-rule accents, centered layout
|
||||
// 7. ELEGANT - Gold double-rule accents, centered layout
|
||||
// ===========================================================================
|
||||
|
||||
function renderElegant(
|
||||
@@ -960,7 +960,7 @@ function renderElegant(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 8. CREATIVE — Purple sidebar, card-style rows
|
||||
// 8. CREATIVE - Purple sidebar, card-style rows
|
||||
// ===========================================================================
|
||||
|
||||
function renderCreative(
|
||||
@@ -1058,7 +1058,7 @@ function renderCreative(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 9. COMPACT — Data-dense layout with tight spacing
|
||||
// 9. COMPACT - Data-dense layout with tight spacing
|
||||
// ===========================================================================
|
||||
|
||||
function renderCompact(
|
||||
@@ -1184,7 +1184,7 @@ function renderCompact(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 10. DARK — Full dark background with cyan highlights
|
||||
// 10. DARK - Full dark background with cyan highlights
|
||||
// ===========================================================================
|
||||
|
||||
function renderDark(
|
||||
@@ -1307,7 +1307,7 @@ function renderDark(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 11. VIBRANT — Coral header band, warm tones
|
||||
// 11. VIBRANT - Coral header band, warm tones
|
||||
// ===========================================================================
|
||||
|
||||
function renderVibrant(
|
||||
@@ -1390,7 +1390,7 @@ function renderVibrant(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 12. CORPORATE — Blue header with info bar below
|
||||
// 12. CORPORATE - Blue header with info bar below
|
||||
// ===========================================================================
|
||||
|
||||
function renderCorporate(
|
||||
@@ -1482,7 +1482,7 @@ function renderCorporate(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 13. FRESH — Oversized watermark invoice number
|
||||
// 13. FRESH - Oversized watermark invoice number
|
||||
// ===========================================================================
|
||||
|
||||
function renderFresh(
|
||||
@@ -1581,7 +1581,7 @@ function renderFresh(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 14. NATURAL — Warm beige full-page background, terracotta accents
|
||||
// 14. NATURAL - Warm beige full-page background, terracotta accents
|
||||
// ===========================================================================
|
||||
|
||||
function renderNatural(
|
||||
@@ -1684,7 +1684,7 @@ function renderNatural(
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 15. STATEMENT — Total-forward design, hero amount top-right
|
||||
// 15. STATEMENT - Total-forward design, hero amount top-right
|
||||
// ===========================================================================
|
||||
|
||||
function renderStatement(
|
||||
|
||||
@@ -15,10 +15,10 @@ export interface CurrencyOption {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LOCALES — ~140 worldwide locale/region combinations
|
||||
// LOCALES - ~140 worldwide locale/region combinations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LOCALES: LocaleOption[] = [
|
||||
const _LOCALES_RAW: LocaleOption[] = [
|
||||
// System default
|
||||
{ code: 'system', name: 'System Default' },
|
||||
|
||||
@@ -234,11 +234,16 @@ export const LOCALES: LocaleOption[] = [
|
||||
{ code: 'fj-FJ', name: 'Fijian (Fiji)' },
|
||||
]
|
||||
|
||||
export const LOCALES: LocaleOption[] = [
|
||||
{ code: 'system', name: 'System Default' },
|
||||
..._LOCALES_RAW.filter(l => l.code !== 'system').sort((a, b) => a.name.localeCompare(b.name)),
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FALLBACK_CURRENCIES — ~120+ currencies with English names
|
||||
// FALLBACK_CURRENCIES - ~120+ currencies with English names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FALLBACK_CURRENCIES: CurrencyOption[] = [
|
||||
const _FALLBACK_CURRENCIES_RAW: CurrencyOption[] = [
|
||||
{ code: 'USD', name: 'US Dollar' },
|
||||
{ code: 'EUR', name: 'Euro' },
|
||||
{ code: 'GBP', name: 'British Pound' },
|
||||
@@ -369,6 +374,9 @@ export const FALLBACK_CURRENCIES: CurrencyOption[] = [
|
||||
{ code: 'BND', name: 'Brunei Dollar' },
|
||||
]
|
||||
|
||||
export const FALLBACK_CURRENCIES: CurrencyOption[] =
|
||||
[..._FALLBACK_CURRENCIES_RAW].sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Currency builder (runtime Intl detection with fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -383,7 +391,7 @@ function buildCurrencies(): CurrencyOption[] {
|
||||
return codes.map((code) => ({
|
||||
code,
|
||||
name: displayNames.of(code) ?? code,
|
||||
}))
|
||||
})).sort((a, b) => a.name.localeCompare(b.name))
|
||||
} catch {
|
||||
return FALLBACK_CURRENCIES
|
||||
}
|
||||
@@ -423,7 +431,7 @@ export function getCurrencyCode(): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Short date — e.g. "Jan 5, 2025"
|
||||
* Short date - e.g. "Jan 5, 2025"
|
||||
* Parses the date-part manually to avoid timezone-shift issues.
|
||||
*/
|
||||
export function formatDate(dateString: string): string {
|
||||
@@ -437,7 +445,7 @@ export function formatDate(dateString: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Long date — e.g. "Monday, January 5, 2025"
|
||||
* Long date - e.g. "Monday, January 5, 2025"
|
||||
*/
|
||||
export function formatDateLong(dateString: string): string {
|
||||
const [year, month, day] = dateString.substring(0, 10).split('-').map(Number)
|
||||
@@ -451,7 +459,7 @@ export function formatDateLong(dateString: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Short date + time — e.g. "Jan 5, 2025, 3:42 PM"
|
||||
* Short date + time - e.g. "Jan 5, 2025, 3:42 PM"
|
||||
*/
|
||||
export function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
@@ -465,7 +473,7 @@ export function formatDateTime(dateString: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Full currency format — e.g. "$1,234.56"
|
||||
* Full currency format - e.g. "$1,234.56"
|
||||
*/
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat(getLocaleCode(), {
|
||||
@@ -485,7 +493,7 @@ export function formatCurrencyCompact(amount: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Number with locale grouping — e.g. "1,234.56"
|
||||
* Number with locale grouping - e.g. "1,234.56"
|
||||
*/
|
||||
export function formatNumber(amount: number, decimals?: number): string {
|
||||
return new Intl.NumberFormat(getLocaleCode(), {
|
||||
@@ -495,7 +503,7 @@ export function formatNumber(amount: number, decimals?: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract just the currency symbol — e.g. "$", "\u00a3", "\u20ac"
|
||||
* Extract just the currency symbol - e.g. "$", "\u00a3", "\u20ac"
|
||||
*/
|
||||
export function getCurrencySymbol(): string {
|
||||
const parts = new Intl.NumberFormat(getLocaleCode(), {
|
||||
|
||||
@@ -36,28 +36,27 @@
|
||||
<!-- Calendar Grid -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div :key="weekStart.getTime()" class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
|
||||
<!-- Day column headers -->
|
||||
<div class="grid shrink-0 border-b border-border-subtle" :style="gridStyle">
|
||||
<!-- Top-left corner (hour gutter) -->
|
||||
<div class="w-14 shrink-0 border-r border-border-subtle" />
|
||||
<!-- Day headers -->
|
||||
<div
|
||||
v-for="(day, index) in weekDays"
|
||||
:key="index"
|
||||
class="px-2 py-2.5 text-center border-r border-border-subtle last:border-r-0"
|
||||
:class="isToday(day) ? 'bg-accent/5' : ''"
|
||||
>
|
||||
<span
|
||||
class="text-[0.6875rem] uppercase tracking-[0.08em] font-medium"
|
||||
:class="isToday(day) ? 'text-accent-text' : 'text-text-tertiary'"
|
||||
>
|
||||
{{ formatDayHeader(day) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable hour rows -->
|
||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto min-h-0">
|
||||
<!-- Day column headers (sticky) -->
|
||||
<div class="grid shrink-0 border-b border-border-subtle sticky top-0 z-10 bg-bg-surface" :style="gridStyle">
|
||||
<!-- Top-left corner (hour gutter) -->
|
||||
<div class="w-14 shrink-0 border-r border-border-subtle" />
|
||||
<!-- Day headers -->
|
||||
<div
|
||||
v-for="(day, index) in weekDays"
|
||||
:key="index"
|
||||
class="px-2 py-2.5 text-center border-r border-border-subtle last:border-r-0"
|
||||
:class="isToday(day) ? 'bg-accent/5' : ''"
|
||||
>
|
||||
<span
|
||||
class="text-[0.6875rem] uppercase tracking-[0.08em] font-medium"
|
||||
:class="isToday(day) ? 'text-accent-text' : 'text-text-tertiary'"
|
||||
>
|
||||
{{ formatDayHeader(day) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid relative" :style="gridStyle">
|
||||
<!-- Hour labels column -->
|
||||
<div class="w-14 shrink-0 border-r border-border-subtle">
|
||||
@@ -138,8 +137,8 @@ const scrollContainer = ref<HTMLElement | null>(null)
|
||||
// The start-of-week date (Monday)
|
||||
const weekStart = ref(getMonday(new Date()))
|
||||
|
||||
// Hours displayed: 6am through 11pm (6..23)
|
||||
const HOUR_START = 6
|
||||
// Hours displayed: full 24h (0..23)
|
||||
const HOUR_START = 0
|
||||
const HOUR_END = 23
|
||||
const hours = Array.from({ length: HOUR_END - HOUR_START + 1 }, (_, i) => HOUR_START + i)
|
||||
const HOUR_HEIGHT = 48 // h-12 = 3rem = 48px
|
||||
@@ -251,7 +250,7 @@ function getEntryTooltip(entry: TimeEntry): string {
|
||||
const duration = formatDuration(entry.duration)
|
||||
const start = new Date(entry.start_time)
|
||||
const time = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
const desc = entry.description ? ` — ${entry.description}` : ''
|
||||
const desc = entry.description ? ` - ${entry.description}` : ''
|
||||
return `${project} (${duration}) at ${time}${desc}`
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<p class="text-xs text-text-tertiary mt-1">{{ formattedDate }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats row — 4 columns -->
|
||||
<!-- Stats row - 4 columns -->
|
||||
<div class="grid grid-cols-4 gap-6 mb-8">
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</p>
|
||||
|
||||
@@ -149,11 +149,11 @@
|
||||
>
|
||||
<p class="text-text-secondary mb-2">Available tokens:</p>
|
||||
<div class="space-y-1 font-mono text-text-tertiary">
|
||||
<p><span class="text-accent-text">{YYYY}</span> — {{ new Date().getFullYear() }}</p>
|
||||
<p><span class="text-accent-text">{YY}</span> — {{ String(new Date().getFullYear()).slice(-2) }}</p>
|
||||
<p><span class="text-accent-text">{MM}</span> — {{ String(new Date().getMonth() + 1).padStart(2, '0') }}</p>
|
||||
<p><span class="text-accent-text">{DD}</span> — {{ String(new Date().getDate()).padStart(2, '0') }}</p>
|
||||
<p><span class="text-accent-text">{###}</span> — next number ({{ String(invoicesStore.invoices.length + 1).padStart(3, '0') }})</p>
|
||||
<p><span class="text-accent-text">{YYYY}</span> - {{ new Date().getFullYear() }}</p>
|
||||
<p><span class="text-accent-text">{YY}</span> - {{ String(new Date().getFullYear()).slice(-2) }}</p>
|
||||
<p><span class="text-accent-text">{MM}</span> - {{ String(new Date().getMonth() + 1).padStart(2, '0') }}</p>
|
||||
<p><span class="text-accent-text">{DD}</span> - {{ String(new Date().getDate()).padStart(2, '0') }}</p>
|
||||
<p><span class="text-accent-text">{###}</span> - next number ({{ String(invoicesStore.invoices.length + 1).padStart(3, '0') }})</p>
|
||||
</div>
|
||||
<p class="text-text-tertiary mt-2">e.g. INV-{YYYY}-{###}</p>
|
||||
</div>
|
||||
@@ -614,7 +614,7 @@ async function importFromProject() {
|
||||
|
||||
if (totalHours > 0) {
|
||||
lineItems.value.push({
|
||||
description: `${project.name} — ${totalHours.toFixed(1)}h tracked`,
|
||||
description: `${project.name} - ${totalHours.toFixed(1)}h tracked`,
|
||||
quantity: parseFloat(totalHours.toFixed(2)),
|
||||
unit_price: project.hourly_rate
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<Transition name="fade" appear>
|
||||
<div class="p-6">
|
||||
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Reports</h1>
|
||||
|
||||
@@ -59,7 +58,7 @@
|
||||
|
||||
<!-- Hours Tab -->
|
||||
<template v-if="activeTab === 'hours'">
|
||||
<!-- Summary Stats — pure typography -->
|
||||
<!-- Summary Stats - pure typography -->
|
||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
||||
@@ -181,12 +180,12 @@
|
||||
class="border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<td class="py-3 pr-4 text-[0.8125rem] text-text-primary">{{ row.project_name }}</td>
|
||||
<td class="py-3 pr-4 text-[0.75rem] text-text-secondary">{{ row.client_name || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-[0.75rem] text-text-secondary">{{ row.client_name || '-' }}</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-primary">{{ row.total_hours.toFixed(1) }}h</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency(row.hourly_rate) }}</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(row.revenue) }}</td>
|
||||
<td class="py-3 text-right text-[0.75rem] font-mono" :class="row.budget_used_pct != null && row.budget_used_pct > 100 ? 'text-status-error' : 'text-text-secondary'">
|
||||
{{ row.budget_used_pct != null ? row.budget_used_pct.toFixed(0) + '%' : '—' }}
|
||||
{{ row.budget_used_pct != null ? row.budget_used_pct.toFixed(0) + '%' : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -199,7 +198,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Idle sub-settings — progressive disclosure -->
|
||||
<!-- Idle sub-settings - progressive disclosure -->
|
||||
<div v-if="idleDetection" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
<!-- Hero timer display -->
|
||||
<div class="text-center pt-4 pb-8">
|
||||
<div class="relative inline-block">
|
||||
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2" :class="timerPulseClass">
|
||||
<span class="text-text-primary">{{ timerParts.hours }}</span>
|
||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
||||
<span class="text-text-primary">{{ timerParts.minutes }}</span>
|
||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
||||
<span class="text-text-primary">{{ timerParts.seconds }}</span>
|
||||
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2 transition-colors duration-300" :class="[timerPulseClass, timerStore.isPaused ? 'opacity-60' : '']">
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.hours }}</span>
|
||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.minutes }}</span>
|
||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
|
||||
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.seconds }}</span>
|
||||
</p>
|
||||
<!-- Paused badge - absolutely positioned so it doesn't shift layout -->
|
||||
<span
|
||||
v-if="timerStore.isPaused"
|
||||
class="absolute -bottom-1 left-1/2 -translate-x-1/2 text-[0.625rem] font-semibold uppercase tracking-[0.15em] px-2.5 py-0.5 rounded-full transition-opacity duration-200"
|
||||
:class="timerStore.timerState === 'PAUSED_IDLE' ? 'text-status-warning bg-status-warning/10' : timerStore.timerState === 'PAUSED_MANUAL' ? 'text-status-warning bg-status-warning/10' : 'text-status-info bg-status-info/10'"
|
||||
>
|
||||
{{ timerStore.timerState === 'PAUSED_IDLE' ? 'Idle' : timerStore.timerState === 'PAUSED_MANUAL' ? 'Paused' : 'App hidden' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="timerStore.isRunning"
|
||||
v-if="!timerStore.isStopped"
|
||||
@click="openMiniTimer"
|
||||
class="absolute -right-8 top-1/2 -translate-y-1/2 p-2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
title="Pop out mini timer"
|
||||
@@ -20,23 +28,34 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Paused indicator -->
|
||||
<p
|
||||
v-if="timerStore.isPaused"
|
||||
class="text-[0.75rem] font-medium mb-4"
|
||||
:class="timerStore.timerState === 'PAUSED_IDLE' ? 'text-status-warning' : 'text-status-info'"
|
||||
>
|
||||
{{ timerStore.timerState === 'PAUSED_IDLE' ? 'Paused (idle)' : 'Paused (app not visible)' }}
|
||||
</p>
|
||||
<div v-else class="mb-4" />
|
||||
<div class="h-6" />
|
||||
|
||||
<button
|
||||
@click="toggleTimer"
|
||||
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
|
||||
:class="buttonClass"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<!-- Pause / Resume button (visible when timer is active) -->
|
||||
<button
|
||||
v-if="timerStore.isRunning"
|
||||
@click="timerStore.pauseManual()"
|
||||
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-status-warning/15 text-status-warning hover:bg-status-warning/25"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
|
||||
@click="timerStore.resumeFromPause()"
|
||||
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-accent text-bg-base hover:bg-accent-hover"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
|
||||
<!-- Start / Stop button -->
|
||||
<button
|
||||
@click="toggleTimer"
|
||||
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
|
||||
:class="buttonClass"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favorites strip -->
|
||||
|
||||
Reference in New Issue
Block a user