Files
wgBill/frontend/src/components/GroupStep.tsx
T
dodox d97aac0e17 Initial scaffold: Flask/SQLite backend, React/Vite PWA frontend
Backend: receipt upload -> LLM vision extraction (OpenAI-compatible,
provider-agnostic), item review/edit, per-group splitting against
Cospend projects/members, highlight+upload via WebDAV, public share
link, bill creation via Cospend's OCS API (verified against real
source, not just doc summaries).

Frontend: capture -> review -> group -> summary flow as an installable
PWA.

install.sh / run.sh (venv + npm, tmux session) instead of Docker, per
the ~/Projects/gain pattern.
2026-08-30 15:02:18 +02:00

225 lines
6.7 KiB
TypeScript

import { useEffect, useState } from 'react'
import { api } from '../api'
import type { CospendProject, Group, Item, Member } from '../types'
interface Props {
receiptId: string
items: Item[]
onAllDone: (groups: Group[]) => void
}
export function GroupStep({ receiptId, items, onAllDone }: Props) {
const [projects, setProjects] = useState<CospendProject[]>([])
const [projectId, setProjectId] = useState<string | null>(null)
const [members, setMembers] = useState<Member[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [remainingIds, setRemainingIds] = useState<Set<string>>(new Set(items.map((i) => i.id)))
const [groups, setGroups] = useState<Group[]>([])
// current draft
const [name, setName] = useState('WG')
const [payerId, setPayerId] = useState<string | null>(null)
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(new Set())
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set())
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// load Cospend projects once, pick the configured default (or the only one)
useEffect(() => {
api
.getProjects()
.then(({ projects: p, default_project_id }) => {
setProjects(p)
const initial = default_project_id ?? (p.length === 1 ? p[0].id : null)
if (initial) setProjectId(initial)
})
.catch((e) => setLoadError(e instanceof Error ? e.message : String(e)))
}, [])
// load members whenever the selected project changes
useEffect(() => {
if (!projectId) return
api
.getMembers(projectId)
.then((m) => {
setMembers(m)
if (m.length > 0) {
setPayerId(m[0].id)
setSelectedMemberIds(new Set(m.map((mm) => mm.id)))
}
})
.catch((e) => setLoadError(e instanceof Error ? e.message : String(e)))
}, [projectId])
function toggleItem(id: string) {
setSelectedItemIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
function toggleMember(id: string) {
setSelectedMemberIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const remainingItems = items.filter((i) => remainingIds.has(i.id))
const groupTotal = items
.filter((i) => selectedItemIds.has(i.id))
.reduce((sum, i) => sum + i.price, 0)
async function submitCurrentGroup() {
if (!projectId) {
setError('pick a Cospend project')
return
}
if (!payerId || selectedMemberIds.size === 0 || selectedItemIds.size === 0) {
setError('pick a payer, at least one member, and at least one item')
return
}
setSubmitting(true)
setError(null)
try {
const { id: groupId } = await api.createGroup(receiptId, {
name,
cospend_project_id: projectId,
payer_member_id: payerId,
member_ids: [...selectedMemberIds],
item_ids: [...selectedItemIds],
})
const result = await api.submitGroup(receiptId, groupId)
const allGroups = await api.listGroups(receiptId)
setGroups(allGroups)
setRemainingIds((prev) => {
const next = new Set(prev)
selectedItemIds.forEach((id) => next.delete(id))
return next
})
const wasLastGroup = remainingItems.length === selectedItemIds.size
setSelectedItemIds(new Set())
setName('WG')
if (wasLastGroup) {
onAllDone(allGroups)
}
void result
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSubmitting(false)
}
}
if (loadError) {
return (
<div className="step">
<p className="error">Couldn't load Cospend data: {loadError}</p>
</div>
)
}
return (
<div className="step group-step">
<h2>Who's splitting this?</h2>
<p className="hint">
Select the items for one splitting arrangement, pick who's in on it, submit. Repeat for
any items that need a different group (e.g. only your snack).
</p>
<label className="payer-select">
Cospend project
<select value={projectId ?? ''} onChange={(e) => setProjectId(e.target.value)}>
<option value="" disabled>
select a project…
</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
{groups.length > 0 && (
<div className="submitted-groups">
{groups.map((g) => (
<p key={g.id}>
✓ {g.name} bill created —{' '}
<a href={g.share_url ?? '#'} target="_blank" rel="noreferrer">
receipt
</a>
</p>
))}
</div>
)}
<input
className="group-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="group name (e.g. WG, just me + Alex)"
/>
<h3>Items ({remainingItems.length} left)</h3>
<ul className="item-list selectable">
{remainingItems.map((item) => (
<li
key={item.id}
className={selectedItemIds.has(item.id) ? 'selected' : ''}
onClick={() => toggleItem(item.id)}
>
<span>{item.label || '(unnamed)'}</span>
<span>{item.price.toFixed(2)}</span>
</li>
))}
</ul>
<h3>Split with</h3>
<ul className="member-list">
{members.map((m) => (
<li key={m.id}>
<label>
<input
type="checkbox"
checked={selectedMemberIds.has(m.id)}
onChange={() => toggleMember(m.id)}
/>
{m.name}
</label>
</li>
))}
</ul>
<label className="payer-select">
Paid by
<select value={payerId ?? ''} onChange={(e) => setPayerId(e.target.value)}>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</label>
<p className="total">Group total: {groupTotal.toFixed(2)}</p>
{error && <p className="error">{error}</p>}
<button disabled={submitting || selectedItemIds.size === 0} onClick={submitCurrentGroup}>
{submitting ? 'Creating bill' : 'Create bill for this group'}
</button>
{remainingItems.length === 0 && groups.length > 0 && (
<button className="secondary" onClick={() => onAllDone(groups)}>
All items assigned finish
</button>
)}
</div>
)
}