soja-cli 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/application/finance.js +1 -0
  2. package/dist/application/intake.js +1 -0
  3. package/dist/application/services/task-service.js +26 -2
  4. package/dist/application/services/workspace-service.js +5 -2
  5. package/dist/application/timeline.js +2 -0
  6. package/dist/cli/commands/workspace.js +12 -1
  7. package/dist/cli/output.js +2 -1
  8. package/dist/data/local/tasks.js +5 -1
  9. package/dist/data/local/workspaces.js +6 -3
  10. package/dist/data/sync/engine.js +1 -1
  11. package/dist/data/sync/finance.js +15 -0
  12. package/dist/data/sync/index.js +4 -0
  13. package/dist/data/sync/intake.js +27 -0
  14. package/dist/data/sync/services.js +7 -2
  15. package/dist/data/sync/store.js +3 -3
  16. package/dist/database/migrations/0007_finances.sql +5 -0
  17. package/dist/database/migrations/0008_finances_designates.sql +44 -0
  18. package/dist/database/migrations/0009_external_approval.sql +1 -0
  19. package/dist/database/migrations/meta/0008_snapshot.json +1221 -0
  20. package/dist/database/migrations/meta/0009_snapshot.json +1228 -0
  21. package/dist/database/migrations/meta/_journal.json +21 -0
  22. package/dist/database/schema.js +7 -0
  23. package/dist/ui/Shell.js +7 -0
  24. package/dist/ui/chrome/Sidebar.js +1 -1
  25. package/dist/ui/components/TaskTable.js +1 -1
  26. package/dist/ui/hooks/use-commands.js +1 -0
  27. package/dist/ui/hooks/use-task-actions.js +36 -0
  28. package/dist/ui/screens/FinanceScreen.js +88 -0
  29. package/dist/ui/screens/HelpScreen.js +9 -0
  30. package/dist/ui/screens/ProjectsScreen.js +185 -1
  31. package/dist/ui/screens/TaskScreen.js +9 -1
  32. package/dist/ui/screens/WorkspacesScreen.js +29 -1
  33. package/package.json +1 -1
@@ -50,6 +50,27 @@
50
50
  "when": 1790294003288,
51
51
  "tag": "0006_archive",
52
52
  "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "6",
57
+ "when": 1790297600000,
58
+ "tag": "0007_finances",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1790448915874,
65
+ "tag": "0008_finances_designates",
66
+ "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1790453289569,
72
+ "tag": "0009_external_approval",
73
+ "breakpoints": true
53
74
  }
54
75
  ]
55
76
  }
@@ -32,6 +32,7 @@ export const workspaceMembers = sqliteTable('workspace_members', {
32
32
  .notNull()
33
33
  .references(() => users.id, { onDelete: 'cascade' }),
34
34
  role: text('role', { enum: WORKSPACE_ROLES }).notNull(),
35
+ designated: integer('designated', { mode: 'boolean' }).notNull().default(false),
35
36
  }, (t) => [primaryKey({ columns: [t.workspaceId, t.userId] }), check('workspace_members_role', oneOf('role', WORKSPACE_ROLES))]);
