Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion dashboard/src/components/billing/AssignProjectDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ const canSubmit = computed(
() => selected.value !== (props.subscription?.project || UNTAGGED),
)

const assign = useCall<unknown, { subscription: string; project: string | null }>({
const assign = useCall<
unknown,
{ subscription: string; project: string | null }
>({
url: method(API.setSubscriptionProject),
method: 'POST',
immediate: false,
Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/components/billing/InvoiceListView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,11 @@ const filters: ListViewFilter[] = [
@row-click="emit('rowClick', $event)"
>
<template #filters>
<!-- Shares the filter row's mobile sizing: grow from a floor below `sm`,
fixed 11rem from `sm` up. -->
<DateRangePicker
v-model="range"
class="w-44 shrink-0"
class="min-w-[9rem] flex-1 sm:w-44 sm:flex-none"
size="sm"
format="D MMM"
placeholder="Any date"
Expand Down
205 changes: 205 additions & 0 deletions dashboard/src/components/billing/InvoiceReceipt.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import ChargeBreakdown from '@/components/billing/ChargeBreakdown.vue'
import { shortDate } from '@/lib/date'
import { money } from '@/lib/format'
import type { InvoiceDetail } from '@/types/billing'

// The receipt itself and nothing around it: what the invoice is made of, what it
// comes to, and what has happened to it. Both hosts supply their own chrome —
// the docked panel puts identity in its header and Pay in its footer, the mobile
// page puts them above and below this — so neither lives here.
const props = defineProps<{
invoice: InvoiceDetail
/** Owned by useInvoiceDetail, which holds the status rules. */
overdue?: boolean
}>()

// Activity is folded away by default — for a settled invoice the log is
// reference, not news. Fold it again when switching invoices.
const activityExpanded = ref(false)
watch(
() => props.invoice.name,
() => (activityExpanded.value = false),
)

// Timeline dot colour per event theme; gray for informational events, so color
// stays reserved for outcomes.
const DOTS: Record<string, string> = {
green: 'bg-[var(--ink-green-6)]',
red: 'bg-[var(--ink-red-6)]',
}
const dotClass = (theme: string): string =>
DOTS[theme] || 'bg-[var(--ink-gray-4)]'

const paidWithIcon = computed(() =>
/upi/i.test(props.invoice.paid_with?.method_type ?? '')
? 'lucide-smartphone'
: 'lucide-credit-card',
)

// "31 May 2026, 09:00" → "31 May 2026": the date carries the story; the
// clock time is noise at timeline granularity.
const eventDate = (at: string | null): string => String(at ?? '').split(',')[0]

// One gray sentence under the label: amount first, then the backend's detail.
const eventDetail = (ev: {
detail: string | null
amount: number
currency?: string
}): string => {
const parts: string[] = []
if (ev.amount)
parts.push(money(ev.amount, ev.currency || props.invoice.currency))
if (ev.detail) parts.push(ev.detail)
return parts.join(' · ')
}
</script>

<template>
<div class="flex min-h-0 flex-1 flex-col">
<p
v-if="overdue"
class="flex items-center gap-1.5 px-4 pt-4 text-p-sm text-ink-red-7"
>
<span class="lucide-triangle-alert size-3.5 shrink-0" />
Due {{ shortDate(invoice.due_date) }} (overdue)
</p>

<!-- Receipt: plan charges per server, then metered add-ons — each
section a plain eyebrow with its subtotal, like the V2 receipt. -->
<!-- No inner scroll: the panel already scrolls, and a second scroller
here clipped the receipt mid-row once a team had more than one
machine on the invoice. -->
<div class="shrink-0 px-4 pt-4">
<ChargeBreakdown :lines="invoice.items" :currency="invoice.currency" />
</div>

<!-- Cost breakdown + Activity -->
<div class="mt-4 border-t border-outline-gray-2 px-4">
<dl class="space-y-2 py-3 text-sm">
<div class="flex justify-between gap-3">
<dt class="text-ink-gray-5">Subtotal</dt>
<dd class="tabular-nums text-ink-gray-8">
{{ money(invoice.subtotal, invoice.currency) }}
</dd>
</div>
<div
v-if="invoice.output_tax_amount"
class="flex justify-between gap-3"
>
<dt class="text-ink-gray-5">
{{ invoice.output_tax_type || 'Tax' }}
<template v-if="invoice.output_tax_rate">
({{ invoice.output_tax_rate }}%)</template
>
</dt>
<dd class="tabular-nums text-ink-gray-8">
{{ money(invoice.output_tax_amount, invoice.currency) }}
</dd>
</div>
<p v-if="invoice.zero_rating_reason" class="text-p-sm text-ink-gray-5">
{{ invoice.zero_rating_reason }}
</p>
<div v-if="invoice.credit_applied" class="flex justify-between gap-3">
<dt class="text-ink-green-5">Credits applied</dt>
<dd class="tabular-nums text-ink-green-5">
−{{ money(invoice.credit_applied, invoice.currency) }}
</dd>
</div>
<div
class="mt-1 flex justify-between gap-3 border-t border-outline-gray-1 pt-2.5 font-semibold"
>
<dt class="text-ink-gray-8">Total</dt>
<dd class="tabular-nums text-ink-gray-9">
{{ money(invoice.total, invoice.currency) }}
</dd>
</div>
<!-- Which method settled it — a quiet receipt line, not a form
row. Falls back to the paid amount when the method is gone. -->
<div
v-if="invoice.paid_with"
class="flex justify-between gap-3 text-p-sm text-ink-gray-5"
>
<dt>Paid with</dt>
<dd class="flex min-w-0 items-center gap-1.5">
<span
class="size-3.5 shrink-0 text-ink-gray-4"
:class="paidWithIcon"
aria-hidden="true"
/>
<span class="truncate">{{ invoice.paid_with.label }}</span>
</dd>
</div>
<div
v-else-if="invoice.amount_paid"
class="flex justify-between gap-3 text-p-sm text-ink-gray-5"
>
<dt>Paid</dt>
<dd class="tabular-nums">
{{ money(invoice.amount_paid, invoice.currency) }}
</dd>
</div>
</dl>

<!-- Activity — this invoice's own history. Folded away entirely:
for a settled invoice the log is reference, not news. -->
<section
v-if="invoice.activity?.length"
class="border-t border-outline-gray-1 py-3"
>
<button
class="flex w-full items-center gap-1.5 text-left"
:aria-expanded="activityExpanded"
@click="activityExpanded = !activityExpanded"
>
<span
class="lucide-chevron-right size-3.5 shrink-0 text-ink-gray-5 transition-transform duration-150 ease-out"
:class="activityExpanded ? 'rotate-90' : ''"
/>
<h3 class="text-sm-medium text-ink-gray-8">Activity</h3>
<span class="text-p-sm text-ink-gray-5">
{{ invoice.activity.length }}
</span>
</button>
<ol v-if="activityExpanded" class="relative mt-3">
<li
v-for="(ev, idx) in invoice.activity"
:key="idx"
class="relative flex gap-3 pb-4 last:pb-0"
>
<!-- Rail: one continuous line running through the column, with
the solid dot sitting on top of it. The line is dropped on
the last row so it doesn't dangle. -->
<div class="relative flex w-2.5 shrink-0 justify-center">
<span
v-if="idx < invoice.activity.length - 1"
class="absolute left-1/2 top-2 h-[calc(100%+1rem)] w-px -translate-x-1/2 bg-[var(--outline-gray-2)]"
/>
<span
class="relative z-10 mt-1 size-2 shrink-0 rounded-full"
:class="dotClass(ev.theme)"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-baseline justify-between gap-2">
<span class="text-sm-medium text-ink-gray-8">
{{ ev.title }}
</span>
<span class="shrink-0 tabular-nums text-p-sm text-ink-gray-5">
{{ eventDate(ev.at) }}
</span>
</div>
<p
v-if="eventDetail(ev)"
class="mt-0.5 break-words text-p-sm text-ink-gray-5"
>
{{ eventDetail(ev) }}
</p>
</div>
</li>
</ol>
</section>
</div>
</div>
</template>
16 changes: 13 additions & 3 deletions dashboard/src/components/billing/ProjectMembersDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ function serverTitle(sub: SubscriptionRow): string {
return sub.server || sub.plan_title || sub.name
}

const assign = useCall<unknown, { subscription: string; project: string | null }>({
const assign = useCall<
unknown,
{ subscription: string; project: string | null }
>({
url: method(API.setSubscriptionProject),
method: 'POST',
immediate: false,
Expand All @@ -63,7 +66,10 @@ async function addMember(): Promise<void> {
if (!toAdd.value || !props.project) return
busy.value = toAdd.value
try {
await assign.submit({ subscription: toAdd.value, project: props.project.name })
await assign.submit({
subscription: toAdd.value,
project: props.project.name,
})
if (assign.error) throw assign.error
toAdd.value = NONE
reloadSubscriptionGrouping()
Expand Down Expand Up @@ -92,7 +98,11 @@ async function removeMember(sub: SubscriptionRow): Promise<void> {
</script>

<template>
<Dialog v-model="open" :title="project ? `${project.title} — servers` : ''" size="lg">
<Dialog
v-model="open"
:title="project ? `${project.title} — servers` : ''"
size="lg"
>
<template #default>
<div class="space-y-4">
<div v-if="members.length" class="divide-y divide-outline-gray-1">
Expand Down
6 changes: 5 additions & 1 deletion dashboard/src/components/billing/ProjectRowActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ const options = computed<ActionItem[]>(() => {
icon: 'lucide-server',
onClick: () => emit('manageMembers', props.project),
},
{ label: 'Edit', icon: 'lucide-pencil', onClick: () => emit('rename', props.project) },
{
label: 'Edit',
icon: 'lucide-pencil',
onClick: () => emit('rename', props.project),
},
{
label: props.project.enabled ? 'Disable' : 'Enable',
icon: props.project.enabled ? 'lucide-pause' : 'lucide-play',
Expand Down
10 changes: 7 additions & 3 deletions dashboard/src/components/billing/ProjectsCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { useBillingOverview } from '@/composables/useBillingOverview'
import { useCapabilities } from '@/composables/useCapabilities'
import { useProjects } from '@/composables/useProjects'
import { money } from '@/lib/format'
import { standingTheme } from '@/lib/status'
import type { BadgeTheme } from '@/lib/status'
import { standingTheme } from '@/lib/status'
import type { Project } from '@/types/billing'

// Projects — a team's own cost-breakdown tags: subscriptions tagged into the
Expand Down Expand Up @@ -55,7 +55,8 @@ function statusInfo(p: Project): { label: string; theme: BadgeTheme } | null {
}

function subtitle(p: Project): string {
const resources = p.resource_count === 1 ? '1 resource' : `${p.resource_count} resources`
const resources =
p.resource_count === 1 ? '1 resource' : `${p.resource_count} resources`
if (!p.spending_limit) return resources
return `${resources} · ${money(p.committed_run_rate, currency.value)} of ${money(p.spending_limit, currency.value)}/mo`
}
Expand Down Expand Up @@ -166,7 +167,10 @@ function subtitle(p: Project): string {
</EmptyState>

<CreateProjectDialog v-model="showCreate" @created="reloadProjects" />
<RenameProjectDialog v-model:project="pendingRename" @saved="reloadProjects" />
<RenameProjectDialog
v-model:project="pendingRename"
@saved="reloadProjects"
/>
<ProjectMembersDialog
v-model:project="pendingManageMembers"
@changed="reloadProjects"
Expand Down
5 changes: 3 additions & 2 deletions dashboard/src/components/billing/ProjectsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { useBillingOverview } from '@/composables/useBillingOverview'
import { useCapabilities } from '@/composables/useCapabilities'
import { useProjects } from '@/composables/useProjects'
import { money } from '@/lib/format'
import { standingTheme } from '@/lib/status'
import type { BadgeTheme } from '@/lib/status'
import { standingTheme } from '@/lib/status'
import type { Project } from '@/types/billing'

// Every Project, in full. The card keeps the top few; this is where the long
Expand All @@ -35,7 +35,8 @@ function statusInfo(p: Project): { label: string; theme: BadgeTheme } | null {
}

function rowSubtitle(p: Project): string {
const resources = p.resource_count === 1 ? '1 resource' : `${p.resource_count} resources`
const resources =
p.resource_count === 1 ? '1 resource' : `${p.resource_count} resources`
if (!p.spending_limit) return resources
return `${resources} · ${money(p.committed_run_rate, currency.value)} of ${money(p.spending_limit, currency.value)}/mo`
}
Expand Down
11 changes: 8 additions & 3 deletions dashboard/src/components/billing/RenameProjectDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ watch(

const titleChanged = computed(
() =>
title.value.trim().length > 0 && title.value.trim() !== props.project?.title,
title.value.trim().length > 0 &&
title.value.trim() !== props.project?.title,
)
const limitChanged = computed(
() => (spendingLimit.value || 0) !== (props.project?.spending_limit || 0),
)
const canSubmit = computed(
() => title.value.trim().length > 0 && (titleChanged.value || limitChanged.value),
() =>
title.value.trim().length > 0 && (titleChanged.value || limitChanged.value),
)

const rename = useCall<unknown, { name: string; title: string }>({
Expand All @@ -64,7 +66,10 @@ async function submit(): Promise<void> {
if (!props.project || !canSubmit.value) return
try {
if (titleChanged.value)
await rename.submit({ name: props.project.name, title: title.value.trim() })
await rename.submit({
name: props.project.name,
title: title.value.trim(),
})
if (rename.error) throw rename.error
if (limitChanged.value)
await setLimit.submit({
Expand Down
11 changes: 7 additions & 4 deletions dashboard/src/components/billing/WalletHistoryPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ function isCredit(entry: CreditLedgerEntry): boolean {
/>
</template>

<!-- Ledger -->
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Ledger. No inner scroll: SidePanel's body already scrolls, and a
second scroller here traps the ledger in a short box — worst on the
mobile sheet, where the panel is the full screen and the ledger got
a sliver of it. Same fix the invoice receipt took. -->
<div>
<div v-if="ledger.loading && !ledger.data" class="space-y-3 p-4">
<LoadingText :lines="5" />
</div>
Expand All @@ -67,11 +70,11 @@ function isCredit(entry: CreditLedgerEntry): boolean {
>
No credit activity yet.
</div>
<ul v-else class="divide-y divide-outline-gray-1 px-4">
<ul v-else class="divide-y divide-outline-gray-1">
<li
v-for="(e, idx) in ledger.data"
:key="idx"
class="flex items-center gap-3 py-3"
class="flex items-center gap-3 px-4 py-3"
>
<span
class="grid size-8 shrink-0 place-items-center rounded-full"
Expand Down
Loading
Loading