Skip to content
Merged
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
111 changes: 111 additions & 0 deletions app/frontend/src/components/SplitEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useState } from 'react';
import { useAuth } from '../auth';
import type { Member } from '../spaces';
import type { CostSplit, Subscription } from '../subscriptions';
import { useAction } from '../useAction';
import { FormDialog } from './FormDialog';

export function SplitEditor({
members,
onClose,
onSaved,
subscription,
}: {
members: Member[];
onClose: () => void;
onSaved: (split: CostSplit | null) => void;
subscription: Subscription;
}) {
const auth = useAuth();
const saving = useAction();
const current = subscription.split;
const [payerId, setPayerId] = useState(current?.payerId ?? auth.user?.id ?? members[0]?.userId ?? '');
const [weights, setWeights] = useState<Record<string, number>>(() =>
Object.fromEntries(current ? current.shares.map((share) => [share.userId, share.weight]) : members.map((member) => [member.userId, 1])),
);
const shares = Object.entries(weights).map(([userId, weight]) => ({ userId, weight }));
const total = shares.reduce((sum, share) => sum + share.weight, 0);

return (
<FormDialog
busy={saving.busy}
busyLabel="Saving…"
onClose={onClose}
onSubmit={() =>
void saving
.run(async () => {
if (shares.length < 2) throw new Error('Split between at least two members.');
onSaved(await auth.request<CostSplit>(`/subscriptions/${subscription.id}/split`, { method: 'PUT', body: JSON.stringify({ payerId, shares }) }));
return `${subscription.name} is split between ${shares.length} members.`;
}, 'Could not save the split')
.then((saved) => saved && onClose())
}
submitLabel="Save split"
title={`Split ${subscription.name}`}
>
<label className="field-label">
Paid by
<select onChange={(event) => setPayerId(event.currentTarget.value)} value={payerId}>
{members.map((member) => (
<option key={member.userId} value={member.userId}>
{member.displayName}
</option>
))}
</select>
</label>
<fieldset className="m-0 grid gap-2 border-0 p-0">
<legend className="field-label mb-1.5 p-0">Shared by</legend>
{members.map((member) => {
const weight = weights[member.userId];
return (
<div className="flex items-center gap-3" key={member.userId}>
<label className="flex flex-1 items-center gap-2 text-sm">
<input
checked={weight !== undefined}
onChange={(event) => {
const { [member.userId]: _removed, ...rest } = weights;
setWeights(event.currentTarget.checked ? { ...rest, [member.userId]: 1 } : rest);
}}
type="checkbox"
/>
{member.displayName}
</label>
{weight !== undefined && (
<>
<input
aria-label={`Shares for ${member.displayName}`}
className="w-16 text-sm"
max={100}
min={1}
onChange={(event) => setWeights({ ...weights, [member.userId]: Math.min(100, Math.max(1, Number(event.currentTarget.value) || 1)) })}
type="number"
value={weight}
/>
<span className="mono-sm w-10 text-right text-faint">{Math.round((weight / total) * 100)}%</span>
</>
)}
</div>
);
})}
</fieldset>
{current && (
<button
className="text-button w-fit text-sm"
disabled={saving.busy}
onClick={() =>
void saving
.run(async () => {
await auth.request(`/subscriptions/${subscription.id}/split`, { method: 'DELETE' });
onSaved(null);
return `${subscription.name} is no longer split.`;
}, 'Could not change the split')
.then((done) => done && onClose())
}
type="button"
>
Stop splitting
</button>
)}
</FormDialog>
);
}
3 changes: 2 additions & 1 deletion app/frontend/src/components/database/DatabaseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { Icon, ListSkeleton } from '@project-graphite/ui';
import { addDays, dayKey, monthDays } from '../../agenda';
import { useAuth } from '../../auth';
import { databaseViews, noteTitle, type DatabaseRow, type Note, type NoteDatabase, type Property, type PropertyValues } from '../../notes';
import type { Member } from '../../spaces';
import { useAction } from '../../useAction';
import { useResource } from '../../useResource';
import { LoadError } from '../LoadError';
import { PropertiesDialog } from './PropertiesDialog';
import { PropertyField, propertyDisplay, type Member } from './PropertyField';
import { PropertyField, propertyDisplay } from './PropertyField';

const blank = (display: ReactNode) => display === null || (Array.isArray(display) && display.length === 0);

Expand Down
6 changes: 1 addition & 5 deletions app/frontend/src/components/database/PropertyField.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import type { ReactNode } from 'react';
import { Popover, TagChip } from '@project-graphite/ui';
import type { Property } from '../../notes';

export interface Member {
userId: string;
displayName: string;
}
import type { Member } from '../../spaces';