36
37
  export const projects = sqliteTable('projects', {
37
38
  id: text('id').primaryKey(),
@@ -62,6 +63,10 @@ export const tasks = sqliteTable('tasks', {
62
63
  .notNull()
63
64
  .references(() => users.id),
64
65
  requester: text('requester'),
66
+ remunerated: integer('remunerated', { mode: 'boolean' }).notNull().default(false),
67
+ priceMinor: integer('price_minor'),
68
+ currencyCode: text('currency_code'),
69
+ externalApprovalStatus: text('external_approval_status', { enum: ['pending', 'approved', 'rejected'] }),
65
70
  branch: text('branch'),
66
71
  baseBranch: text('base_branch'),
67
72
  branchStart: text('branch_start'),
@@ -77,6 +82,8 @@ export const tasks = sqliteTable('tasks', {
77
82
  check('tasks_type', oneOf('type', TASK_TYPES)),
78
83
  check('tasks_priority', oneOf('priority', TASK_PRIORITIES)),
79
84
  check('tasks_status', oneOf('status', TASK_STATUSES)),
85
+ check('tasks_price_nonnegative', sql.raw('price_minor IS NULL OR price_minor >= 0')),
86
+ check('tasks_remuneration_fields', sql.raw('(remunerated = 0 AND price_minor IS NULL AND currency_code IS NULL) OR (remunerated = 1 AND price_minor IS NOT NULL AND currency_code IS NOT NULL)')),
80
87
  ]);
81
88
  export const taskComments = sqliteTable('task_comments', {
82
89
  id: text('id').primaryKey(),
package/dist/ui/Shell.js CHANGED
@@ -27,6 +27,7 @@ import { TaskScreen } from './screens/TaskScreen.js';
27
27
  import { WorkspacesScreen } from './screens/WorkspacesScreen.js';
28
28
  import { ChatScreen } from './screens/ChatScreen.js';
29
29
  import { AccessScreen } from './screens/AccessScreen.js';
30
+ import { FinanceScreen } from './screens/FinanceScreen.js';
30
31
  import { ApiClient } from '../data/remote/api-client.js';
31
32
  import { CredentialStore } from '../config/credentials.js';
32
33
  import { resolvePaths } from '../config/paths.js';
@@ -90,6 +91,8 @@ function Frame() {
90
91
  go({ type: 'push', route: { name: 'projects' } });
91
92
  else if (input === 'w')
92
93
  go({ type: 'push', route: { name: 'workspaces' } });
94
+ else if (input === 'f' && services.finance)
95
+ go({ type: 'push', route: { name: 'finance' } });
93
96
  else if (input === 'A')
94
97
  go({ type: 'push', route: { name: 'access' } });
95
98
  else if (input === 'M')
@@ -131,6 +134,8 @@ function ScreenFor({ route, active }) {
131
134
  return _jsx(TaskListScreen, { active: active, projectId: route.projectId });
132
135
  case 'workspaces':
133
136
  return _jsx(WorkspacesScreen, { active: active });
137
+ case 'finance':
138
+ return _jsx(FinanceScreen, { active: active });
134
139
  case 'access':
135
140
  return _jsx(AccessScreen, { active: active });
136
141
  case 'help':
@@ -169,6 +174,8 @@ function trailFor(route, projectName) {
169
174
  return ['Projects'];
170
175
  case 'workspaces':
171
176
  return ['Workspaces'];
177
+ case 'finance':
178
+ return ['Finance'];
172
179
  case 'access':
173
180
  return ['Access approvals'];
174
181
  case 'help':
@@ -38,7 +38,7 @@ export function Sidebar({ width, height, active }) {
38
38
  const openChannels = (channels.data ?? []).filter((channel) => !channel.archivedAt);
39
39
  // Leave room for the sections that follow; long lists are cut with a "more" line.
40
40
  const projectRows = Math.max(1, height - VIEWS.length - 8 - (chat ? Math.min(openChannels.length, 5) + 2 : 0));
41
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, flexShrink: 0, paddingX: 1, overflow: "hidden", children: [_jsx(Section, { title: "TASKS" }), VIEWS.map((view) => (_jsx(Item, { label: VIEW_LABELS[view], count: counts.data?.[view], selected: onHome && homeFilter === view, onClick: () => openView(view), active: active }, view))), chat ? (_jsxs(_Fragment, { children: [_jsx(Section, { title: "CHAT" }), openChannels.slice(0, 5).map((channel) => (_jsx(Item, { label: `#${channel.name}`, count: channel.unread || undefined, highlight: channel.mentions > 0 ? 'mention' : channel.unread > 0 ? 'unread' : undefined, selected: route.name === 'chat' && route.channel === channel.name, onClick: () => open({ name: 'chat', channel: channel.name }), active: active }, channel.id)))] })) : null, _jsx(Section, { title: "PROJECTS" }), (projects.data ?? []).slice(0, projectRows).map((project) => (_jsx(Item, { label: project.name, count: project.active || undefined, selected: route.name === 'project' && route.projectId === project.id, onClick: () => open({ name: 'project', projectId: project.id }), active: active }, project.id))), (projects.data?.length ?? 0) > projectRows ? (_jsx(Item, { label: `${symbols.ellipsis} all projects`, selected: route.name === 'projects', onClick: () => open({ name: 'projects' }), active: active, muted: true })) : null, (projects.data?.length ?? 0) === 0 && !projects.loading ? _jsx(Text, { color: palette.faint, children: ' none yet · p' }) : null] }));
41
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, flexShrink: 0, paddingX: 1, overflow: "hidden", children: [_jsx(Section, { title: "TASKS" }), VIEWS.map((view) => (_jsx(Item, { label: VIEW_LABELS[view], count: counts.data?.[view], selected: onHome && homeFilter === view, onClick: () => openView(view), active: active }, view))), chat ? (_jsxs(_Fragment, { children: [_jsx(Section, { title: "CHAT" }), openChannels.slice(0, 5).map((channel) => (_jsx(Item, { label: `#${channel.name}`, count: channel.unread || undefined, highlight: channel.mentions > 0 ? 'mention' : channel.unread > 0 ? 'unread' : undefined, selected: route.name === 'chat' && route.channel === channel.name, onClick: () => open({ name: 'chat', channel: channel.name }), active: active }, channel.id)))] })) : null, services.finance ? (_jsxs(_Fragment, { children: [_jsx(Section, { title: "REPORTS" }), _jsx(Item, { label: "Finance & performance", selected: route.name === 'finance', onClick: () => open({ name: 'finance' }), active: active })] })) : null, _jsx(Section, { title: "PROJECTS" }), (projects.data ?? []).slice(0, projectRows).map((project) => (_jsx(Item, { label: project.name, count: project.active || undefined, selected: route.name === 'project' && route.projectId === project.id, onClick: () => open({ name: 'project', projectId: project.id }), active: active }, project.id))), (projects.data?.length ?? 0) > projectRows ? (_jsx(Item, { label: `${symbols.ellipsis} all projects`, selected: route.name === 'projects', onClick: () => open({ name: 'projects' }), active: active, muted: true })) : null, (projects.data?.length ?? 0) === 0 && !projects.loading ? _jsx(Text, { color: palette.faint, children: ' none yet · p' }) : null] }));
42
42
  }
43
43
  function Section({ title }) {
44
44
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.faint, bold: true, children: title }) }));
@@ -29,7 +29,7 @@ export function TaskTable({ tasks, selected, offset, height, width, showAssignee
29
29
  const pr = pullRequests?.get(task.id);
30
30
  const bg = isSelected ? { backgroundColor: palette.selection } : {};
31
31
  const closed = task.status === 'cancelled' || task.status === 'done';
32
- return (_jsx(Clickable, { layer: layer, active: active, width: "100%", onClick: () => (isSelected ? onOpen?.(index) : onSelect?.(index)), children: _jsxs(Box, { flexGrow: 1, ...bg, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: palette.accent, ...bg, children: isSelected ? `${symbols.pointer} ` : ' ' }) }), _jsx(Box, { width: columns.ref, flexShrink: 0, children: _jsx(Text, { color: isSelected ? palette.accent : palette.muted, bold: isSelected, ...bg, children: task.ref }) }), _jsx(Box, { width: columns.priority, flexShrink: 0, children: _jsx(PriorityMeter, { priority: task.priority, label: columns.priorityLabel, background: bg.backgroundColor }) }), _jsx(Box, { width: columns.status, flexShrink: 0, children: _jsx(StatusBadge, { status: task.status, glyphOnly: columns.statusGlyphOnly }) }), columns.project ? (_jsx(Box, { width: columns.project, flexShrink: 0, paddingRight: 1, children: _jsx(Text, { color: task.project ? palette.muted : palette.faint, wrap: "truncate-end", ...bg, children: task.project?.name ?? '—' }) })) : null, columns.assignee ? (_jsx(Box, { width: columns.assignee, flexShrink: 0, paddingRight: 1, children: _jsx(Text, { color: palette.muted, wrap: "truncate-end", ...bg, children: task.assignee ? `@${task.assignee.username}` : '—' }) })) : null, _jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsxs(Text, { wrap: "truncate-end", ...bg, children: [task.archivedAt ? _jsx(Text, { color: palette.faint, children: 'archived · ' }) : null, pr ? _jsx(PullRequestMark, { pr: pr }) : null, _jsx(Text, { color: closed ? palette.muted : palette.text, bold: isSelected, strikethrough: task.status === 'cancelled', children: task.title })] }) })] }) }, task.id));
32
+ return (_jsx(Clickable, { layer: layer, active: active, width: "100%", onClick: () => (isSelected ? onOpen?.(index) : onSelect?.(index)), children: _jsxs(Box, { flexGrow: 1, ...bg, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: palette.accent, ...bg, children: isSelected ? `${symbols.pointer} ` : ' ' }) }), _jsx(Box, { width: columns.ref, flexShrink: 0, children: _jsx(Text, { color: isSelected ? palette.accent : palette.muted, bold: isSelected, ...bg, children: task.ref }) }), _jsx(Box, { width: columns.priority, flexShrink: 0, children: _jsx(PriorityMeter, { priority: task.priority, label: columns.priorityLabel, background: bg.backgroundColor }) }), _jsx(Box, { width: columns.status, flexShrink: 0, children: _jsx(StatusBadge, { status: task.status, glyphOnly: columns.statusGlyphOnly }) }), columns.project ? (_jsx(Box, { width: columns.project, flexShrink: 0, paddingRight: 1, children: _jsx(Text, { color: task.project ? palette.muted : palette.faint, wrap: "truncate-end", ...bg, children: task.project?.name ?? '—' }) })) : null, columns.assignee ? (_jsx(Box, { width: columns.assignee, flexShrink: 0, paddingRight: 1, children: _jsx(Text, { color: palette.muted, wrap: "truncate-end", ...bg, children: task.assignee ? `@${task.assignee.username}` : '—' }) })) : null, _jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsxs(Text, { wrap: "truncate-end", ...bg, children: [task.archivedAt ? _jsx(Text, { color: palette.faint, children: 'archived · ' }) : null, task.remunerated ? _jsx(Text, { color: palette.success, children: '$ ' }) : null, pr ? _jsx(PullRequestMark, { pr: pr }) : null, _jsx(Text, { color: closed ? palette.muted : palette.text, bold: isSelected, strikethrough: task.status === 'cancelled', children: task.title })] }) })] }) }, task.id));
33
33
  }) }), _jsx(ScrollBar, { total: tasks.length, visible: rows, offset: offset, height: Math.min(rows, tasks.length) })] })] }) }));
