- cc-collector.py: extract input/output/cache tokens from JSONL usage fields and calculate cost_usd using model-based pricing table - Session model: add input_tokens, output_tokens, cost_usd columns - Migration 0010: ALTER TABLE sessions ADD cost columns - Ingest: persist cost fields on upsert (updated on every sync) - Dashboard /projects: aggregate total_cost_usd per project from sessions - ProjectHours schema + ProjectSummary TS type: expose total_cost_usd - ProjectsView: replace Budget% column with "Cost $" showing total spend; Grid cards show CC Cost row when cost > 0 - backfill_session_costs.py: one-time script to populate cost for all historical sessions from local JSONL files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
8.4 KiB
Vue
215 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
import { useRouter, RouterLink } from 'vue-router'
|
|
import { dashboardApi } from '@/api/endpoints/dashboard'
|
|
import Card from '@/components/ui/Card.vue'
|
|
import CardContent from '@/components/ui/CardContent.vue'
|
|
import Spinner from '@/components/ui/Spinner.vue'
|
|
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
|
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
|
import DataTable from '@/components/ui/DataTable.vue'
|
|
import Input from '@/components/ui/Input.vue'
|
|
import type { TableColumn } from '@/components/ui/DataTable.vue'
|
|
import { formatDuration, formatDate } from '@/lib/utils'
|
|
import type { ProjectSummary } from '@/types'
|
|
import { LayoutGrid, List, FolderOpen, Code2, Zap, Search } from 'lucide-vue-next'
|
|
|
|
const router = useRouter()
|
|
const projects = ref<ProjectSummary[]>([])
|
|
const loading = ref(false)
|
|
const search = ref('')
|
|
|
|
const savedView = localStorage.getItem('projects.view')
|
|
const viewMode = ref<'grid' | 'list'>(savedView === 'list' ? 'list' : 'grid')
|
|
|
|
watch(viewMode, (v) => localStorage.setItem('projects.view', v))
|
|
|
|
const viewOptions = [
|
|
{ value: 'grid', label: 'Grid', icon: LayoutGrid },
|
|
{ value: 'list', label: 'List', icon: List },
|
|
]
|
|
|
|
const listColumns: TableColumn[] = [
|
|
{ key: 'display_name', title: 'Project', minWidth: 160, sortable: true, filterable: true, resizable: true },
|
|
{ key: 'client', title: 'Client', width: 140, minWidth: 80, sortable: true, filterable: true, resizable: true },
|
|
{ key: 'job_number', title: 'OMG #', width: 100, minWidth: 70, sortable: true, filterable: true, resizable: true },
|
|
{ key: 'total_hours', title: 'Hours', width: 90, minWidth: 60, sortable: true, resizable: true, align: 'right', type: 'number' },
|
|
{ key: 'session_count', title: 'Sessions', width: 90, minWidth: 60, sortable: true, resizable: true, align: 'right', type: 'number' },
|
|
{ key: 'last_active', title: 'Last Active', width: 120, minWidth: 90, sortable: true, resizable: true, align: 'right', type: 'date' },
|
|
{ key: 'total_cost_usd', title: 'Cost $', width: 90, minWidth: 60, sortable: true, resizable: true, align: 'right', type: 'number' },
|
|
]
|
|
|
|
const listRows = computed((): Record<string, unknown>[] => {
|
|
const q = search.value.trim().toLowerCase()
|
|
return projects.value
|
|
.filter(p => !q
|
|
|| p.display_name.toLowerCase().includes(q)
|
|
|| (p.client ?? '').toLowerCase().includes(q)
|
|
|| (p.job_number ?? '').toLowerCase().includes(q)
|
|
)
|
|
.map(p => ({
|
|
project_id: p.project_id,
|
|
display_name: p.display_name,
|
|
client: p.client ?? '',
|
|
job_number: p.job_number ?? '',
|
|
total_hours: p.total_hours,
|
|
session_count: p.session_count,
|
|
last_active: p.last_active ?? null,
|
|
total_cost_usd: p.total_cost_usd ?? 0,
|
|
}))
|
|
})
|
|
|
|
onMounted(async () => {
|
|
loading.value = true
|
|
try {
|
|
const res = await dashboardApi.projects({})
|
|
projects.value = res.data.sort((a, b) => b.total_hours - a.total_hours)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="p-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center gap-3 mb-6">
|
|
<h2 class="text-lg font-semibold text-foreground flex-1">Projects</h2>
|
|
|
|
<!-- View toggle -->
|
|
<SegmentedControl
|
|
v-model="viewMode"
|
|
:options="viewOptions"
|
|
aria-label="View mode"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="loading" class="flex items-center justify-center h-40">
|
|
<Spinner size="lg" class="text-primary" />
|
|
</div>
|
|
|
|
<div v-else-if="projects.length === 0" class="py-12">
|
|
<EmptyState
|
|
title="No projects yet"
|
|
description="Start a Claude Code session to see your projects here."
|
|
:icons="[FolderOpen, Code2, Zap]"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Grid view -->
|
|
<div v-else-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
<Card
|
|
v-for="proj in projects"
|
|
:key="proj.project_id"
|
|
class="cursor-pointer hover:border-primary/50 transition-colors"
|
|
@click="router.push(`/projects/${proj.project_id}`)"
|
|
>
|
|
<CardContent class="p-4">
|
|
<!-- Header -->
|
|
<div class="flex items-start justify-between gap-2 mb-3">
|
|
<div class="min-w-0">
|
|
<p class="font-semibold text-sm text-foreground truncate">{{ proj.display_name }}</p>
|
|
<p v-if="proj.client" class="text-xs text-muted-foreground truncate">{{ proj.client }}</p>
|
|
</div>
|
|
<RouterLink
|
|
v-if="proj.job_number"
|
|
:to="{ name: 'omg', query: { highlight: proj.job_number } }"
|
|
class="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded shrink-0 hover:bg-primary/10 hover:text-primary transition-colors"
|
|
@click.stop
|
|
>
|
|
{{ proj.job_number }}
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="space-y-1.5">
|
|
<div class="flex items-center justify-between text-xs">
|
|
<span class="text-muted-foreground">Total hours</span>
|
|
<span class="font-medium text-foreground">{{ formatDuration(proj.total_hours) }}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between text-xs">
|
|
<span class="text-muted-foreground">Sessions</span>
|
|
<span class="text-foreground">{{ proj.session_count }}</span>
|
|
</div>
|
|
<div v-if="proj.last_active" class="flex items-center justify-between text-xs">
|
|
<span class="text-muted-foreground">Last active</span>
|
|
<span class="text-foreground">{{ formatDate(proj.last_active) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cost -->
|
|
<div v-if="proj.total_cost_usd > 0" class="flex items-center justify-between text-xs mt-1">
|
|
<span class="text-muted-foreground">CC Cost</span>
|
|
<span class="font-medium text-foreground">${{ proj.total_cost_usd.toFixed(2) }}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- List view -->
|
|
<div v-else class="space-y-3">
|
|
<!-- Search bar -->
|
|
<div class="relative max-w-xs">
|
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
v-model="search"
|
|
placeholder="Search projects…"
|
|
class="pl-8 h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<DataTable
|
|
:columns="listColumns"
|
|
:rows="listRows"
|
|
row-key="project_id"
|
|
@row-click="(row) => router.push(`/projects/${String(row.project_id)}`)"
|
|
>
|
|
<!-- Project name cell -->
|
|
<template #cell-display_name="{ row }">
|
|
<p class="text-sm font-medium text-foreground truncate">{{ row.display_name }}</p>
|
|
</template>
|
|
|
|
<!-- Client cell -->
|
|
<template #cell-client="{ value }">
|
|
<span v-if="value" class="text-sm text-muted-foreground truncate block">{{ value }}</span>
|
|
<span v-else class="text-xs text-muted-foreground/40">—</span>
|
|
</template>
|
|
|
|
<!-- OMG # cell -->
|
|
<template #cell-job_number="{ row, value }">
|
|
<RouterLink
|
|
v-if="value"
|
|
:to="{ name: 'omg', query: { highlight: String(value) } }"
|
|
class="text-xs tabular-nums text-primary hover:underline"
|
|
@click.stop
|
|
>{{ value }}</RouterLink>
|
|
<span v-else class="text-xs text-muted-foreground/40">—</span>
|
|
</template>
|
|
|
|
<!-- Hours cell -->
|
|
<template #cell-total_hours="{ value }">
|
|
<span class="text-sm tabular-nums text-foreground">{{ formatDuration(value as number) }}</span>
|
|
</template>
|
|
|
|
<!-- Sessions cell -->
|
|
<template #cell-session_count="{ value }">
|
|
<span class="text-sm tabular-nums text-muted-foreground">{{ value }}</span>
|
|
</template>
|
|
|
|
<!-- Last Active cell -->
|
|
<template #cell-last_active="{ value }">
|
|
<span class="text-xs text-muted-foreground">{{ value ? formatDate(value as string) : '—' }}</span>
|
|
</template>
|
|
|
|
<!-- Cost cell -->
|
|
<template #cell-total_cost_usd="{ value }">
|
|
<span v-if="(value as number) > 0" class="text-xs tabular-nums text-foreground">
|
|
${{ (value as number).toFixed(2) }}
|
|
</span>
|
|
<span v-else class="text-xs text-muted-foreground/40">—</span>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
</div>
|
|
</template>
|