soja-cli 1.5.1 → 1.6.1
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.
- package/dist/application/finance.js +1 -0
- package/dist/application/intake.js +1 -0
- package/dist/application/services/task-service.js +26 -2
- package/dist/application/services/workspace-service.js +5 -2
- package/dist/application/timeline.js +2 -0
- package/dist/cli/commands/workspace.js +12 -1
- package/dist/cli/output.js +2 -1
- package/dist/data/local/tasks.js +5 -1
- package/dist/data/local/workspaces.js +6 -3
- package/dist/data/sync/engine.js +1 -1
- package/dist/data/sync/finance.js +15 -0
- package/dist/data/sync/index.js +4 -0
- package/dist/data/sync/intake.js +27 -0
- package/dist/data/sync/services.js +7 -2
- package/dist/data/sync/store.js +3 -3
- package/dist/database/migrations/0007_finances.sql +5 -0
- package/dist/database/migrations/0008_finances_designates.sql +44 -0
- package/dist/database/migrations/0009_external_approval.sql +1 -0
- package/dist/database/migrations/meta/0008_snapshot.json +1221 -0
- package/dist/database/migrations/meta/0009_snapshot.json +1228 -0
- package/dist/database/migrations/meta/_journal.json +21 -0
- package/dist/database/schema.js +7 -0
- package/dist/ui/Shell.js +7 -0
- package/dist/ui/chrome/Sidebar.js +1 -1
- package/dist/ui/components/TaskTable.js +1 -1
- package/dist/ui/hooks/use-commands.js +1 -0
- package/dist/ui/hooks/use-task-actions.js +36 -0
- package/dist/ui/screens/FinanceScreen.js +88 -0
- package/dist/ui/screens/HelpScreen.js +9 -0
- package/dist/ui/screens/ProjectsScreen.js +186 -1
- package/dist/ui/screens/TaskScreen.js +9 -1
- package/dist/ui/screens/WorkspacesScreen.js +29 -1
- 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
|
}
|
package/dist/database/schema.js
CHANGED
|
@@ -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,183 @@ 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
|
+
// Application role labels may be human-readable (for example, "Super Administrador").
|
|
155
|
+
if (!allowedRoles.length || allowedRoles.some((role) => role.length > 64 || !/^[\p{L}\p{N}][\p{L}\p{N} _.:-]*$/u.test(role) || !/[\p{L}\p{N}]$/u.test(role)))
|
|
156
|
+
return false;
|
|
157
|
+
return promptExportPath(sourceIssuer, allowedOrigins, allowedRoles);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const promptExportPath = (sourceIssuer, allowedOrigins, allowedRoles) => openOverlay({
|
|
161
|
+
kind: 'prompt',
|
|
162
|
+
title: 'Save integration credentials',
|
|
163
|
+
context: 'contains a private key and secret; keep it outside source control',
|
|
164
|
+
initial: join(resolvePaths().configDir, 'intake', `.env.${project.key.toLowerCase()}`),
|
|
165
|
+
placeholder: join(resolvePaths().configDir, 'intake', `.env.${project.key.toLowerCase()}`),
|
|
166
|
+
onSubmit: async (outputInput) => {
|
|
167
|
+
const outputPath = resolve(outputInput.startsWith('~/') ? join(homedir(), outputInput.slice(2)) : outputInput);
|
|
168
|
+
const ok = await run(async () => {
|
|
169
|
+
await mkdir(dirname(outputPath), { recursive: true, mode: 0o700 });
|
|
170
|
+
const openApiPath = `${outputPath}.openapi.json`;
|
|
171
|
+
const envHandle = await openFile(outputPath, 'wx', 0o600);
|
|
172
|
+
let apiHandle;
|
|
173
|
+
let credentialsSaved = false;
|
|
174
|
+
try {
|
|
175
|
+
apiHandle = await openFile(openApiPath, 'wx', 0o600);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
await envHandle.close();
|
|
179
|
+
await unlink(outputPath).catch(() => undefined);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const pair = generateKeyPairSync('rsa', { modulusLength: 3072 });
|
|
184
|
+
const publicJwk = pair.publicKey.export({ format: 'jwk' });
|
|
185
|
+
const privateJwk = JSON.stringify(pair.privateKey.export({ format: 'jwk' }));
|
|
186
|
+
const configured = await intake.configure(session, project.id, { sourceIssuer, publicJwk, allowedOrigins, allowedRoles });
|
|
187
|
+
const credentials = configured.secret ? configured : await intake.rotateSecret(session, project.id);
|
|
188
|
+
const contract = await intake.exportContract(session, project.id);
|
|
189
|
+
const privateEnv = [
|
|
190
|
+
`APP_ORIGINS='${allowedOrigins.join(',')}'`,
|
|
191
|
+
`SUPABASE_AUTH_ISSUER='${sourceIssuer}'`,
|
|
192
|
+
`SOJA_API_URL='${services.environment.mode === 'remote' ? services.environment.server : ''}'`,
|
|
193
|
+
`SOJA_INTEGRATION_ID='${credentials.id}'`,
|
|
194
|
+
`SOJA_PROJECT_ID='${project.id}'`,
|
|
195
|
+
`SOJA_INTEGRATION_SECRET='${credentials.secret}'`,
|
|
196
|
+
`SOJA_ASSERTION_PRIVATE_JWK='${privateJwk}'`,
|
|
197
|
+
'',
|
|
198
|
+
].join('\n');
|
|
199
|
+
await envHandle.writeFile(privateEnv, 'utf8');
|
|
200
|
+
await envHandle.chmod(0o600);
|
|
201
|
+
credentialsSaved = true;
|
|
202
|
+
await apiHandle.writeFile(`${JSON.stringify(contract, null, 2)}\n`, 'utf8');
|
|
203
|
+
await apiHandle.chmod(0o600);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
await Promise.all([envHandle.close().catch(() => undefined), apiHandle.close().catch(() => undefined)]);
|
|
207
|
+
if (!credentialsSaved)
|
|
208
|
+
await unlink(outputPath).catch(() => undefined);
|
|
209
|
+
await unlink(openApiPath).catch(() => undefined);
|
|
210
|
+
if (credentialsSaved)
|
|
211
|
+
throw new Error(`Credentials were saved at ${outputPath}, but OpenAPI export failed. Keep that file safe and retry export.`);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await Promise.all([envHandle.close().catch(() => undefined), apiHandle.close().catch(() => undefined)]);
|
|
216
|
+
}
|
|
217
|
+
return openApiPath;
|
|
218
|
+
});
|
|
219
|
+
if (ok)
|
|
220
|
+
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.`);
|
|
221
|
+
return ok;
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
openOverlay({
|
|
225
|
+
kind: 'prompt',
|
|
226
|
+
title: `Supabase Auth issuer · ${project.name}`,
|
|
227
|
+
context: 'must exactly match the iss claim of Auth access tokens',
|
|
228
|
+
placeholder: 'https://project-ref.supabase.co/auth/v1',
|
|
229
|
+
onSubmit: (issuerInput) => {
|
|
230
|
+
let sourceIssuer;
|
|
231
|
+
try {
|
|
232
|
+
const url = new URL(issuerInput.trim());
|
|
233
|
+
if (url.protocol !== 'https:' || url.search || url.hash || url.username || url.password)
|
|
234
|
+
return false;
|
|
235
|
+
sourceIssuer = url.href.replace(/\/$/, '');
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return promptOrigin(sourceIssuer);
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
60
245
|
}
|
|
61
246
|
function Header({ width, text, right = false }) {
|
|
62
247
|
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" }))] }));
|