34
34
  }
35
35
  function Cell({ width, text }) {
@@ -27,6 +27,7 @@ export function useCommandPalette(newTaskProjectId) {
27
27
  { id: 'new-project', label: 'New project', run: () => flows.newProject() },
28
28
  { id: 'switch-workspace', label: 'Switch workspace', run: () => flows.pickWorkspace() },
29
29
  { id: 'workspaces', label: 'Workspaces', key: 'w', run: () => go({ type: 'push', route: { name: 'workspaces' } }) },
30
+ ...(services.finance ? [{ id: 'finance', label: 'Finance & performance', key: 'f', run: () => go({ type: 'push', route: { name: 'finance' } }) }] : []),
30
31
  { id: 'new-workspace', label: 'New workspace', run: () => flows.newWorkspace() },
31
32
  { id: 'add-developer', label: 'Add developer', run: () => flows.addDeveloper() },
32
33
  { id: 'parent-folders', label: 'Parent folders', run: () => flows.manageParentFolders() },
@@ -111,6 +111,42 @@ export function useTaskActions() {
111
111
  onSubmit: (value) => update(task, { requester: value }, value.trim() ? `requested by ${value.trim()}` : 'has no requester'),
112
112
  });
113
113
  },
114
+ async price(task) {
115
+ if (!services.finance) {
116
+ notify('Ticket pricing requires an online workspace with finance enabled.', 'info');
117
+ return;
118
+ }
119
+ const summary = await services.finance.summary(session);
120
+ const currencyCode = task.currencyCode ?? summary.currencyCode;
121
+ if (!currencyCode) {
122
+ notify('Set the workspace currency in Finance before pricing tickets.', 'info');
123
+ return;
124
+ }
125
+ const digits = new Intl.NumberFormat('en', { style: 'currency', currency: currencyCode }).resolvedOptions().maximumFractionDigits ?? 2;
126
+ const initial = task.priceMinor === null ? '' : (task.priceMinor / 10 ** digits).toFixed(digits);
127
+ openOverlay({
128
+ kind: 'prompt',
129
+ title: `Ticket price (${currencyCode})`,
130
+ context: task.ref,
131
+ initial,
132
+ placeholder: `e.g. ${digits ? `12.${'0'.repeat(digits)}` : '12'} · or “unpaid”`,
133
+ onSubmit: (value) => {
134
+ if (value.trim().toLowerCase() === 'unpaid')
135
+ return update(task, { remunerated: false }, 'marked unpaid');
136
+ const pattern = digits ? new RegExp(`^\\d+(?:\\.\\d{1,${digits}})?$`) : /^\\d+$/;
137
+ if (!pattern.test(value.trim())) {
138
+ notify(`Enter a non-negative amount with up to ${digits} decimal places, or “unpaid”.`, 'error');
139
+ return false;
140
+ }
141
+ const amountMinor = Math.round(Number(value.trim()) * 10 ** digits);
142
+ if (!Number.isSafeInteger(amountMinor)) {
143
+ notify('That amount is too large.', 'error');
144
+ return false;
145
+ }
146
+ return update(task, { remunerated: true, priceMinor: amountMinor, currencyCode }, `priced at ${value.trim()} ${currencyCode}`);
147
+ },
148
+ });
149
+ },
114
150
  title(task) {
115
151
  openOverlay({
116
152
  kind: 'prompt',
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useAppState } from '../app-state.js';
4
+ import { EmptyState } from '../components/EmptyState.js';
5
+ import { useLayout } from '../hooks/use-layout.js';
6
+ import { useQuery } from '../hooks/use-query.js';
7
+ import { Layer } from '../input/dispatcher.js';
8
+ import { useKeys } from '../input/KeyProvider.js';
9
+ import { Panel } from '../kit/Panel.js';
10
+ import { ScreenFrame } from './ScreenFrame.js';
11
+ import { palette, symbols } from '../theme/theme.js';
12
+ const PERIODS = ['hourly', 'weekly', 'monthly', 'yearly'];
13
+ export function FinanceScreen({ active }) {
14
+ const { services, session, openOverlay, run, invalidateQueries } = useAppState();
15
+ const { width, height } = useLayout();
16
+ const finance = services.finance;
17
+ const topic = `finance:${session.workspace.id}`;
18
+ const query = useQuery(() => finance ? finance.summary(session) : Promise.reject(new Error('Finance is only available in online workspaces.')), topic, [topic]);
19
+ const data = query.data;
20
+ const enoughMembers = (data?.performanceByMember.length ?? 0) >= 2;
21
+ useKeys(Layer.screen, (input, key) => {
22
+ if (!finance || key.ctrl || key.meta)
23
+ return false;
24
+ if (input === 'c') {
25
+ openOverlay({ kind: 'prompt', title: 'Workspace currency (ISO 4217)', initial: data?.currencyCode ?? '', placeholder: 'USD, EUR, CAD…', onSubmit: (value) => {
26
+ const currencyCode = value.trim().toUpperCase();
27
+ try {
28
+ new Intl.NumberFormat('en', { style: 'currency', currency: currencyCode });
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ return run(async () => {
34
+ await finance.configureCurrency(session, currencyCode);
35
+ invalidateQueries([topic]);
36
+ }, `Workspace currency set to ${currencyCode}`);
37
+ } });
38
+ return true;
39
+ }
40
+ if (input === 's') {
41
+ if (!data?.currencyCode)
42
+ return false;
43
+ const digits = new Intl.NumberFormat('en', { style: 'currency', currency: data.currencyCode }).resolvedOptions().maximumFractionDigits ?? 2;
44
+ openOverlay({ kind: 'prompt', title: `Your base salary (${data.currencyCode})`, initial: '', placeholder: `amount and period · e.g. ${digits ? `2500.${'0'.repeat(digits)} monthly` : '2500 monthly'}`, onSubmit: (value) => {
45
+ const match = /^(\d+(?:\.\d{1,3})?)\s+(hourly|weekly|monthly|yearly)$/i.exec(value.trim());
46
+ const periodText = match?.[2]?.toLowerCase();
47
+ if (!match || !periodText || !PERIODS.includes(periodText))
48
+ return false;
49
+ const period = periodText;
50
+ const amountMinor = Math.round(Number(match[1]) * 10 ** digits);
51
+ if (!Number.isSafeInteger(amountMinor))
52
+ return false;
53
+ return run(async () => {
54
+ await finance.setMySalary(session, { amountMinor, period });
55
+ invalidateQueries([topic]);
56
+ }, 'Your private salary setting was saved');
57
+ } });
58
+ return true;
59
+ }
60
+ return false;
61
+ }, active);
62
+ if (!finance)
63
+ return _jsx(ScreenFrame, { title: "Finance", hints: [['esc', 'back']], children: _jsx(EmptyState, { lines: ['Finance is available in online workspaces.'], icon: symbols.dot }) });
64
+ if (query.error && !data)
65
+ return _jsx(ScreenFrame, { title: "Finance", hints: [['esc', 'back']], children: _jsx(EmptyState, { lines: [query.error.message, query.error.hint ?? ''], icon: symbols.cross }) });
66
+ if (!data)
67
+ return _jsx(ScreenFrame, { title: "Finance", hints: [['c', 'currency'], ['s', 'salary'], ['esc', 'back']], children: "Loading finance\u2026" });
68
+ const compact = height < 25 || width < 75;
69
+ const rows = Math.max(1, Math.min(6, height - (compact ? 8 : 12)));
70
+ const financeMembers = data.paidByMember.slice(0, rows);
71
+ const currencyCode = data.currencyCode;
72
+ const performance = data.performanceByMember.slice(0, rows);
73
+ return (_jsx(ScreenFrame, { title: "Finance & performance", aside: session.workspace.name, hints: [['c', 'currency'], ['s', 'my salary'], ['esc', 'back']], children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Panel, { title: "My base salary", aside: "private to you", height: 4, children: [_jsx(Text, { color: palette.text, children: data.mySalary && data.currencyCode ? `${money(data.mySalary.amountMinor, data.currencyCode)} / ${data.mySalary.period}` : 'Not set · press s' }), _jsx(Text, { color: palette.faint, children: "Excluded from ticket earnings and workspace comparisons." })] }), !data.currencyCode ? (_jsxs(Panel, { title: "Workspace currency", height: 5, children: [_jsx(Text, { color: palette.warning, children: "Not configured \u00B7 press c to choose an ISO 4217 currency." }), _jsx(Text, { color: palette.faint, children: "Ticket prices and team finance totals use one currency." })] })) : null, enoughMembers ? (_jsxs(Box, { flexDirection: compact ? 'column' : 'row', gap: 1, children: [_jsxs(Panel, { title: `Ticket earnings · ${data.currencyCode ?? '—'}`, flexGrow: 1, children: [currencyCode ? financeMembers.map((member) => _jsx(ChartRow, { label: `@${member.username}`, value: member.amountMinor, max: Math.max(1, ...financeMembers.map((row) => row.amountMinor)), text: money(member.amountMinor, currencyCode) }, member.id)) : _jsx(Text, { color: palette.faint, children: "Set the workspace currency to enable paid tickets." }), data.currencyCode && financeMembers.length === 0 ? _jsx(Text, { color: palette.faint, children: "No ticket earnings yet." }) : null, _jsx(Text, { color: palette.faint, children: "Only remunerated tickets moved to Done. Salary is excluded." })] }), _jsxs(Panel, { title: "Performance \u00B7 all ticket types", flexGrow: 1, children: [performance.map((member) => _jsx(ChartRow, { label: `@${member.username}`, value: member.ticketCount, max: Math.max(1, ...performance.map((row) => row.ticketCount)), text: `${member.ticketCount} tickets · ${member.openCount} open` }, member.id)), _jsxs(Text, { color: palette.faint, children: ["Average close time \u00B7 ", performance.map((member) => `${member.username}: ${duration(member.averageCloseMs)}`).join(' · ')] }), _jsx(Text, { color: palette.faint, children: "Open age counts every ticket; cancelled tickets are excluded from close averages." })] })] })) : (_jsx(Panel, { title: "Workspace comparisons", height: 4, children: _jsx(Text, { color: palette.faint, children: "Comparisons appear when at least two workspace members are eligible." }) })), _jsx(Text, { color: palette.faint, children: "Values are accrued in SOJA when approved and marked Done; they do not confirm a bank transfer." })] }) }));
74
+ }
75
+ function ChartRow({ label, value, max, text }) {
76
+ const bar = Math.max(value > 0 ? 1 : 0, Math.round((value / max) * 16));
77
+ return _jsxs(Box, { gap: 1, children: [_jsx(Box, { width: 15, children: _jsx(Text, { color: palette.muted, wrap: "truncate-end", children: label }) }), _jsxs(Text, { color: palette.accent, children: ['█'.repeat(bar), '░'.repeat(16 - bar)] }), _jsx(Text, { color: palette.text, children: text })] });
78
+ }
79
+ function money(amountMinor, currencyCode) {
80
+ const digits = new Intl.NumberFormat('en', { style: 'currency', currency: currencyCode }).resolvedOptions().maximumFractionDigits ?? 2;
81
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: currencyCode }).format(amountMinor / 10 ** digits);
82
+ }
83
+ function duration(milliseconds) {
84
+ if (milliseconds === null)
85
+ return 'no closed tickets';
86
+ const hours = milliseconds / 3_600_000;
87
+ return hours < 48 ? `${hours.toFixed(1)}h` : `${(hours / 24).toFixed(1)}d`;
88
+ }
@@ -34,6 +34,14 @@ const SECTIONS = [
34
34
  ['x', 'Done / reopen'],
35
35
  ],