const optionsOf = (property: Property, ids: unknown) =>
(Array.isArray(ids) ? ids : [ids]).flatMap((id) => property.options?.filter((option) => option.id === id) ?? []);
Expand Down
4 changes: 2 additions & 2 deletions app/frontend/src/pages/NotePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { Link, useNavigate, useParams } from 'react-router';
import { ConfirmDialog, EmptyState, Icon, LinesSkeleton, PageSkeleton } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { DatabaseView } from '../components/database/DatabaseView';
import { PropertyField, type Member } from '../components/database/PropertyField';
import { PropertyField } from '../components/database/PropertyField';
import { LoadError } from '../components/LoadError';
import { NoteHistory } from '../components/NoteHistory';
import { noteTitle, type Note, type NoteDetails, type PropertyValues } from '../notes';
import { itemHref, useSpaces } from '../spaces';
import { itemHref, useSpaces, type Member } from '../spaces';
import { useAction } from '../useAction';
import { useResource } from '../useResource';

Expand Down
119 changes: 119 additions & 0 deletions app/frontend/src/pages/spaces/SpaceBalances.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useState } from 'react';
import { ListSkeleton } from '@project-graphite/ui';
import { useAuth } from '../../auth';
import { LoadError } from '../../components/LoadError';
import { money } from '../../subscriptions';
import { useAction } from '../../useAction';
import { useResource } from '../../useResource';

interface Debt {
fromUserId: string;
fromName: string;
toUserId: string;
toName: string;
amountMinor: number;
currency: string;
}

interface Balances {
month: string;
charges: { itemId: string; name: string; payerName: string; amountMinor: number; currency: string; renewals: string[] }[];
debts: Debt[];
settlements: (Debt & { id: string })[];
}

const shiftMonth = (month: string, by: number) => {
const [year, number] = month.split('-').map(Number) as [number, number];
const date = new Date(Date.UTC(year, number - 1 + by, 1));
return date.toISOString().slice(0, 7);
};

export function SpaceBalances({ spaceId }: { spaceId: string }) {
const auth = useAuth();
const me = auth.user?.id;
const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7));
const balances = useResource<Balances>(`/spaces/${spaceId}/balances?month=${month}`, true);
const action = useAction();

function settle(debt: Debt) {
void action.run(async () => {
await auth.request(`/spaces/${spaceId}/settlements`, {
method: 'POST',
body: JSON.stringify({ fromUserId: debt.fromUserId, toUserId: debt.toUserId, amountMinor: debt.amountMinor, currency: debt.currency, month }),
});
balances.reload();
return `Recorded ${money(debt.amountMinor, debt.currency)} from ${debt.fromName} to ${debt.toName}.`;
}, 'Could not record the payment');
}

function undo(id: string) {
void action.run(async () => {
await auth.request(`/settlements/${id}`, { method: 'DELETE' });
balances.reload();
return 'The payment was removed.';
}, 'Could not remove the payment');
}

const label = new Date(`${month}-01T00:00:00`).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });

return (
<section aria-label="Split costs" className="grid gap-3 rounded-xl border border-line bg-surface p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="m-0 text-base font-medium">Split costs · {label}</h2>
<div className="flex gap-2">
<button aria-label="Previous month" className="secondary-button px-2.5 py-1 text-sm" onClick={() => setMonth(shiftMonth(month, -1))} type="button">
←
</button>
<button aria-label="Next month" className="secondary-button px-2.5 py-1 text-sm" onClick={() => setMonth(shiftMonth(month, 1))} type="button">
→
</button>
</div>
</div>
{balances.error ? (
<LoadError error={balances.error} onRetry={balances.reload} />
) : !balances.data ? (
<ListSkeleton label="Adding up who owes what" rows={2} />
) : balances.data.charges.length === 0 && balances.data.settlements.length === 0 ? (
<p className="m-0 text-sm text-muted">No split subscriptions renew this month.</p>
) : (
<>
{balances.data.debts.length === 0 ? (
<p className="m-0 text-ink">Everyone is square.</p>
) : (
<ul className="m-0 grid list-none gap-2 p-0">
{balances.data.debts.map((debt) => (
<li className="flex flex-wrap items-center justify-between gap-3" key={`${debt.fromUserId}-${debt.toUserId}-${debt.currency}`}>
<span className="text-ink">
{debt.fromName} owes {debt.toName} {money(debt.amountMinor, debt.currency)}
</span>
{me === debt.toUserId && (
<button className="secondary-button px-3 py-1.5 text-sm" disabled={action.busy} onClick={() => settle(debt)} type="button">
Mark as paid
</button>
)}
</li>
))}
</ul>
)}
<p className="mono-sm m-0 text-faint">
{balances.data.charges.map((charge) => `${charge.name} ${money(charge.amountMinor, charge.currency)}, paid by ${charge.payerName}`).join(' · ')}
</p>
{balances.data.settlements.length > 0 && (
<ul className="m-0 grid list-none gap-1 p-0 text-sm text-muted">
{balances.data.settlements.map((payment) => (
<li className="flex flex-wrap items-center gap-2" key={payment.id}>
{payment.fromName} paid {payment.toName} {money(payment.amountMinor, payment.currency)}
{(me === payment.fromUserId || me === payment.toUserId) && (
<button className="text-button text-sm" disabled={action.busy} onClick={() => undo(payment.id)} type="button">
Undo
</button>
)}
</li>
))}
</ul>
)}
</>
)}
</section>
);
}
23 changes: 23 additions & 0 deletions app/frontend/src/pages/spaces/SpaceSubscriptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { EmptyState, ListSkeleton } from '@project-graphite/ui';
import { useAuth } from '../../auth';
import { SplitEditor } from '../../components/SplitEditor';
import { SubscriptionEditor } from '../../components/SubscriptionEditor';
import type { Member } from '../../spaces';
import { cycleLabel, money, shortDate, type Subscription } from '../../subscriptions';
import { useAction } from '../../useAction';
import { useResource } from '../../useResource';
import { SpaceBalances } from './SpaceBalances';
import { useSpace } from './SpaceLayout';
import { LoadError } from '../../components/LoadError';

