Add recurring bills UI with full CRUD, filters, overdue highlight, amount override
Validate / validate (push) Successful in 33s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 15:23:53 +07:00
parent b611207d25
commit b43924f527
3 changed files with 1063 additions and 22 deletions
@@ -36,6 +36,7 @@
...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant') ...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
? [ ? [
{ href: `/companies/${data.company.id}/accounts`, label: 'Accounts' }, { href: `/companies/${data.company.id}/accounts`, label: 'Accounts' },
{ href: `/companies/${data.company.id}/bills`, label: 'Bills' },
{ href: `/companies/${data.company.id}/profile`, label: 'Profile' }, { href: `/companies/${data.company.id}/profile`, label: 'Profile' },
{ href: `/companies/${data.company.id}/documents`, label: 'Documents' } { href: `/companies/${data.company.id}/documents`, label: 'Documents' }
] ]
@@ -1,21 +1,540 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
recurringBills,
companyAccounts,
projects,
categories,
parties
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { postBillsDue } from '$lib/server/recurring-bills/poster.js'; import { postBillsDue } from '$lib/server/recurring-bills/poster.js';
import { computeNextDueDate, toIsoDate, type Cycle } from '$lib/server/recurring-bills/cycle.js';
import { and, asc, eq, isNull } from 'drizzle-orm';
const CYCLES = ['weekly', 'monthly', 'quarterly', 'yearly'] as const;
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseCycle(v: FormDataEntryValue | null): Cycle | null {
const s = v?.toString();
if (!s) return null;
return (CYCLES as readonly string[]).includes(s) ? (s as Cycle) : null;
}
function parseAmount(v: FormDataEntryValue | null): string | null {
const s = trimOrNull(v);
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n < 0) return null;
return n.toFixed(2);
}
function parseInt0(v: FormDataEntryValue | null): number | null {
const s = trimOrNull(v);
if (!s) return null;
const n = Number(s);
if (!Number.isInteger(n)) return null;
return n;
}
function parseIsoDate(v: FormDataEntryValue | null): string | null {
const s = trimOrNull(v);
if (!s) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const d = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
return s;
}
type BillFormFields = {
name: string;
amount: string;
cycle: Cycle;
accountId: string;
projectId: string;
categoryId: string | null;
partyId: string | null;
description: string | null;
currency: string;
startDate: string;
endDate: string | null;
dayOfCycle: number | null;
};
function extractFields(fd: FormData): BillFormFields | string {
const name = trimOrNull(fd.get('name'));
const amount = parseAmount(fd.get('amount'));
const cycle = parseCycle(fd.get('cycle'));
const accountId = trimOrNull(fd.get('accountId'));
const projectId = trimOrNull(fd.get('projectId'));
const startDate = parseIsoDate(fd.get('startDate'));
if (!name) return 'Name is required';
if (!amount) return 'Valid amount is required';
if (!cycle) return 'Invalid cycle';
if (!accountId) return 'Account is required';
if (!projectId) return 'Project is required';
if (!startDate) return 'Valid start date is required';
const currency = trimOrNull(fd.get('currency')) ?? 'THB';
const endDate = parseIsoDate(fd.get('endDate'));
const endDateRaw = trimOrNull(fd.get('endDate'));
if (endDateRaw && !endDate) return 'Invalid end date';
const dayOfCycle = parseInt0(fd.get('dayOfCycle'));
if (fd.get('dayOfCycle') && dayOfCycle === null) return 'Invalid day of cycle';
if (dayOfCycle !== null) {
if (cycle === 'weekly' && (dayOfCycle < 0 || dayOfCycle > 6)) {
return 'Weekly day must be 0 (Sun) 6 (Sat)';
}
if (cycle !== 'weekly' && (dayOfCycle < 1 || dayOfCycle > 31)) {
return 'Day of cycle must be 1 31';
}
}
return {
name,
amount,
cycle,
accountId,
projectId,
categoryId: trimOrNull(fd.get('categoryId')),
partyId: trimOrNull(fd.get('partyId')),
description: trimOrNull(fd.get('description')),
currency,
startDate,
endDate,
dayOfCycle
};
}
export const load: PageServerLoad = async ({ locals, params, parent }) => { export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent(); await parent();
const [billRows, accountRows, projectRows, categoryRows, partyRows] = await Promise.all([
db
.select({
id: recurringBills.id,
name: recurringBills.name,
description: recurringBills.description,
cycle: recurringBills.cycle,
defaultAmount: recurringBills.defaultAmount,
nextCycleAmount: recurringBills.nextCycleAmount,
currency: recurringBills.currency,
dayOfCycle: recurringBills.dayOfCycle,
startDate: recurringBills.startDate,
endDate: recurringBills.endDate,
nextDueDate: recurringBills.nextDueDate,
lastPostedDate: recurringBills.lastPostedDate,
status: recurringBills.status,
pausedAt: recurringBills.pausedAt,
skipNext: recurringBills.skipNext,
accountId: recurringBills.accountId,
accountName: companyAccounts.name,
projectId: recurringBills.projectId,
projectName: projects.name,
categoryId: recurringBills.categoryId,
categoryName: categories.name,
partyId: recurringBills.partyId,
partyName: parties.name,
createdAt: recurringBills.createdAt,
updatedAt: recurringBills.updatedAt
})
.from(recurringBills)
.leftJoin(companyAccounts, eq(recurringBills.accountId, companyAccounts.id))
.leftJoin(projects, eq(recurringBills.projectId, projects.id))
.leftJoin(categories, eq(recurringBills.categoryId, categories.id))
.leftJoin(parties, eq(recurringBills.partyId, parties.id))
.where(
and(eq(recurringBills.companyId, params.companyId), isNull(recurringBills.deletedAt))
)
.orderBy(asc(recurringBills.status), asc(recurringBills.nextDueDate)),
db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt),
eq(companyAccounts.isArchived, false)
)
)
.orderBy(asc(companyAccounts.name)),
db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
.orderBy(asc(projects.name)),
db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.companyId, params.companyId))
.orderBy(asc(categories.name)),
db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
.orderBy(asc(parties.name))
]);
return { return {
bills: [], bills: billRows,
accounts: [], accounts: accountRows,
projects: [], projects: projectRows,
categories: [], categories: categoryRows,
parties: [] parties: partyRows
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
createBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const parsed = extractFields(fd);
if (typeof parsed === 'string') {
return fail(400, { action: 'createBill', error: parsed });
}
// Verify account + project belong to this company
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, parsed.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { action: 'createBill', error: 'Account not found' });
const [proj] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, parsed.projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!proj) return fail(400, { action: 'createBill', error: 'Project not found' });
const dayOfCycle =
parsed.dayOfCycle ??
(parsed.cycle === 'weekly'
? new Date(`${parsed.startDate}T00:00:00Z`).getUTCDay()
: new Date(`${parsed.startDate}T00:00:00Z`).getUTCDate());
const nextDueDate = toIsoDate(
computeNextDueDate(
parsed.startDate,
parsed.cycle,
dayOfCycle,
new Date(`${parsed.startDate}T00:00:00Z`)
)
);
const [inserted] = await db
.insert(recurringBills)
.values({
companyId: params.companyId,
projectId: parsed.projectId,
accountId: parsed.accountId,
categoryId: parsed.categoryId,
partyId: parsed.partyId,
name: parsed.name,
description: parsed.description,
cycle: parsed.cycle,
defaultAmount: parsed.amount,
currency: parsed.currency,
dayOfCycle,
startDate: parsed.startDate,
endDate: parsed.endDate,
nextDueDate,
status: 'active',
createdBy: user.id
})
.returning({ id: recurringBills.id });
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_created',
`Recurring bill "${parsed.name}" created (${parsed.cycle}, ${parsed.amount} ${parsed.currency})`,
{ billId: inserted.id, cycle: parsed.cycle, amount: parsed.amount }
);
return { success: true, action: 'createBill' };
},
updateBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'updateBill', error: 'Bill id is required' });
const [existing] = await db
.select()
.from(recurringBills)
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Bill not found');
const parsed = extractFields(fd);
if (typeof parsed === 'string') {
return fail(400, { action: 'updateBill', error: parsed });
}
const dayOfCycle =
parsed.dayOfCycle ??
(parsed.cycle === 'weekly'
? new Date(`${parsed.startDate}T00:00:00Z`).getUTCDay()
: new Date(`${parsed.startDate}T00:00:00Z`).getUTCDate());
const scheduleChanged =
existing.startDate !== parsed.startDate ||
existing.cycle !== parsed.cycle ||
existing.dayOfCycle !== dayOfCycle;
const nextDueDate =
existing.lastPostedDate === null && scheduleChanged
? toIsoDate(
computeNextDueDate(
parsed.startDate,
parsed.cycle,
dayOfCycle,
new Date(`${parsed.startDate}T00:00:00Z`)
)
)
: existing.nextDueDate;
await db
.update(recurringBills)
.set({
projectId: parsed.projectId,
accountId: parsed.accountId,
categoryId: parsed.categoryId,
partyId: parsed.partyId,
name: parsed.name,
description: parsed.description,
cycle: parsed.cycle,
defaultAmount: parsed.amount,
currency: parsed.currency,
dayOfCycle,
startDate: parsed.startDate,
endDate: parsed.endDate,
nextDueDate,
updatedAt: new Date()
})
.where(eq(recurringBills.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_updated',
`Recurring bill "${parsed.name}" updated`,
{ billId: id }
);
return { success: true, action: 'updateBill' };
},
deleteBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteBill', error: 'Bill id is required' });
const [existing] = await db
.select({ id: recurringBills.id, name: recurringBills.name })
.from(recurringBills)
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Bill not found');
await db
.update(recurringBills)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(recurringBills.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_deleted',
`Recurring bill "${existing.name}" deleted`,
{ billId: id }
);
return { success: true, action: 'deleteBill' };
},
pauseBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'pauseBill', error: 'Bill id is required' });
const result = await db
.update(recurringBills)
.set({ status: 'paused', pausedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.returning({ name: recurringBills.name });
if (result.length === 0) error(404, 'Bill not found');
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_paused',
`Recurring bill "${result[0].name}" paused`,
{ billId: id }
);
return { success: true, action: 'pauseBill' };
},
resumeBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'resumeBill', error: 'Bill id is required' });
const result = await db
.update(recurringBills)
.set({ status: 'active', pausedAt: null, updatedAt: new Date() })
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.returning({ name: recurringBills.name });
if (result.length === 0) error(404, 'Bill not found');
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_resumed',
`Recurring bill "${result[0].name}" resumed`,
{ billId: id }
);
return { success: true, action: 'resumeBill' };
},
skipNextBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'skipNextBill', error: 'Bill id is required' });
const result = await db
.update(recurringBills)
.set({ skipNext: true, updatedAt: new Date() })
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.returning({ name: recurringBills.name, nextDueDate: recurringBills.nextDueDate });
if (result.length === 0) error(404, 'Bill not found');
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_skipped',
`Next cycle (${result[0].nextDueDate}) of "${result[0].name}" will be skipped`,
{ billId: id, intent: true }
);
return { success: true, action: 'skipNextBill' };
},
setNextCycleAmount: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'setNextCycleAmount', error: 'Bill id is required' });
const amountRaw = fd.get('amount');
const cleared = amountRaw === null || amountRaw === '';
const amount = cleared ? null : parseAmount(amountRaw);
if (!cleared && !amount) return fail(400, { action: 'setNextCycleAmount', error: 'Valid amount required' });
await db
.update(recurringBills)
.set({ nextCycleAmount: amount, updatedAt: new Date() })
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
);
return { success: true, action: 'setNextCycleAmount' };
},
runBillsNow: async ({ locals, params }) => { runBillsNow: async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const result = await postBillsDue(params.companyId, new Date()); const result = await postBillsDue(params.companyId, new Date());
@@ -1,40 +1,301 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types'; import type { PageData, ActionData } from './$types';
import { formatDate } from '$lib/utils/date.js';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
type StatusFilter = 'all' | 'active' | 'paused' | 'ended';
let statusFilter = $state<StatusFilter>('all');
let showAddForm = $state(false);
let editingBillId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
const CYCLE_LABELS: Record<string, string> = {
weekly: 'Weekly',
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly'
};
const STATUS_BADGE: Record<string, string> = {
active: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
paused: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
ended: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
};
const todayIso = new Date().toISOString().slice(0, 10);
const filteredBills = $derived(
statusFilter === 'all' ? data.bills : data.bills.filter((b) => b.status === statusFilter)
);
function isOverdue(bill: (typeof data.bills)[number]): boolean {
return bill.status === 'active' && bill.nextDueDate < todayIso;
}
function formatAmount(value: string, currency: string): string {
const n = Number(value);
if (!Number.isFinite(n)) return value;
return `${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
}
function startEdit(id: string) {
editingBillId = id;
confirmDeleteId = null;
showAddForm = false;
}
function openAdd() {
showAddForm = !showAddForm;
editingBillId = null;
confirmDeleteId = null;
}
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script> </script>
<svelte:head> <svelte:head>
<title>Bills - {data.company.name}</title> <title>Bills - {data.company.name}</title>
</svelte:head> </svelte:head>
<div class="space-y-6"> {#snippet billForm(
<header> action: string,
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Recurring Bills</h1> values: {
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> name?: string;
Full UI coming in the next phase. Use "Run Now" to post any bills past their due date. amount?: string;
</p> cycle?: string;
</header> accountId?: string;
projectId?: string;
categoryId?: string;
partyId?: string;
description?: string;
currency?: string;
startDate?: string;
endDate?: string;
dayOfCycle?: string;
} = {},
billId?: string
)}
<form <form
method="POST" method="POST"
action="?/runBillsNow" {action}
use:enhance={() => async ({ update }) => { use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false }); await update({ reset: false });
if (result.type === 'success') {
if (!billId) {
showAddForm = false;
formElement.reset();
} else {
editingBillId = null;
}
}
}} }}
class="mt-4 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-4 dark:bg-gray-700/50 md:grid-cols-2"
> >
<button {#if billId}
type="submit" <input type="hidden" name="id" value={billId} />
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700" {/if}
> <div class="md:col-span-2">
Run Bills Now <label class={labelCls} for="bill-name-{billId ?? 'new'}">Name <span class="text-red-500">*</span></label>
</button> <input
id="bill-name-{billId ?? 'new'}"
name="name"
type="text"
required
value={values.name ?? ''}
class={inputCls}
placeholder="e.g. Office rent"
/>
</div>
<div>
<label class={labelCls} for="bill-amount-{billId ?? 'new'}">Amount <span class="text-red-500">*</span></label>
<input
id="bill-amount-{billId ?? 'new'}"
name="amount"
type="number"
step="0.01"
min="0"
required
value={values.amount ?? ''}
class={inputCls}
/>
</div>
<div>
<label class={labelCls} for="bill-currency-{billId ?? 'new'}">Currency</label>
<input
id="bill-currency-{billId ?? 'new'}"
name="currency"
type="text"
value={values.currency ?? 'THB'}
class={inputCls}
/>
</div>
<div>
<label class={labelCls} for="bill-cycle-{billId ?? 'new'}">Cycle <span class="text-red-500">*</span></label>
<select id="bill-cycle-{billId ?? 'new'}" name="cycle" required value={values.cycle ?? 'monthly'} class={inputCls}>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div>
<label class={labelCls} for="bill-dayOfCycle-{billId ?? 'new'}">Day of cycle</label>
<input
id="bill-dayOfCycle-{billId ?? 'new'}"
name="dayOfCycle"
type="number"
value={values.dayOfCycle ?? ''}
class={inputCls}
placeholder="06 weekly · 131 monthly/qtr/yr"
/>
</div>
<div>
<label class={labelCls} for="bill-account-{billId ?? 'new'}">Account <span class="text-red-500">*</span></label>
<select
id="bill-account-{billId ?? 'new'}"
name="accountId"
required
value={values.accountId ?? ''}
class={inputCls}
>
<option value="" disabled>Select an account</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-project-{billId ?? 'new'}">Project <span class="text-red-500">*</span></label>
<select
id="bill-project-{billId ?? 'new'}"
name="projectId"
required
value={values.projectId ?? ''}
class={inputCls}
>
<option value="" disabled>Select a project</option>
{#each data.projects as proj (proj.id)}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-category-{billId ?? 'new'}">Category</label>
<select
id="bill-category-{billId ?? 'new'}"
name="categoryId"
value={values.categoryId ?? ''}
class={inputCls}
>
<option value=""></option>
{#each data.categories as cat (cat.id)}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-party-{billId ?? 'new'}">Vendor / Party</label>
<select
id="bill-party-{billId ?? 'new'}"
name="partyId"
value={values.partyId ?? ''}
class={inputCls}
>
<option value=""></option>
{#each data.parties as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-start-{billId ?? 'new'}">Start date <span class="text-red-500">*</span></label>
<input
id="bill-start-{billId ?? 'new'}"
name="startDate"
type="date"
required
value={values.startDate ?? todayIso}
class={inputCls}
/>
</div>
<div>
<label class={labelCls} for="bill-end-{billId ?? 'new'}">End date</label>
<input
id="bill-end-{billId ?? 'new'}"
name="endDate"
type="date"
value={values.endDate ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label class={labelCls} for="bill-desc-{billId ?? 'new'}">Description</label>
<textarea
id="bill-desc-{billId ?? 'new'}"
name="description"
rows="2"
class={inputCls}>{values.description ?? ''}</textarea
>
</div>
<div class="md:col-span-2 flex justify-end gap-2">
<button
type="button"
onclick={() => {
if (billId) editingBillId = null;
else showAddForm = false;
}}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{billId ? 'Save' : 'Create Bill'}
</button>
</div>
</form> </form>
{/snippet}
<div class="space-y-6">
<header class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Recurring Bills</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Auto-post rent, utilities, SaaS, and other recurring expenses on their due date. Scheduler
runs every 15 minutes; use "Run Now" to trigger an immediate pass.
</p>
</div>
<form
method="POST"
action="?/runBillsNow"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
}}
>
<button
type="submit"
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
Run Now
</button>
</form>
</header>
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
{#if form?.action === 'runBillsNow'} {#if form?.action === 'runBillsNow'}
<div <div
class="rounded-md border border-gray-200 bg-white p-4 text-sm dark:border-gray-700 dark:bg-gray-800" class="rounded-md border border-gray-200 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-800"
> >
<p class="font-medium text-gray-900 dark:text-white"> <p class="font-medium text-gray-900 dark:text-white">
Posted: {form.postedCount} · Skipped: {form.skippedCount} · Errors: {form.errorCount} Posted: {form.postedCount} · Skipped: {form.skippedCount} · Errors: {form.errorCount}
@@ -48,4 +309,264 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<section
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Add Bill</h2>
<button
type="button"
onclick={openAdd}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddForm ? 'Cancel' : '+ New Bill'}
</button>
</div>
{#if showAddForm}
{@render billForm('?/createBill', { currency: 'THB', startDate: todayIso, cycle: 'monthly' })}
{/if}
</section>
<div class="flex flex-wrap gap-2">
{#each ['all', 'active', 'paused', 'ended'] as s (s)}
<button
type="button"
onclick={() => (statusFilter = s as StatusFilter)}
class="rounded-full px-3 py-1 text-xs font-medium {statusFilter === s
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
{/each}
</div>
{#if filteredBills.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
No {statusFilter === 'all' ? '' : statusFilter} bills yet.
</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Amount</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Cycle</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Account</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Next Due</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each filteredBills as bill (bill.id)}
<tr class="align-top">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white">{bill.name}</div>
{#if bill.projectName}
<div class="text-xs text-gray-500 dark:text-gray-400">
Project: {bill.projectName}
</div>
{/if}
{#if bill.partyName}
<div class="text-xs text-gray-500 dark:text-gray-400">
Vendor: {bill.partyName}
</div>
{/if}
{#if bill.status === 'paused' && bill.pausedAt}
<div class="text-xs text-amber-600 dark:text-amber-400">
Paused since {formatDate(bill.pausedAt)}
</div>
{/if}
{#if bill.skipNext}
<div class="text-xs text-blue-600 dark:text-blue-400">Next cycle will be skipped</div>
{/if}
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
{formatAmount(bill.defaultAmount, bill.currency)}
{#if bill.nextCycleAmount}
<div class="text-xs text-blue-600 dark:text-blue-400">
Override: {formatAmount(bill.nextCycleAmount, bill.currency)}
</div>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
{CYCLE_LABELS[bill.cycle] ?? bill.cycle}
{#if bill.dayOfCycle !== null}
<div class="text-xs text-gray-400">day {bill.dayOfCycle}</div>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
{bill.accountName ?? '—'}
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
<span class={isOverdue(bill) ? 'font-medium text-red-600 dark:text-red-400' : ''}>
{bill.nextDueDate}
</span>
{#if isOverdue(bill)}
<div class="text-xs text-red-500">Overdue</div>
{/if}
{#if bill.lastPostedDate}
<div class="text-xs text-gray-400">
Last: {bill.lastPostedDate}
</div>
{/if}
</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[bill.status] ??
'bg-gray-100 text-gray-700'}"
>
{bill.status}
</span>
</td>
<td class="px-4 py-3 text-right text-xs">
<div class="flex flex-wrap justify-end gap-2">
<button
type="button"
onclick={() => startEdit(bill.id)}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
{#if bill.status === 'active'}
<form
method="POST"
action="?/pauseBill"
use:enhance={() => async ({ update }) => await update({ reset: false })}
>
<input type="hidden" name="id" value={bill.id} />
<button type="submit" class="font-medium text-amber-600 hover:text-amber-700 dark:text-amber-400">
Pause
</button>
</form>
<form
method="POST"
action="?/skipNextBill"
use:enhance={() => async ({ update }) => await update({ reset: false })}
>
<input type="hidden" name="id" value={bill.id} />
<button type="submit" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
Skip next
</button>
</form>
{:else if bill.status === 'paused'}
<form
method="POST"
action="?/resumeBill"
use:enhance={() => async ({ update }) => await update({ reset: false })}
>
<input type="hidden" name="id" value={bill.id} />
<button type="submit" class="font-medium text-emerald-600 hover:text-emerald-700 dark:text-emerald-400">
Resume
</button>
</form>
{/if}
<button
type="button"
onclick={() => (confirmDeleteId = confirmDeleteId === bill.id ? null : bill.id)}
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
</td>
</tr>
{#if confirmDeleteId === bill.id}
<tr>
<td colspan="7" class="bg-red-50 px-4 py-3 dark:bg-red-900/20">
<form
method="POST"
action="?/deleteBill"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
confirmDeleteId = null;
}}
class="flex items-center justify-between gap-3 text-xs"
>
<input type="hidden" name="id" value={bill.id} />
<p class="text-red-700 dark:text-red-300">
Delete "{bill.name}"? This soft-deletes the bill; already-posted expenses remain.
</p>
<div class="flex gap-2">
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
</td>
</tr>
{/if}
{#if editingBillId === bill.id}
<tr>
<td colspan="7" class="bg-gray-50 px-4 py-3 dark:bg-gray-700/30">
{@render billForm(
'?/updateBill',
{
name: bill.name,
amount: bill.defaultAmount,
cycle: bill.cycle,
accountId: bill.accountId,
projectId: bill.projectId,
categoryId: bill.categoryId ?? '',
partyId: bill.partyId ?? '',
description: bill.description ?? '',
currency: bill.currency,
startDate: bill.startDate,
endDate: bill.endDate ?? '',
dayOfCycle: bill.dayOfCycle?.toString() ?? ''
},
bill.id
)}
<form
method="POST"
action="?/setNextCycleAmount"
use:enhance={() => async ({ update }) => await update({ reset: false })}
class="mt-3 flex flex-wrap items-end gap-2 rounded-md bg-white p-3 text-xs dark:bg-gray-800"
>
<input type="hidden" name="id" value={bill.id} />
<div>
<label class={labelCls} for="override-{bill.id}">Next-cycle amount override</label>
<input
id="override-{bill.id}"
name="amount"
type="number"
step="0.01"
min="0"
value={bill.nextCycleAmount ?? ''}
class={inputCls}
placeholder="Leave blank to clear"
/>
</div>
<button
type="submit"
class="rounded-md bg-gray-700 px-3 py-2 font-medium text-white hover:bg-gray-800 dark:bg-gray-600"
>
Save override
</button>
</form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div> </div>