36
36
  ],
37
+ [
38
+ 'Projects (online)',
39
+ [
40
+ ['i', 'Configure/export project ticket API'],
41
+ ['r', 'Link repository'],
42
+ ['e', 'Edit project'],
43
+ ],
44
+ ],
37
45
  [
38
46
  'Git (in a task)',
39
47
  [
@@ -61,6 +69,7 @@ const SECTIONS = [
61
69
  [': or ctrl+k', 'Commands'],
62
70
  ['p', 'Projects'],
63
71
  ['w', 'Workspaces'],
72
+ ['f', 'Finance & performance (online)'],
64
73
  ['A', 'Access approvals (CEO, remote mode)'],
65
74
  ['r', 'Link repo (in Projects)'],
66
75
  ],
@@ -1,5 +1,10 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { basename } from 'node:path';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { generateKeyPairSync } from 'node:crypto';
6
+ import { mkdir, open as openFile, unlink } from 'node:fs/promises';
7
+ import { resolvePaths } from '../../config/paths.js';
3
8
  import { Box, Text } from 'ink';
4
9
  import { useAppState } from '../app-state.js';
5
10
  import { EmptyState } from '../components/EmptyState.js';
@@ -15,7 +20,7 @@ import { Clickable } from '../kit/Clickable.js';
15
20
  import { Gauge } from '../kit/Gauge.js';
16
21
  import { ScreenFrame } from './ScreenFrame.js';
17
22
  export function ProjectsScreen({ active }) {
18
- const { services, session, go } = useAppState();
23
+ const { services, session, go, openOverlay, run, notify } = useAppState();
19
24
  const { width, height } = useLayout();
20
25
  const query = useQuery(() => services.projects.list(session), `projects:${session.workspace.id}`, [`projects:${session.workspace.id}`, `tasks:${session.workspace.id}`]);
21
26
  const projects = query.data ?? [];
@@ -36,6 +41,8 @@ export function ProjectsScreen({ active }) {
36
41
  void flows.pickRepository(project);
37
42
  else if (input === 'e' && project)
38
43
  flows.editProject(project);
44
+ else if (input === 'i' && project && services.intake)
45
+ configureIntake(project);
39
46
  else
40
47
  return false;
41
48
  return true;
@@ -49,6 +56,7 @@ export function ProjectsScreen({ active }) {
49
56
  ['n', 'new project'],
50
57
  ['r', 'link repo'],
51
58
  ['e', 'edit'],
59
+ ...(services.intake ? [['i', 'ticket API']] : []),
52
60
  ['esc', 'back'],
53
61
  ], children: projects.length === 0 && !query.loading ? (_jsx(EmptyState, { lines: EMPTY_PROJECTS, icon: symbols.dot })) : (_jsxs(Clickable, { onWheel: (direction) => list.select(list.index + direction), active: active, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: 2 }), _jsx(Header, { width: 9, text: "KEY" }), _jsx(Box, { width: nameWidth, flexShrink: 0, children: _jsx(Text, { color: palette.faint, bold: true, children: "NAME" }) }), _jsx(Header, { width: 8, text: "ACTIVE", right: true }), wide ? (_jsxs(_Fragment, { children: [_jsx(Header, { width: 10, text: "PROGRESS", right: true }), _jsx(Header, { width: 8, text: "REVIEW", right: true }), _jsx(Header, { width: 9, text: "BLOCKED", right: true }), _jsx(Box, { width: 17, paddingLeft: 3, children: _jsx(Text, { color: palette.faint, bold: true, children: "DONE" }) }), _jsx(Box, { width: 16, paddingLeft: 2, children: _jsx(Text, { color: palette.faint, bold: true, children: "REPO" }) })] })) : null] }), projects.slice(list.offset, list.offset + rows - 1).map((project, position) => {
54
62
  const index = list.offset + position;
@@ -57,6 +65,182 @@ export function ProjectsScreen({ active }) {
57
65
  const total = Object.values(project.counts).reduce((sum, count) => sum + count, 0);
58
66
  return (_jsx(Clickable, { active: active, onClick: () => (selected ? go({ type: 'push', route: { name: 'project', projectId: project.id } }) : list.select(index)), children: _jsxs(Box, { flexGrow: 1, ...bg, children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: palette.accent, children: selected ? symbols.pointer : ' ' }) }), _jsx(Box, { width: 9, children: _jsx(Text, { color: selected ? palette.accent : palette.muted, bold: selected, children: project.key }) }), _jsx(Box, { width: nameWidth, flexShrink: 0, children: _jsx(Text, { color: palette.text, bold: selected, wrap: "truncate-end", children: project.name }) }), _jsx(Count, { width: 8, value: project.active, color: palette.text }), wide ? (_jsxs(_Fragment, { children: [_jsx(Count, { width: 10, value: project.counts.in_progress, color: palette.warning }), _jsx(Count, { width: 8, value: project.counts.review, color: palette.info }), _jsx(Count, { width: 9, value: project.counts.blocked, color: palette.danger }), _jsx(Box, { width: 17, paddingLeft: 3, children: _jsx(Gauge, { value: project.counts.done, total: total, width: 8 }) }), _jsx(Box, { width: 16, paddingLeft: 2, children: _jsx(Text, { color: project.repositoryPath ? palette.muted : palette.faint, wrap: "truncate-end", children: project.repositoryPath ? basename(project.repositoryPath) : 'not linked · r' }) })] })) : null] }) }, project.id));
