Skip to content
Merged
37 changes: 16 additions & 21 deletions apps/app/src/components/collaboration/CollaborativeBudgetField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useCollaborativeFragment } from '@/hooks/useCollaborativeFragment';
import type { BudgetData } from '@op/common/client';
import { Button } from '@op/ui/Button';
import { NumberField } from '@op/ui/NumberField';
import { useEffect, useRef, useState } from 'react';
Expand All @@ -9,15 +10,6 @@ import { useTranslations } from '@/lib/i18n';

import { useCollaborativeDoc } from './CollaborativeDocContext';

/**
* Yjs-synced budget shape — currency + amount as an atomic value
* TODO: the backend part will be done separately once we have more clarity how it's going to be used.
*/
interface BudgetValue {
currency: string;
amount: number;
}

const DEFAULT_CURRENCY = 'USD';
const DEFAULT_CURRENCY_SYMBOL = '$';

Expand All @@ -32,13 +24,13 @@ function formatBudgetDisplay(amount: number, currencySymbol: string): string {

interface CollaborativeBudgetFieldProps {
maxAmount?: number;
initialValue?: number | null;
onChange?: (budget: number | null) => void;
initialValue?: BudgetData | null;
onChange?: (budget: BudgetData | null) => void;
}

/**
* Collaborative budget input synced via Yjs XmlFragment.
* Stores `{ currency, amount }` as a JSON string in the shared doc
* Stores `MoneyAmount` (`{ amount, currency }`) as a JSON string in the shared doc
* for future multi-currency support.
*
* Displays as a pill when a value exists, switching to an inline
Expand All @@ -55,7 +47,7 @@ export function CollaborativeBudgetField({

const initialBudgetValue =
initialValue !== null
? { currency: DEFAULT_CURRENCY, amount: initialValue }
? { currency: initialValue.currency, amount: initialValue.amount }
: null;

const [budgetText, setBudgetText] = useCollaborativeFragment(
Expand All @@ -64,12 +56,12 @@ export function CollaborativeBudgetField({
initialBudgetValue ? JSON.stringify(initialBudgetValue) : '',
);

const budget = budgetText ? (JSON.parse(budgetText) as BudgetValue) : null;
const setBudget = (value: BudgetValue | null) =>
setBudgetText(value ? JSON.stringify(value) : '');
const budget = budgetText ? (JSON.parse(budgetText) as BudgetData) : null;
const setBudget = (newBudget: BudgetData | null) =>
setBudgetText(newBudget ? JSON.stringify(newBudget) : '');

const onChangeRef = useRef(onChange);
const lastEmittedAmountRef = useRef<number | null | undefined>(undefined);
const lastEmittedRef = useRef<string | undefined>(undefined);

useEffect(() => {
onChangeRef.current = onChange;
Expand Down Expand Up @@ -100,13 +92,16 @@ export function CollaborativeBudgetField({
};

useEffect(() => {
if (lastEmittedAmountRef.current === budgetAmount) {
const emitted: BudgetData | null = budget;
const key = emitted ? `${emitted.amount}:${emitted.currency}` : null;

if (lastEmittedRef.current === key) {
return;
}

lastEmittedAmountRef.current = budgetAmount;
onChangeRef.current?.(budgetAmount);
}, [budgetAmount]);
lastEmittedRef.current = key ?? undefined;
onChangeRef.current?.(emitted);
}, [budget]);

const handleStartEditing = () => {
setIsEditing(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export function BudgetFieldConfig({
const budgetSchema = getFieldSchema(template, 'budget');
const showBudget = !!budgetSchema;
const budgetCurrency =
(budgetSchema?.['x-currency'] as string | undefined) ?? 'USD';
(budgetSchema?.properties?.currency as { default?: string } | undefined)
?.default ?? 'USD';
const budgetCurrencySymbol = CURRENCY_SYMBOL_MAP.get(budgetCurrency) ?? '$';
const budgetMaxAmount = budgetSchema?.maximum as number | undefined;
const budgetRequired = isFieldRequired(template, 'budget');
Expand All @@ -64,10 +65,13 @@ export function BudgetFieldConfig({
properties: {
...prev.properties,
budget: {
type: 'number',
type: 'object',
title: t('Budget'),
'x-format': 'money',
'x-currency': 'USD',
properties: {
amount: { type: 'number' },
currency: { type: 'string', default: 'USD' },
},
},
},
}));
Expand Down Expand Up @@ -95,11 +99,24 @@ export function BudgetFieldConfig({
if (!existing) {
return prev;
}
const existingProps = (existing.properties ?? {}) as Record<
string,
Record<string, unknown>
>;
return {
...prev,
properties: {
...prev.properties,
budget: { ...existing, 'x-currency': String(key) },
budget: {
...existing,
properties: {
...existingProps,
currency: {
...(existingProps.currency ?? { type: 'string' }),
default: String(key),
},
},
},
},
};
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { trpc } from '@op/api/client';
import { SYSTEM_FIELD_KEYS } from '@op/common/client';
import { useDebouncedCallback, useMediaQuery } from '@op/hooks';
import { screens } from '@op/styles/constants';
import { FieldConfigCard } from '@op/ui/FieldConfigCard';
Expand Down Expand Up @@ -102,8 +103,12 @@ export function TemplateEditorContent({

const updateInstance = trpc.decision.updateDecisionInstance.useMutation();

// Derive field views from the template (all fields are editable)
const fields = useMemo(() => getFields(template), [template]);
// Derive field views from the template, excluding locked system fields
// that are always rendered separately above the sortable list.
const fields = useMemo(
() => getFields(template).filter((f) => !SYSTEM_FIELD_KEYS.has(f.id)),
[template],
);

// Sidebar field list — includes visual-only locked fields at the top
const sidebarFields = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,24 @@ export function ProposalCardBudget({
const { budget } = parseProposalData(proposal.proposalData);

// Use allocated amount if provided, otherwise fall back to budget
const displayAmount = !isNullish(allocated) ? Number(allocated) : budget;
if (!isNullish(allocated)) {
return (
<span
className={cn(
'font-serif text-title-base text-neutral-charcoal',
className,
)}
>
{formatCurrency(
Number(allocated),
undefined,
budget?.currency ?? 'USD',
)}
</span>
);
}

if (!displayAmount) {
if (!budget) {
return null;
}

Expand All @@ -163,7 +178,7 @@ export function ProposalCardBudget({
className,
)}
>
{formatCurrency(displayAmount)}
{formatCurrency(budget.amount, undefined, budget.currency)}
</span>
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/components/decisions/ProposalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export function ProposalView({
)}
{budget && (
<span className="font-serif text-title-base text-neutral-black">
{formatCurrency(budget)}
{formatCurrency(budget.amount, undefined, budget.currency)}
</span>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function ProposalEditor({
missingFields.push(t('Title'));
}

if (templateRequired.includes('budget') && currentDraft.budget === null) {
if (templateRequired.includes('budget') && !currentDraft.budget) {
missingFields.push(t('Budget'));
}

Expand Down Expand Up @@ -178,7 +178,7 @@ export function ProposalEditor({
if (
currentDraft.budget !== null &&
budgetMax !== undefined &&
currentDraft.budget > budgetMax
currentDraft.budget.amount > budgetMax
) {
toast.error({
message: t('Budget cannot exceed {amount}', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ function renderField(
return (
<CollaborativeBudgetField
maxAmount={schema.maximum}
initialValue={(draft[key] as number | null) ?? null}
initialValue={null}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we just make it optional and null by default?

onChange={(value) => onFieldChange(key, value)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export type XFormat = 'short-text' | 'long-text' | 'money' | 'category';
*/
export interface ProposalPropertySchema extends JSONSchema7 {
'x-format'?: string;
'x-currency'?: string;
}

export interface ProposalTemplateSchema extends JSONSchema7 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { trpc } from '@op/api/client';
import type { proposalEncoder } from '@op/api/encoders';
import { type ProposalDataInput, parseProposalData } from '@op/common/client';
import {
type BudgetData,
type ProposalDataInput,
normalizeBudget,
parseProposalData,
} from '@op/common/client';
import { useDebouncedCallback } from '@op/hooks';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { z } from 'zod';
Expand All @@ -16,7 +21,7 @@ type Proposal = z.infer<typeof proposalEncoder>;
export interface ProposalDraftFields extends Record<string, unknown> {
title: string;
category: string | null;
budget: number | null;
budget: BudgetData | null;
}

/**
Expand Down Expand Up @@ -125,7 +130,7 @@ export function useProposalDraft({
} else if (key === 'category') {
next.category = typeof value === 'string' ? value : null;
} else if (key === 'budget') {
next.budget = typeof value === 'number' ? value : null;
next.budget = normalizeBudget(value) ?? null;
}
// Dynamic fields are Yjs-only — we don't store them in draft state.

Expand Down
1 change: 1 addition & 0 deletions packages/common/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Client-safe exports for @op/common
// This file should only export types and schemas that don't depend on server-only modules

export * from './money';
export * from './services/decision/proposalDataSchema';
export * from './services/decision/types';
export {
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './money';
export * from './realtime';
export * from './services';
export * from './utils';
Expand Down
13 changes: 13 additions & 0 deletions packages/common/src/money.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { z } from 'zod';

/**
* Canonical schema for a monetary amount.
* `amount` is the numeric value; `currency` is an ISO 4217 code (e.g. "USD").
*/
export const moneyAmountSchema = z.object({
amount: z.number(),
currency: z.string(),
});

/** A monetary amount with currency. */
export type MoneyAmount = z.infer<typeof moneyAmountSchema>;
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export async function generateProposalsCsv(
'Proposal ID': p.id,
Title: proposalData.title || '',
Description: getDocumentDescription(p),
Budget: proposalData.budget ?? '',
Budget: proposalData.budget?.amount ?? '',
Currency: proposalData.budget?.currency ?? '',
Category: proposalData.category ?? '',
Status: p.status,
'Submitted By': p.submittedBy?.name || '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ export async function getProposalDocumentsContent(
const fragmentNames = proposalTemplate
? getProposalFragmentNames(proposalTemplate)
: ['default'];

const fragments = await client.getDocumentFragments(
collaborationDocId,
fragmentNames,
);

return { id, fragments };
} catch (error) {
console.warn('Failed to fetch TipTap document', {
Expand Down
8 changes: 4 additions & 4 deletions packages/common/src/services/decision/getResultsStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { assertAccess, permission } from 'access-zones';

import { NotFoundError } from '../../utils';
import { getOrgAccessUser } from '../access';
import { extractBudgetValue } from './proposalDataSchema';

export const getResultsStats = async ({
instanceId,
Expand Down Expand Up @@ -93,11 +94,10 @@ export const getResultsStats = async ({
// Use allocated amount if it exists, otherwise fall back to budget
if (item.allocated !== null) {
const allocatedNum = Number(item.allocated);
return sum + (isNaN(allocatedNum) ? 0 : allocatedNum);
return sum + (Number.isNaN(allocatedNum) ? 0 : allocatedNum);
}
const proposalData = item.proposalData as any;
const budget = proposalData?.budget ?? 0;
return sum + (typeof budget === 'number' ? budget : 0);
const proposalData = item.proposalData as Record<string, unknown>;
return sum + extractBudgetValue(proposalData?.budget);
}, 0);

return {
Expand Down
Loading