tidy up project structure and normalize formatting
This commit is contained in:
38
.gitignore
vendored
38
.gitignore
vendored
@@ -1 +1,39 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
dist
|
||||||
|
trash
|
||||||
|
|
||||||
|
# Rust/Tauri build artifacts
|
||||||
|
src-tauri/target
|
||||||
|
src-tauri/gen
|
||||||
|
|
||||||
|
# AI/LLM tools
|
||||||
|
.claude
|
||||||
|
.claude/*
|
||||||
|
CLAUDE.md
|
||||||
|
.cursorrules
|
||||||
|
.cursor
|
||||||
|
.cursor/
|
||||||
|
.copilot
|
||||||
|
.copilot/
|
||||||
|
.github/copilot
|
||||||
|
.aider*
|
||||||
|
.aiderignore
|
||||||
|
.continue
|
||||||
|
.continue/
|
||||||
|
.ai
|
||||||
|
.ai/
|
||||||
|
.llm
|
||||||
|
.llm/
|
||||||
|
.windsurf
|
||||||
|
.windsurf/
|
||||||
|
.codeium
|
||||||
|
.codeium/
|
||||||
|
.tabnine
|
||||||
|
.tabnine/
|
||||||
|
.sourcery
|
||||||
|
.sourcery/
|
||||||
|
cursor.rules
|
||||||
|
.bolt
|
||||||
|
.bolt/
|
||||||
|
.v0
|
||||||
|
.v0/
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Migrate clients table — add new columns (safe to re-run)
|
// Migrate clients table - add new columns (safe to re-run)
|
||||||
let migration_columns = [
|
let migration_columns = [
|
||||||
"ALTER TABLE clients ADD COLUMN company TEXT",
|
"ALTER TABLE clients ADD COLUMN company TEXT",
|
||||||
"ALTER TABLE clients ADD COLUMN phone TEXT",
|
"ALTER TABLE clients ADD COLUMN phone TEXT",
|
||||||
@@ -46,7 +46,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Migrate projects table — add budget columns (safe to re-run)
|
// Migrate projects table - add budget columns (safe to re-run)
|
||||||
let project_migrations = [
|
let project_migrations = [
|
||||||
"ALTER TABLE projects ADD COLUMN budget_hours REAL DEFAULT NULL",
|
"ALTER TABLE projects ADD COLUMN budget_hours REAL DEFAULT NULL",
|
||||||
"ALTER TABLE projects ADD COLUMN budget_amount REAL DEFAULT NULL",
|
"ALTER TABLE projects ADD COLUMN budget_amount REAL DEFAULT NULL",
|
||||||
@@ -111,7 +111,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Migrate invoices table — add template_id column (safe to re-run)
|
// Migrate invoices table - add template_id column (safe to re-run)
|
||||||
let invoice_migrations = [
|
let invoice_migrations = [
|
||||||
"ALTER TABLE invoices ADD COLUMN template_id TEXT DEFAULT 'clean'",
|
"ALTER TABLE invoices ADD COLUMN template_id TEXT DEFAULT 'clean'",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -217,7 +217,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
MOTION SYSTEM — Transitions & Animations
|
MOTION SYSTEM - Transitions & Animations
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
/* Page transitions */
|
/* 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(
|
function renderClean(
|
||||||
@@ -316,7 +316,7 @@ function renderClean(
|
|||||||
|
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 2. PROFESSIONAL — Navy header band, corporate polish
|
// 2. PROFESSIONAL - Navy header band, corporate polish
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
function renderProfessional(
|
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(
|
function renderBold(
|
||||||
@@ -475,7 +475,7 @@ function renderBold(
|
|||||||
drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX)
|
drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX)
|
||||||
y += rowH
|
y += rowH
|
||||||
|
|
||||||
// Rows — no borders, no stripes, generous height
|
// Rows - no borders, no stripes, generous height
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (pageBreak(doc, y)) y = 20
|
if (pageBreak(doc, y)) y = 20
|
||||||
y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX)
|
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(
|
function renderMinimal(
|
||||||
@@ -562,7 +562,7 @@ function renderMinimal(
|
|||||||
drawHeaderText(doc, layout, y, rowH, c.primary, padX)
|
drawHeaderText(doc, layout, y, rowH, c.primary, padX)
|
||||||
y += rowH
|
y += rowH
|
||||||
|
|
||||||
// Rows — whitespace separation only
|
// Rows - whitespace separation only
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (pageBreak(doc, y)) y = 20
|
if (pageBreak(doc, y)) y = 20
|
||||||
y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX)
|
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(
|
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(
|
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(
|
function renderElegant(
|
||||||
@@ -960,7 +960,7 @@ function renderElegant(
|
|||||||
|
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 8. CREATIVE — Purple sidebar, card-style rows
|
// 8. CREATIVE - Purple sidebar, card-style rows
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
function renderCreative(
|
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(
|
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(
|
function renderDark(
|
||||||
@@ -1307,7 +1307,7 @@ function renderDark(
|
|||||||
|
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 11. VIBRANT — Coral header band, warm tones
|
// 11. VIBRANT - Coral header band, warm tones
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
function renderVibrant(
|
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(
|
function renderCorporate(
|
||||||
@@ -1482,7 +1482,7 @@ function renderCorporate(
|
|||||||
|
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 13. FRESH — Oversized watermark invoice number
|
// 13. FRESH - Oversized watermark invoice number
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
function renderFresh(
|
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(
|
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(
|
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
|
// System default
|
||||||
{ code: 'system', name: 'System Default' },
|
{ code: 'system', name: 'System Default' },
|
||||||
|
|
||||||
@@ -234,11 +234,16 @@ export const LOCALES: LocaleOption[] = [
|
|||||||
{ code: 'fj-FJ', name: 'Fijian (Fiji)' },
|
{ 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: 'USD', name: 'US Dollar' },
|
||||||
{ code: 'EUR', name: 'Euro' },
|
{ code: 'EUR', name: 'Euro' },
|
||||||
{ code: 'GBP', name: 'British Pound' },
|
{ code: 'GBP', name: 'British Pound' },
|
||||||
@@ -369,6 +374,9 @@ export const FALLBACK_CURRENCIES: CurrencyOption[] = [
|
|||||||
{ code: 'BND', name: 'Brunei Dollar' },
|
{ 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)
|
// Currency builder (runtime Intl detection with fallback)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -383,7 +391,7 @@ function buildCurrencies(): CurrencyOption[] {
|
|||||||
return codes.map((code) => ({
|
return codes.map((code) => ({
|
||||||
code,
|
code,
|
||||||
name: displayNames.of(code) ?? code,
|
name: displayNames.of(code) ?? code,
|
||||||
}))
|
})).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
} catch {
|
} catch {
|
||||||
return FALLBACK_CURRENCIES
|
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.
|
* Parses the date-part manually to avoid timezone-shift issues.
|
||||||
*/
|
*/
|
||||||
export function formatDate(dateString: string): string {
|
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 {
|
export function formatDateLong(dateString: string): string {
|
||||||
const [year, month, day] = dateString.substring(0, 10).split('-').map(Number)
|
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 {
|
export function formatDateTime(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
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 {
|
export function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat(getLocaleCode(), {
|
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 {
|
export function formatNumber(amount: number, decimals?: number): string {
|
||||||
return new Intl.NumberFormat(getLocaleCode(), {
|
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 {
|
export function getCurrencySymbol(): string {
|
||||||
const parts = new Intl.NumberFormat(getLocaleCode(), {
|
const parts = new Intl.NumberFormat(getLocaleCode(), {
|
||||||
|
|||||||
@@ -36,8 +36,10 @@
|
|||||||
<!-- Calendar Grid -->
|
<!-- Calendar Grid -->
|
||||||
<Transition name="fade" mode="out-in">
|
<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">
|
<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 -->
|
<!-- Scrollable hour rows -->
|
||||||
<div class="grid shrink-0 border-b border-border-subtle" :style="gridStyle">
|
<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) -->
|
<!-- Top-left corner (hour gutter) -->
|
||||||
<div class="w-14 shrink-0 border-r border-border-subtle" />
|
<div class="w-14 shrink-0 border-r border-border-subtle" />
|
||||||
<!-- Day headers -->
|
<!-- Day headers -->
|
||||||
@@ -55,9 +57,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable hour rows -->
|
|
||||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto min-h-0">
|
|
||||||
<div class="grid relative" :style="gridStyle">
|
<div class="grid relative" :style="gridStyle">
|
||||||
<!-- Hour labels column -->
|
<!-- Hour labels column -->
|
||||||
<div class="w-14 shrink-0 border-r border-border-subtle">
|
<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)
|
// The start-of-week date (Monday)
|
||||||
const weekStart = ref(getMonday(new Date()))
|
const weekStart = ref(getMonday(new Date()))
|
||||||
|
|
||||||
// Hours displayed: 6am through 11pm (6..23)
|
// Hours displayed: full 24h (0..23)
|
||||||
const HOUR_START = 6
|
const HOUR_START = 0
|
||||||
const HOUR_END = 23
|
const HOUR_END = 23
|
||||||
const hours = Array.from({ length: HOUR_END - HOUR_START + 1 }, (_, i) => HOUR_START + i)
|
const hours = Array.from({ length: HOUR_END - HOUR_START + 1 }, (_, i) => HOUR_START + i)
|
||||||
const HOUR_HEIGHT = 48 // h-12 = 3rem = 48px
|
const HOUR_HEIGHT = 48 // h-12 = 3rem = 48px
|
||||||
@@ -251,7 +250,7 @@ function getEntryTooltip(entry: TimeEntry): string {
|
|||||||
const duration = formatDuration(entry.duration)
|
const duration = formatDuration(entry.duration)
|
||||||
const start = new Date(entry.start_time)
|
const start = new Date(entry.start_time)
|
||||||
const time = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
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}`
|
return `${project} (${duration}) at ${time}${desc}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<p class="text-xs text-text-tertiary mt-1">{{ formattedDate }}</p>
|
<p class="text-xs text-text-tertiary mt-1">{{ formattedDate }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats row — 4 columns -->
|
<!-- Stats row - 4 columns -->
|
||||||
<div class="grid grid-cols-4 gap-6 mb-8">
|
<div class="grid grid-cols-4 gap-6 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</p>
|
<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>
|
<p class="text-text-secondary mb-2">Available tokens:</p>
|
||||||
<div class="space-y-1 font-mono text-text-tertiary">
|
<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">{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">{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">{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">{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">{###}</span> - next number ({{ String(invoicesStore.invoices.length + 1).padStart(3, '0') }})</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-text-tertiary mt-2">e.g. INV-{YYYY}-{###}</p>
|
<p class="text-text-tertiary mt-2">e.g. INV-{YYYY}-{###}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -614,7 +614,7 @@ async function importFromProject() {
|
|||||||
|
|
||||||
if (totalHours > 0) {
|
if (totalHours > 0) {
|
||||||
lineItems.value.push({
|
lineItems.value.push({
|
||||||
description: `${project.name} — ${totalHours.toFixed(1)}h tracked`,
|
description: `${project.name} - ${totalHours.toFixed(1)}h tracked`,
|
||||||
quantity: parseFloat(totalHours.toFixed(2)),
|
quantity: parseFloat(totalHours.toFixed(2)),
|
||||||
unit_price: project.hourly_rate
|
unit_price: project.hourly_rate
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-[0.8125rem] font-semibold text-text-primary">{{ project.name }}</h3>
|
<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>
|
||||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-100">
|
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-100">
|
||||||
<button
|
<button
|
||||||
@@ -86,13 +86,16 @@
|
|||||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||||
@click.self="tryCloseDialog"
|
@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">
|
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
|
||||||
{{ editingProject ? 'Edit Project' : 'Create Project' }}
|
{{ editingProject ? 'Edit Project' : 'Create Project' }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
<!-- Name -->
|
<!-- Two-column layout: Identity | Billing -->
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
|
<!-- Left column: Identity -->
|
||||||
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Name *</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Name *</label>
|
||||||
<input
|
<input
|
||||||
@@ -104,7 +107,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Client -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client</label>
|
||||||
<AppSelect
|
<AppSelect
|
||||||
@@ -117,7 +119,41 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hourly Rate -->
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Hourly Rate ({{ getCurrencySymbol() }})</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Hourly Rate ({{ getCurrencySymbol() }})</label>
|
||||||
<AppNumberInput
|
<AppNumberInput
|
||||||
@@ -128,18 +164,6 @@
|
|||||||
:prefix="getCurrencySymbol()"
|
:prefix="getCurrencySymbol()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Hours</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Hours</label>
|
||||||
<AppNumberInput
|
<AppNumberInput
|
||||||
@@ -151,18 +175,86 @@
|
|||||||
placeholder="No limit"
|
placeholder="No limit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Fixed budget fields -->
|
||||||
|
<template v-if="billingType === 'fixed'">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Amount</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Amount ({{ getCurrencySymbol() }})</label>
|
||||||
<AppNumberInput
|
<AppNumberInput
|
||||||
:model-value="formData.budget_amount ?? 0"
|
:model-value="formData.budget_amount ?? 0"
|
||||||
@update:model-value="formData.budget_amount = $event || null"
|
@update:model-value="formData.budget_amount = $event || null"
|
||||||
:min="0"
|
:min="0"
|
||||||
:step="100"
|
:step="100"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
prefix="$"
|
: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"
|
placeholder="No limit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Tracked Apps -->
|
<!-- Tracked Apps -->
|
||||||
@@ -288,7 +380,7 @@ import AppSelect from '../components/AppSelect.vue'
|
|||||||
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
||||||
import RunningAppsPicker from '../components/RunningAppsPicker.vue'
|
import RunningAppsPicker from '../components/RunningAppsPicker.vue'
|
||||||
import AppColorPicker from '../components/AppColorPicker.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 { useClientsStore } from '../stores/clients'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { formatCurrency, getCurrencySymbol } from '../utils/locale'
|
import { formatCurrency, getCurrencySymbol } from '../utils/locale'
|
||||||
@@ -326,6 +418,54 @@ const showDeleteDialog = ref(false)
|
|||||||
const editingProject = ref<Project | null>(null)
|
const editingProject = ref<Project | null>(null)
|
||||||
const projectToDelete = 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
|
// Tracked apps state
|
||||||
const trackedApps = ref<TrackedApp[]>([])
|
const trackedApps = ref<TrackedApp[]>([])
|
||||||
const pendingAddApps = ref<TrackedApp[]>([])
|
const pendingAddApps = ref<TrackedApp[]>([])
|
||||||
@@ -455,10 +595,16 @@ function openCreateDialog() {
|
|||||||
formData.archived = false
|
formData.archived = false
|
||||||
formData.budget_hours = null
|
formData.budget_hours = null
|
||||||
formData.budget_amount = null
|
formData.budget_amount = null
|
||||||
|
billingType.value = 'hourly'
|
||||||
trackedApps.value = []
|
trackedApps.value = []
|
||||||
pendingAddApps.value = []
|
pendingAddApps.value = []
|
||||||
pendingRemoveIds.value = []
|
pendingRemoveIds.value = []
|
||||||
trackedAppsExpanded.value = false
|
trackedAppsExpanded.value = false
|
||||||
|
projectTasksList.value = []
|
||||||
|
pendingNewTasks.value = []
|
||||||
|
pendingRemoveTaskIds.value = []
|
||||||
|
newTaskName.value = ''
|
||||||
|
tasksExpanded.value = false
|
||||||
snapshotForm(getFormData())
|
snapshotForm(getFormData())
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -474,12 +620,21 @@ async function openEditDialog(project: Project) {
|
|||||||
formData.archived = project.archived
|
formData.archived = project.archived
|
||||||
formData.budget_hours = project.budget_hours ?? null
|
formData.budget_hours = project.budget_hours ?? null
|
||||||
formData.budget_amount = project.budget_amount ?? 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 = []
|
pendingAddApps.value = []
|
||||||
pendingRemoveIds.value = []
|
pendingRemoveIds.value = []
|
||||||
trackedAppsExpanded.value = false
|
trackedAppsExpanded.value = false
|
||||||
|
projectTasksList.value = []
|
||||||
|
pendingNewTasks.value = []
|
||||||
|
pendingRemoveTaskIds.value = []
|
||||||
|
newTaskName.value = ''
|
||||||
|
tasksExpanded.value = false
|
||||||
if (project.id) {
|
if (project.id) {
|
||||||
await loadTrackedApps(project.id)
|
await loadTrackedApps(project.id)
|
||||||
|
await loadProjectTasks(project.id)
|
||||||
if (trackedApps.value.length > 0) trackedAppsExpanded.value = true
|
if (trackedApps.value.length > 0) trackedAppsExpanded.value = true
|
||||||
|
if (projectTasksList.value.length > 0) tasksExpanded.value = true
|
||||||
} else {
|
} else {
|
||||||
trackedApps.value = []
|
trackedApps.value = []
|
||||||
}
|
}
|
||||||
@@ -495,6 +650,13 @@ function closeDialog() {
|
|||||||
|
|
||||||
// Handle form submit
|
// Handle form submit
|
||||||
async function handleSubmit() {
|
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
|
let projectId: number | undefined
|
||||||
if (editingProject.value) {
|
if (editingProject.value) {
|
||||||
await projectsStore.updateProject({ ...formData })
|
await projectsStore.updateProject({ ...formData })
|
||||||
@@ -504,6 +666,7 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
await saveTrackedApps(projectId)
|
await saveTrackedApps(projectId)
|
||||||
|
await saveTasks(projectId)
|
||||||
}
|
}
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<Transition name="fade" appear>
|
|
||||||
<div class="p-6">
|
<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>
|
<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 -->
|
<!-- Hours Tab -->
|
||||||
<template v-if="activeTab === 'hours'">
|
<template v-if="activeTab === 'hours'">
|
||||||
<!-- Summary Stats — pure typography -->
|
<!-- Summary Stats - pure typography -->
|
||||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
<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"
|
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.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-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-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 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'">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -199,7 +198,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -160,7 +160,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,15 +3,23 @@
|
|||||||
<!-- Hero timer display -->
|
<!-- Hero timer display -->
|
||||||
<div class="text-center pt-4 pb-8">
|
<div class="text-center pt-4 pb-8">
|
||||||
<div class="relative inline-block">
|
<div class="relative inline-block">
|
||||||
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2" :class="timerPulseClass">
|
<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="text-text-primary">{{ timerParts.hours }}</span>
|
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.hours }}</span>
|
||||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
|
||||||
<span class="text-text-primary">{{ timerParts.minutes }}</span>
|
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.minutes }}</span>
|
||||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
|
||||||
<span class="text-text-primary">{{ timerParts.seconds }}</span>
|
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.seconds }}</span>
|
||||||
</p>
|
</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
|
<button
|
||||||
v-if="timerStore.isRunning"
|
v-if="!timerStore.isStopped"
|
||||||
@click="openMiniTimer"
|
@click="openMiniTimer"
|
||||||
class="absolute -right-8 top-1/2 -translate-y-1/2 p-2 text-text-tertiary hover:text-text-secondary transition-colors"
|
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"
|
title="Pop out mini timer"
|
||||||
@@ -20,16 +28,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Paused indicator -->
|
<div class="h-6" />
|
||||||
<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="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
|
<button
|
||||||
@click="toggleTimer"
|
@click="toggleTimer"
|
||||||
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
|
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
|
||||||
@@ -38,6 +56,7 @@
|
|||||||
{{ buttonLabel }}
|
{{ buttonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Favorites strip -->
|
<!-- Favorites strip -->
|
||||||
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">
|
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user