59
67
  })] })) }));
68
+ function configureIntake(project) {
69
+ if (!services.intake)
70
+ return;
71
+ const intake = services.intake;
72
+ openOverlay({
73
+ kind: 'picker',
74
+ title: `Ticket API · ${project.key}`,
75
+ context: 'workspace owner only · secrets stay on this machine until moved to Supabase',
76
+ options: [
77
+ { value: 'review', label: 'Review pending tickets', hint: 'approve or reject external requests' },
78
+ { value: 'configure', label: 'Configure / rotate key and export', hint: 'generates a new RSA keypair' },
79
+ { value: 'revoke', label: 'Revoke project integration', hint: 'stops new external tickets', tone: 'danger' },
80
+ ],
81
+ onSelect: (value) => {
82
+ if (value === 'review')
83
+ return showReviewQueue();
84
+ if (value === 'configure')
85
+ return beginSetup();
86
+ openOverlay({
87
+ kind: 'confirm',
88
+ title: 'Revoke ticket API?',
89
+ context: project.key,
90
+ message: 'External forms will stop creating tickets until this project is configured again.',
91
+ confirmLabel: 'Revoke API',
92
+ tone: 'danger',
93
+ onConfirm: () => run(async () => intake.revoke(session, project.id), `Ticket API revoked for ${project.key}`),
94
+ });
95
+ },
96
+ });
97
+ function showReviewQueue() {
98
+ void intake.reviewQueue(session, project.id).then((requests) => {
99
+ if (!requests?.length) {
100
+ notify(`No pending tickets for ${project.key}`, 'info');
101
+ return;
102
+ }
103
+ openOverlay({
104
+ kind: 'picker',
105
+ title: `Pending tickets · ${project.key}`,
106
+ context: 'Owner review · approve to publish to the team, reject to cancel',
107
+ options: requests.map((request) => ({ value: request.id, label: `${request.ref} · ${request.title}`, hint: `${request.requester ?? request.email ?? request.sourceRole} · ${new Date(request.createdAt).toLocaleDateString()}` })),
108
+ onSelect: (taskId) => {
109
+ const request = requests.find((item) => item.id === taskId);
110
+ if (!request)
111
+ return;
112
+ openOverlay({
113
+ kind: 'picker',
114
+ title: `${request.ref} · ${request.title}`,
115
+ context: `${request.requester ?? request.email ?? 'External requester'} · role ${request.sourceRole}${request.description ? `\n\n${request.description}` : ''}`,
116
+ options: [
117
+ { value: 'approved', label: 'Approve ticket', hint: 'makes it visible to workspace members' },
118
+ { value: 'rejected', label: 'Reject ticket', hint: 'marks it cancelled', tone: 'danger' },
119
+ ],
120
+ onSelect: (decision) => run(async () => intake.decide(session, project.id, taskId, { decision: decision }), `${request.ref} ${decision}`),
121
+ });
122
+ },
123
+ });
124
+ }).catch((error) => notify(error instanceof Error ? error.message : 'Could not load pending tickets', 'error'));
125
+ }
126
+ function beginSetup() {
127
+ const promptOrigin = (sourceIssuer) => openOverlay({
128
+ kind: 'prompt',
129
+ title: 'Allowed app origins',
130
+ context: 'comma separated exact origins · HTTPS only',
131
+ placeholder: 'https://portal.example.com',
132
+ onSubmit: (originInput) => {
133
+ const allowedOrigins = originInput.split(',').map((value) => value.trim()).filter(Boolean);
134
+ if (!allowedOrigins.length || allowedOrigins.some((value) => {
135
+ try {
136
+ const parsed = new URL(value);
137
+ return parsed.protocol !== 'https:' || parsed.origin !== value.replace(/\/$/, '');
138
+ }
139
+ catch {
140
+ return true;
141
+ }
142
+ }))
143
+ return false;
144
+ return promptRoles(sourceIssuer, allowedOrigins);
145
+ },
146
+ });
147
+ const promptRoles = (sourceIssuer, allowedOrigins) => openOverlay({
148
+ kind: 'prompt',
149
+ title: 'Allowed application roles',
150
+ context: 'comma separated exact role names from your server-side role lookup',
151
+ placeholder: 'support_customer, workspace_admin',
152
+ onSubmit: (rolesInput) => {
153
+ const allowedRoles = [...new Set(rolesInput.split(',').map((value) => value.trim()).filter(Boolean))];
154
+ if (!allowedRoles.length || allowedRoles.some((role) => !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/.test(role)))
155
+ return false;
156
+ return promptExportPath(sourceIssuer, allowedOrigins, allowedRoles);
157
+ },
158
+ });
159
+ const promptExportPath = (sourceIssuer, allowedOrigins, allowedRoles) => openOverlay({
160
+ kind: 'prompt',
161
+ title: 'Save integration credentials',
162
+ context: 'contains a private key and secret; keep it outside source control',
163
+ initial: join(resolvePaths().configDir, 'intake', `.env.${project.key.toLowerCase()}`),
164
+ placeholder: join(resolvePaths().configDir, 'intake', `.env.${project.key.toLowerCase()}`),
165
+ onSubmit: async (outputInput) => {
166
+ const outputPath = resolve(outputInput.startsWith('~/') ? join(homedir(), outputInput.slice(2)) : outputInput);
167
+ const ok = await run(async () => {
168
+ await mkdir(dirname(outputPath), { recursive: true, mode: 0o700 });
169
+ const openApiPath = `${outputPath}.openapi.json`;
170
+ const envHandle = await openFile(outputPath, 'wx', 0o600);
171
+ let apiHandle;
172
+ let credentialsSaved = false;
173
+ try {
174
+ apiHandle = await openFile(openApiPath, 'wx', 0o600);
175
+ }
176
+ catch (error) {
177
+ await envHandle.close();
178
+ await unlink(outputPath).catch(() => undefined);
179
+ throw error;
180
+ }
181
+ try {
182
+ const pair = generateKeyPairSync('rsa', { modulusLength: 3072 });
183
+ const publicJwk = pair.publicKey.export({ format: 'jwk' });
184
+ const privateJwk = JSON.stringify(pair.privateKey.export({ format: 'jwk' }));
185
+ const configured = await intake.configure(session, project.id, { sourceIssuer, publicJwk, allowedOrigins, allowedRoles });
186
+ const credentials = configured.secret ? configured : await intake.rotateSecret(session, project.id);
187
+ const contract = await intake.exportContract(session, project.id);
188
+ const privateEnv = [
189
+ `APP_ORIGINS='${allowedOrigins.join(',')}'`,
190
+ `SUPABASE_AUTH_ISSUER='${sourceIssuer}'`,
191
+ `SOJA_API_URL='${services.environment.mode === 'remote' ? services.environment.server : ''}'`,
192
+ `SOJA_INTEGRATION_ID='${credentials.id}'`,
193
+ `SOJA_PROJECT_ID='${project.id}'`,
194
+ `SOJA_INTEGRATION_SECRET='${credentials.secret}'`,
195
+ `SOJA_ASSERTION_PRIVATE_JWK='${privateJwk}'`,
196
+ '',
197
+ ].join('\n');
198
+ await envHandle.writeFile(privateEnv, 'utf8');
199
+ await envHandle.chmod(0o600);
200
+ credentialsSaved = true;
201
+ await apiHandle.writeFile(`${JSON.stringify(contract, null, 2)}\n`, 'utf8');
202
+ await apiHandle.chmod(0o600);
203
+ }
204
+ catch (error) {
205
+ await Promise.all([envHandle.close().catch(() => undefined), apiHandle.close().catch(() => undefined)]);
206
+ if (!credentialsSaved)
207
+ await unlink(outputPath).catch(() => undefined);
208
+ await unlink(openApiPath).catch(() => undefined);
209
+ if (credentialsSaved)
210
+ throw new Error(`Credentials were saved at ${outputPath}, but OpenAPI export failed. Keep that file safe and retry export.`);
211
+ throw error;
212
+ }
213
+ finally {
214
+ await Promise.all([envHandle.close().catch(() => undefined), apiHandle.close().catch(() => undefined)]);
215
+ }
216
+ return openApiPath;
217
+ });
218
+ if (ok)
219
+ notify(`Ticket API configured for ${project.key}`, 'success', `Credentials: ${outputPath} · OpenAPI: ${outputPath}.openapi.json · move credentials to Supabase Secrets and delete this local file when done.`);
220
+ return ok;
221
+ },
222
+ });
223
+ openOverlay({
224
+ kind: 'prompt',
225
+ title: `Supabase Auth issuer · ${project.name}`,
226
+ context: 'must exactly match the iss claim of Auth access tokens',
227
+ placeholder: 'https://project-ref.supabase.co/auth/v1',
228
+ onSubmit: (issuerInput) => {
229
+ let sourceIssuer;
230
+ try {
231
+ const url = new URL(issuerInput.trim());
232
+ if (url.protocol !== 'https:' || url.search || url.hash || url.username || url.password)
233
+ return false;
234
+ sourceIssuer = url.href.replace(/\/$/, '');
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ return promptOrigin(sourceIssuer);
240
+ },
241
+ });
242
+ }
243
+ }
60
244
  }