Expand All @@ -21,7 +24,12 @@ export function SpaceSubscriptions() {
);
const action = useAction();
const [editing, setEditing] = useState<Subscription | 'new'>();
const [splitting, setSplitting] = useState<Subscription>();
const editable = space.role !== 'viewer';
const shared = space.kind === 'shared';
const members = useResource<Member[]>(shared ? `/spaces/${space.id}/members` : null, true);
const memberName = (userId: string) => members.data?.find((member) => member.userId === userId)?.displayName ?? 'a former member';
const splitVersion = (subscriptions.data ?? []).map((subscription) => JSON.stringify(subscription.split ?? null)).join();

function act(subscription: Subscription, verb: 'used' | 'cancel' | 'resume', message: string) {
void action.run(async () => {
Expand Down Expand Up @@ -50,6 +58,7 @@ export function SpaceSubscriptions() {
</button>
)}
</div>
{shared && !cancelled && <SpaceBalances key={splitVersion} spaceId={space.id} />}
{subscriptions.data.length === 0 ? (
<EmptyState title={cancelled ? 'Nothing cancelled' : 'No subscriptions yet'} />
) : (
Expand All @@ -67,6 +76,7 @@ export function SpaceSubscriptions() {
cancelled ? `cancelled ${shortDate(subscription.cancelledOn!)}` : `renews ${shortDate(subscription.nextRenewal)}`,
subscription.trialEndsOn && !cancelled && `trial ends ${shortDate(subscription.trialEndsOn)}`,
subscription.paymentLabel,
subscription.split && `split · paid by ${memberName(subscription.split.payerId)}`,
]
.filter(Boolean)
.join(' · ')}
Expand All @@ -83,6 +93,11 @@ export function SpaceSubscriptions() {
<button className="secondary-button px-3 py-2 text-sm" onClick={() => setEditing(subscription)} type="button">
Edit
</button>
{shared && members.data && members.data.length > 1 && (
<button className="secondary-button px-3 py-2 text-sm" onClick={() => setSplitting(subscription)} type="button">
Split
</button>
)}
<button className="secondary-button px-3 py-2 text-sm" disabled={action.busy} onClick={() => act(subscription, 'used', `Noted that you used ${subscription.name}.`)} type="button">
I used this
</button>
Expand All @@ -102,6 +117,14 @@ export function SpaceSubscriptions() {
))}
</ul>
)}
{splitting && members.data && (
<SplitEditor
members={members.data}
onClose={() => setSplitting(undefined)}
onSaved={(split) => subscriptions.mutate((current) => current.map((shown) => (shown.id === splitting.id ? { ...shown, split } : shown)))}
subscription={splitting}
/>
)}
{editing && (
<SubscriptionEditor
onClose={() => setEditing(undefined)}
Expand Down
5 changes: 5 additions & 0 deletions app/frontend/src/spaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export interface Space {
createdAt: string;
}

export interface Member {
userId: string;
displayName: string;
}

export interface Tag {
id: string;
spaceId: string;
Expand Down
6 changes: 6 additions & 0 deletions app/frontend/src/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export interface CostSplit {
payerId: string;
shares: { userId: string; weight: number }[];
}

export interface Subscription {
id: string;
spaceId: string;
Expand All @@ -18,6 +23,7 @@ export interface Subscription {
reminderDays: number | null;
lastUsedOn: string | null;
cancelledOn: string | null;
split: CostSplit | null;
}

export const categories = [
Expand Down
Loading
Loading