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.
225 lines
6.7 KiB
TypeScript
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>
|
|
)
|
|
}
|