61
245
  function Header({ width, text, right = false }) {
62
246
  return (_jsx(Box, { width: width, justifyContent: right ? 'flex-end' : 'flex-start', children: _jsx(Text, { color: palette.faint, bold: true, children: text }) }));
@@ -109,6 +109,7 @@ export function TaskScreen({ active, taskRef }) {
109
109
  t: () => actions.type(task),
110
110
  m: () => actions.project(task),
111
111
  r: () => actions.requester(task),
112
+ '$': () => actions.price(task),
112
113
  d: () => actions.description(task),
113
114
  c: () => actions.comment(task),
114
115
  e: () => actions.edit(task),
@@ -136,6 +137,7 @@ export function TaskScreen({ active, taskRef }) {
136
137
  ['s', 'status'],
137
138
  ['p', 'priority'],
138
139
  ['a', 'assign'],
140
+ ...(services.finance ? [['$', 'ticket price']] : []),
139
141
  ['c', 'comment'],
140
142
  ['e', 'edit'],
141
143
  ['g', 'git'],
@@ -149,7 +151,13 @@ export function TaskScreen({ active, taskRef }) {
149
151
  const visibleTimeline = timeline.slice(Math.max(0, end - timelineRows), end);
150
152
  const description = task.description ? clampLines(wrapText(task.description, (wide ? width - Math.floor(width / 2) : width) - 5), Math.max(1, descriptionHeight - 2)) : [];
151
153
  const detailsWidth = wide ? Math.floor(width / 2) : width;
152
- return (_jsxs(ScreenFrame, { title: `${task.ref} · ${TYPE_LABELS[task.type]}`, aside: width >= 70 ? `created ${formatStamp(task.createdAt)}${task.creator ? ` by @${task.creator.username}` : ''} ${symbols.dot} updated ${formatRelative(task.updatedAt)}` : undefined, hints: hints, children: [titleLines.map((line, index) => (_jsx(Text, { bold: true, color: palette.text, children: line }, index))), _jsxs(Box, { gap: 2, marginTop: 1, children: [_jsx(Clickable, { onClick: () => actions.status(task), active: active, children: _jsx(StatusBadge, { status: task.status }) }), _jsx(Clickable, { onClick: () => actions.priority(task), active: active, children: _jsx(PriorityMeter, { priority: task.priority }) }), task.archivedAt ? _jsx(Badge, { tone: "warning", children: `archived ${formatRelative(task.archivedAt)} · e to restore` }) : null, isClosed(task.status) && task.completedAt ? _jsx(Text, { color: palette.muted, children: `closed ${formatRelative(task.completedAt)}` }) : null] }), (notices.data ?? []).slice(0, 2).map((notice) => (_jsx(Clickable, { onClick: openNotices, active: active, children: _jsx(Text, { color: notice.kind === 'conflict' ? palette.warning : palette.danger, wrap: "truncate-end", children: `! ${notice.message}` }) }, notice.id))), _jsxs(Box, { marginTop: 1, flexDirection: wide ? 'row' : 'column', gap: wide ? 1 : 0, children: [_jsxs(Panel, { title: "Details", width: detailsWidth, height: detailsHeight, children: [_jsx(Field, { label: "Project", value: task.project?.name, onClick: () => actions.project(task), active: active }), _jsx(Field, { label: "Assignee", value: task.assignee ? `@${task.assignee.username}` : undefined, hint: task.assignee?.displayName, onClick: () => actions.assign(task), active: active }), _jsx(Field, { label: "Requested by", value: task.requester ?? undefined, onClick: () => actions.requester(task), active: active }), _jsx(GitFields, { state: git.data, fallbackBranch: task.branch ?? task.suggestedBranch, recorded: task.branch !== null, closed: isClosed(task.status), onBranch: () => actions.branch(task), onCommits: () => openOverlay({ kind: 'git-log' }), active: active }), _jsx(PullRequestField, { state: task.branch ? pullRequest.data : null, loading: pullRequest.loading, onClick: () => actions.gitMenu(task), active: active })] }), descriptionHeight ? (_jsx(Panel, { title: "Description", aside: description.length ? 'd edit' : undefined, height: descriptionHeight, flexGrow: 1, children: _jsx(Clickable, { onClick: () => void actions.description(task), active: active, flexDirection: "column", children: description.length ? (description.map((line, index) => (_jsx(Text, { color: palette.text, children: line }, index)))) : (_jsx(Text, { color: palette.faint, children: "No description. Press d (or click) to write one." })) }) })) : null] }), _jsxs(Box, { flexGrow: 1, gap: 1, children: [_jsx(Panel, { title: "Activity", aside: maxScroll > 0 ? (offset > 0 ? '↓ newer' : '↑ older') : undefined, height: timelineRows + 2, flexGrow: 1, children: _jsxs(Clickable, { onWheel: (direction) => scrollBy(-direction), active: active, flexDirection: "column", children: [visibleTimeline.map((line) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.faint, children: line.stamp.padEnd(7) }), _jsx(Text, { color: line.actor ? palette.muted : palette.faint, children: line.actor.padEnd(actorWidth) }), line.kind === 'comment' ? _jsx(Text, { color: palette.accent, children: line.first ? `${symbols.comment} ` : ' ' }) : _jsx(Text, { color: palette.faint, children: '• ' }), _jsx(Text, { color: line.kind === 'event' ? palette.muted : palette.text, children: line.text })] }, line.key))), visibleTimeline.length === 0 ? _jsx(Text, { color: palette.faint, children: "Nothing yet." }) : null] }) }), showMentions ? (_jsx(Panel, { title: "Mentioned in chat", height: timelineRows + 2, width: Math.floor(width * 0.38), children: mentionList.map((message) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.muted, children: `${message.author ? `@${message.author.username}` : 'someone'} ` }), _jsx(Text, { color: palette.faint, children: formatStamp(message.createdAt) })] }), _jsx(Text, { color: palette.text, wrap: "truncate-end", children: message.body.split('\n')[0] })] }, message.id))) })) : null] }), showMascot && task ? _jsx(Mascot, { scene: syncStatus?.syncing ? 'syncing' : sceneForTask(task), paused: !active }) : null] }));
154
+ return (_jsxs(ScreenFrame, { title: `${task.ref} · ${TYPE_LABELS[task.type]}`, aside: width >= 70 ? `created ${formatStamp(task.createdAt)}${task.creator ? ` by @${task.creator.username}` : ''} ${symbols.dot} updated ${formatRelative(task.updatedAt)}` : undefined, hints: hints, children: [titleLines.map((line, index) => (_jsx(Text, { bold: true, color: palette.text, children: line }, index))), _jsxs(Box, { gap: 2, marginTop: 1, children: [_jsx(Clickable, { onClick: () => actions.status(task), active: active, children: _jsx(StatusBadge, { status: task.status }) }), _jsx(Clickable, { onClick: () => actions.priority(task), active: active, children: _jsx(PriorityMeter, { priority: task.priority }) }), task.archivedAt ? _jsx(Badge, { tone: "warning", children: `archived ${formatRelative(task.archivedAt)} · e to restore` }) : null, isClosed(task.status) && task.completedAt ? _jsx(Text, { color: palette.muted, children: `closed ${formatRelative(task.completedAt)}` }) : null] }), (notices.data ?? []).slice(0, 2).map((notice) => (_jsx(Clickable, { onClick: openNotices, active: active, children: _jsx(Text, { color: notice.kind === 'conflict' ? palette.warning : palette.danger, wrap: "truncate-end", children: `! ${notice.message}` }) }, notice.id))), _jsxs(Box, { marginTop: 1, flexDirection: wide ? 'row' : 'column', gap: wide ? 1 : 0, children: [_jsxs(Panel, { title: "Details", width: detailsWidth, height: detailsHeight, children: [_jsx(Field, { label: "Project", value: task.project?.name, onClick: () => actions.project(task), active: active }), _jsx(Field, { label: "Assignee", value: task.assignee ? `@${task.assignee.username}` : undefined, hint: task.assignee?.displayName, onClick: () => actions.assign(task), active: active }), _jsx(Field, { label: "Requested by", value: task.requester ?? undefined, onClick: () => actions.requester(task), active: active }), services.finance ? _jsx(Field, { label: "Pay", value: task.remunerated ? formatMoney(task.priceMinor, task.currencyCode) : 'unpaid', onClick: () => void actions.price(task), active: active }) : null, _jsx(GitFields, { state: git.data, fallbackBranch: task.branch ?? task.suggestedBranch, recorded: task.branch !== null, closed: isClosed(task.status), onBranch: () => actions.branch(task), onCommits: () => openOverlay({ kind: 'git-log' }), active: active }), _jsx(PullRequestField, { state: task.branch ? pullRequest.data : null, loading: pullRequest.loading, onClick: () => actions.gitMenu(task), active: active })] }), descriptionHeight ? (_jsx(Panel, { title: "Description", aside: description.length ? 'd edit' : undefined, height: descriptionHeight, flexGrow: 1, children: _jsx(Clickable, { onClick: () => void actions.description(task), active: active, flexDirection: "column", children: description.length ? (description.map((line, index) => (_jsx(Text, { color: palette.text, children: line }, index)))) : (_jsx(Text, { color: palette.faint, children: "No description. Press d (or click) to write one." })) }) })) : null] }), _jsxs(Box, { flexGrow: 1, gap: 1, children: [_jsx(Panel, { title: "Activity", aside: maxScroll > 0 ? (offset > 0 ? '↓ newer' : '↑ older') : undefined, height: timelineRows + 2, flexGrow: 1, children: _jsxs(Clickable, { onWheel: (direction) => scrollBy(-direction), active: active, flexDirection: "column", children: [visibleTimeline.map((line) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.faint, children: line.stamp.padEnd(7) }), _jsx(Text, { color: line.actor ? palette.muted : palette.faint, children: line.actor.padEnd(actorWidth) }), line.kind === 'comment' ? _jsx(Text, { color: palette.accent, children: line.first ? `${symbols.comment} ` : ' ' }) : _jsx(Text, { color: palette.faint, children: '• ' }), _jsx(Text, { color: line.kind === 'event' ? palette.muted : palette.text, children: line.text })] }, line.key))), visibleTimeline.length === 0 ? _jsx(Text, { color: palette.faint, children: "Nothing yet." }) : null] }) }), showMentions ? (_jsx(Panel, { title: "Mentioned in chat", height: timelineRows + 2, width: Math.floor(width * 0.38), children: mentionList.map((message) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.muted, children: `${message.author ? `@${message.author.username}` : 'someone'} ` }), _jsx(Text, { color: palette.faint, children: formatStamp(message.createdAt) })] }), _jsx(Text, { color: palette.text, wrap: "truncate-end", children: message.body.split('\n')[0] })] }, message.id))) })) : null] }), showMascot && task ? _jsx(Mascot, { scene: syncStatus?.syncing ? 'syncing' : sceneForTask(task), paused: !active }) : null] }));
155
+ }
156
+ function formatMoney(amountMinor, currencyCode) {
157
+ if (amountMinor === null || !currencyCode)
158
+ return 'price needed';
159
+ const digits = new Intl.NumberFormat('en', { style: 'currency', currency: currencyCode }).resolvedOptions().maximumFractionDigits ?? 2;
160
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: currencyCode }).format(amountMinor / 10 ** digits);
153
161
  }
154
162
  function PullRequestField({ state, loading, onClick, active }) {
155
163
  return (_jsxs(FieldRow, { onClick: onClick, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Pull request" }) }), state?.status === 'found' ? (_jsx(PullRequestSummary, { pr: state.pr })) : state?.status === 'unavailable' ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `${state.reason}${state.hint ? ` ${state.hint}` : ''}` })) : state?.status === 'none' ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `none · g to open one` })) : (loading && state === undefined ? _jsx(Spinner, { label: "checking GitHub\u2026", color: palette.faint }) : _jsx(Text, { color: palette.faint, children: "\u2014" }))] }));