@kernhq/module-tracker 0.10.1 → 0.11.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/package.json +19 -6
- package/src/client/api-instance.ts +34 -0
- package/src/client/components/BoardCard.svelte +182 -0
- package/src/client/components/BoardView.svelte +248 -0
- package/src/client/components/CommentComposer.svelte +219 -0
- package/src/client/components/CommentThread.svelte +389 -0
- package/src/client/components/CustomField.svelte +333 -0
- package/src/client/components/DueDate.svelte +44 -0
- package/src/client/components/FilterMenu.svelte +91 -0
- package/src/client/components/GroupHeader.svelte +137 -0
- package/src/client/components/HomeLinks.svelte +38 -0
- package/src/client/components/IssueApprovals.svelte +217 -0
- package/src/client/components/IssueConnections.svelte +424 -0
- package/src/client/components/IssueDetailPanel.svelte +1337 -0
- package/src/client/components/IssueInline.svelte +75 -0
- package/src/client/components/IssueListView.svelte +153 -0
- package/src/client/components/IssuePicker.svelte +180 -0
- package/src/client/components/IssueRow.svelte +171 -0
- package/src/client/components/IssueTime.svelte +306 -0
- package/src/client/components/KqlInput.svelte +295 -0
- package/src/client/components/NewIssueDialog.svelte +210 -0
- package/src/client/components/NewProjectDialog.svelte +235 -0
- package/src/client/components/PriorityGlyph.svelte +52 -0
- package/src/client/components/SaveViewDialog.svelte +153 -0
- package/src/client/components/SidebarProjects.svelte +263 -0
- package/src/client/components/SidebarViews.svelte +336 -0
- package/src/client/components/StatusIcon.svelte +50 -0
- package/src/client/components/TrackerControls.svelte +109 -0
- package/src/client/components/TrackerSidebar.svelte +96 -0
- package/src/client/context.svelte.ts +105 -0
- package/src/client/core-api.ts +35 -0
- package/src/client/csv.test.ts +50 -0
- package/src/client/csv.ts +60 -0
- package/src/client/filters.ts +94 -0
- package/src/client/i18n.ts +3260 -0
- package/src/client/index.ts +12 -0
- package/src/client/labels.ts +200 -0
- package/src/client/mock.ts +2729 -0
- package/src/client/module.ts +491 -0
- package/src/client/nav.test.ts +59 -0
- package/src/client/nav.ts +84 -0
- package/src/client/pages/IntakePage.svelte +287 -0
- package/src/client/pages/IssuesPage.svelte +701 -0
- package/src/client/pages/ProjectPage.svelte +29 -0
- package/src/client/pages/ReportsPage.svelte +365 -0
- package/src/client/permissions.ts +33 -0
- package/src/client/planning/PlanningSections.svelte +259 -0
- package/src/client/project/ComponentsPage.svelte +423 -0
- package/src/client/project/CyclesPage.svelte +488 -0
- package/src/client/project/MilestonesPage.svelte +342 -0
- package/src/client/project/PlanCard.svelte +191 -0
- package/src/client/project/ProjectShell.svelte +93 -0
- package/src/client/project/TemplatesPage.svelte +321 -0
- package/src/client/project/context.svelte.ts +58 -0
- package/src/client/query.ts +50 -0
- package/src/client/recurrence.test.ts +37 -0
- package/src/client/recurrence.ts +89 -0
- package/src/client/richtext.ts +155 -0
- package/src/client/settings/CycleList.svelte +422 -0
- package/src/client/settings/FieldEditor.svelte +324 -0
- package/src/client/settings/FieldsSettings.svelte +273 -0
- package/src/client/settings/ImportSettings.svelte +410 -0
- package/src/client/settings/LayoutEditor.svelte +263 -0
- package/src/client/settings/PlanningList.svelte +249 -0
- package/src/client/settings/PlanningSettings.svelte +179 -0
- package/src/client/settings/ProjectsSettings.svelte +462 -0
- package/src/client/settings/RepeatingSettings.svelte +293 -0
- package/src/client/settings/TypeEditor.svelte +214 -0
- package/src/client/settings/TypesSettings.svelte +384 -0
- package/src/client/settings/WorkflowsSettings.svelte +645 -0
- package/src/client/settings/mutations.ts +19 -0
- package/src/client/time.test.ts +39 -0
- package/src/client/time.ts +34 -0
- package/src/client/tracker.test.ts +399 -0
- package/src/client/views.ts +27 -0
- package/src/client/widgets/AssignedCountWidget.svelte +14 -0
- package/src/client/widgets/CountWidget.svelte +56 -0
- package/src/client/widgets/CycleWidget.svelte +85 -0
- package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
- package/src/client/widgets/IssuesWidget.svelte +222 -0
- package/src/client/widgets/ThroughputWidget.svelte +68 -0
- package/src/client/widgets/TimerWidget.svelte +116 -0
- package/src/client/widgets/VelocityWidget.svelte +55 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
EmptyState,
|
|
7
|
+
IconButton,
|
|
8
|
+
Input,
|
|
9
|
+
type MenuItem,
|
|
10
|
+
messageLocale,
|
|
11
|
+
Select,
|
|
12
|
+
Textarea,
|
|
13
|
+
toast,
|
|
14
|
+
} from '@kernhq/ui'
|
|
15
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
16
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
17
|
+
import { t } from '../i18n.js'
|
|
18
|
+
import type { Cycle } from '../index.js'
|
|
19
|
+
import { estimateLabel, estimateUnitOf } from '../labels.js'
|
|
20
|
+
import { projectKql, trackerHref } from '../nav.js'
|
|
21
|
+
import { canTracker } from '../permissions.js'
|
|
22
|
+
import { trackerKeys } from '../query.js'
|
|
23
|
+
import { useRouteProject } from './context.svelte.js'
|
|
24
|
+
import PlanCard from './PlanCard.svelte'
|
|
25
|
+
import ProjectShell from './ProjectShell.svelte'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A project's cycles — the sprint, whatever a team calls it.
|
|
29
|
+
*
|
|
30
|
+
* A cycle is a name and a window, and the window is the whole point, so it is drawn as the same card
|
|
31
|
+
* a milestone is: dates, what it is for, and how much of it is done. The two moves that advance it —
|
|
32
|
+
* starting it, and closing it — are the reason the screen exists.
|
|
33
|
+
*
|
|
34
|
+
* Completing asks where unfinished work goes rather than deciding quietly. Leaving a cycle is the
|
|
35
|
+
* one moment a team looks at what did not get done, and moving it somewhere they did not choose is
|
|
36
|
+
* how work disappears.
|
|
37
|
+
*/
|
|
38
|
+
const api = getTrackerApi()
|
|
39
|
+
const queryClient = useQueryClient()
|
|
40
|
+
|
|
41
|
+
const at = useRouteProject()
|
|
42
|
+
const slug = $derived(at.slug)
|
|
43
|
+
const workspaceId = $derived(at.workspaceId)
|
|
44
|
+
const projectId = $derived(at.projectId)
|
|
45
|
+
const canManage = $derived(canTracker('projectManage'))
|
|
46
|
+
const unit = $derived(estimateUnitOf(at.project))
|
|
47
|
+
|
|
48
|
+
const cyclesQuery = createQuery(() => ({
|
|
49
|
+
queryKey: trackerKeys.cycles(workspaceId, projectId),
|
|
50
|
+
queryFn: () => api.cycles.list({ workspaceId, projectId }),
|
|
51
|
+
enabled: Boolean(projectId),
|
|
52
|
+
}))
|
|
53
|
+
const cycles = $derived(cyclesQuery.data ?? [])
|
|
54
|
+
const active = $derived(cycles.find((cycle) => cycle.status === 'active') ?? null)
|
|
55
|
+
|
|
56
|
+
const invalidate = () => {
|
|
57
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'cycle'] })
|
|
58
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
|
|
59
|
+
}
|
|
60
|
+
const fail = (error: Error) => toast.error(error.message)
|
|
61
|
+
|
|
62
|
+
let editing = $state<Cycle | null>(null)
|
|
63
|
+
let creating = $state(false)
|
|
64
|
+
let draftName = $state('')
|
|
65
|
+
let draftStart = $state('')
|
|
66
|
+
let draftEnd = $state('')
|
|
67
|
+
let draftGoal = $state('')
|
|
68
|
+
/** Closing a cycle names where the unfinished work goes before it happens. */
|
|
69
|
+
let closing = $state<Cycle | null>(null)
|
|
70
|
+
let rollTo = $state<string>('')
|
|
71
|
+
let confirming = $state<Cycle | null>(null)
|
|
72
|
+
|
|
73
|
+
/** `<input type="date">` wants `YYYY-MM-DD` in local time; an ISO timestamp's UTC date can be the
|
|
74
|
+
* day before. */
|
|
75
|
+
const dateValue = (iso: string) => {
|
|
76
|
+
const d = new Date(iso)
|
|
77
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const open = (cycle: Cycle | null) => {
|
|
81
|
+
editing = cycle
|
|
82
|
+
creating = cycle === null
|
|
83
|
+
draftName = cycle?.name ?? ''
|
|
84
|
+
draftGoal = cycle?.goal ?? ''
|
|
85
|
+
draftStart = cycle ? dateValue(cycle.startAt) : ''
|
|
86
|
+
draftEnd = cycle ? dateValue(cycle.endAt) : ''
|
|
87
|
+
}
|
|
88
|
+
const close = () => {
|
|
89
|
+
editing = null
|
|
90
|
+
creating = false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const iso = (day: string, endOfDay = false) =>
|
|
94
|
+
new Date(`${day}T${endOfDay ? '23:59:59' : '00:00:00'}`).toISOString()
|
|
95
|
+
|
|
96
|
+
const save = createMutation(() => ({
|
|
97
|
+
mutationFn: () => {
|
|
98
|
+
const body = {
|
|
99
|
+
name: draftName.trim(),
|
|
100
|
+
goal: draftGoal.trim() || null,
|
|
101
|
+
startAt: iso(draftStart),
|
|
102
|
+
endAt: iso(draftEnd, true),
|
|
103
|
+
}
|
|
104
|
+
return editing
|
|
105
|
+
? api.cycles.update({ workspaceId, id: editing.id, patch: body })
|
|
106
|
+
: api.cycles.create({ workspaceId, projectId, ...body })
|
|
107
|
+
},
|
|
108
|
+
onSuccess: () => {
|
|
109
|
+
close()
|
|
110
|
+
invalidate()
|
|
111
|
+
},
|
|
112
|
+
onError: fail,
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
const start = createMutation(() => ({
|
|
116
|
+
mutationFn: (id: string) => api.cycles.start({ workspaceId, id }),
|
|
117
|
+
onSuccess: invalidate,
|
|
118
|
+
onError: fail,
|
|
119
|
+
}))
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `rollToCycleId` is sent even when it is null, and that is the point: the server reads an omitted
|
|
123
|
+
* key as "pick the next cycle for me" and a null as "the backlog". The screen always has an answer,
|
|
124
|
+
* so it always says which.
|
|
125
|
+
*/
|
|
126
|
+
const complete = createMutation(() => ({
|
|
127
|
+
mutationFn: () =>
|
|
128
|
+
api.cycles.complete({
|
|
129
|
+
workspaceId,
|
|
130
|
+
id: closing?.id as string,
|
|
131
|
+
rollToCycleId: rollTo || null,
|
|
132
|
+
}),
|
|
133
|
+
onSuccess: () => {
|
|
134
|
+
closing = null
|
|
135
|
+
invalidate()
|
|
136
|
+
},
|
|
137
|
+
onError: fail,
|
|
138
|
+
}))
|
|
139
|
+
|
|
140
|
+
const remove = createMutation(() => ({
|
|
141
|
+
mutationFn: (id: string) => api.cycles.delete({ workspaceId, id }),
|
|
142
|
+
onSuccess: () => {
|
|
143
|
+
confirming = null
|
|
144
|
+
invalidate()
|
|
145
|
+
},
|
|
146
|
+
onError: fail,
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
/** One range, formatted as one: `formatRange` collapses the parts two dates share, and reads the
|
|
150
|
+
* right way round under `dir="rtl"`. */
|
|
151
|
+
const rangeFormat = $derived(new Intl.DateTimeFormat(messageLocale(), { day: 'numeric', month: 'short' }))
|
|
152
|
+
const when = (cycle: Cycle) => rangeFormat.formatRange(new Date(cycle.startAt), new Date(cycle.endAt))
|
|
153
|
+
|
|
154
|
+
const toneOf = (cycle: Cycle) =>
|
|
155
|
+
cycle.status === 'active' ? 'running' : cycle.status === 'completed' ? 'done' : 'planned'
|
|
156
|
+
const stateOf = (cycle: Cycle) =>
|
|
157
|
+
cycle.status === 'active'
|
|
158
|
+
? t('cycle_active')
|
|
159
|
+
: cycle.status === 'completed'
|
|
160
|
+
? t('cycle_completed')
|
|
161
|
+
: t('cycle_upcoming')
|
|
162
|
+
|
|
163
|
+
const issuesHref = (cycle: Cycle) =>
|
|
164
|
+
trackerHref(slug, { q: `${projectKql(projectId)} and cycle = ${JSON.stringify(cycle.id)}` })
|
|
165
|
+
|
|
166
|
+
/** Where unfinished work can go: any cycle that has not run yet, or the backlog. */
|
|
167
|
+
const rollOptions = $derived([
|
|
168
|
+
{ value: '', label: t('cycle_roll_backlog') },
|
|
169
|
+
...cycles
|
|
170
|
+
.filter((cycle) => cycle.status === 'upcoming' && cycle.id !== closing?.id)
|
|
171
|
+
.map((cycle) => ({ value: cycle.id, label: cycle.name })),
|
|
172
|
+
])
|
|
173
|
+
|
|
174
|
+
const menuFor = (cycle: Cycle): MenuItem[] => [
|
|
175
|
+
{ type: 'item', id: 'issues', label: t('cycle_issues'), icon: 'list', href: issuesHref(cycle) },
|
|
176
|
+
...(canManage
|
|
177
|
+
? [
|
|
178
|
+
{ type: 'separator' as const },
|
|
179
|
+
...(cycle.status === 'upcoming'
|
|
180
|
+
? [
|
|
181
|
+
{
|
|
182
|
+
type: 'item' as const,
|
|
183
|
+
id: 'start',
|
|
184
|
+
label: t('cycle_start'),
|
|
185
|
+
icon: 'play',
|
|
186
|
+
// one cycle runs at a time; the server refuses a second and says so
|
|
187
|
+
disabled: Boolean(active),
|
|
188
|
+
onSelect: () => start.mutate(cycle.id),
|
|
189
|
+
},
|
|
190
|
+
]
|
|
191
|
+
: []),
|
|
192
|
+
...(cycle.status === 'active'
|
|
193
|
+
? [
|
|
194
|
+
{
|
|
195
|
+
type: 'item' as const,
|
|
196
|
+
id: 'complete',
|
|
197
|
+
label: t('cycle_complete'),
|
|
198
|
+
icon: 'check',
|
|
199
|
+
onSelect: () => {
|
|
200
|
+
closing = cycle
|
|
201
|
+
rollTo = ''
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
: []),
|
|
206
|
+
{
|
|
207
|
+
type: 'item' as const,
|
|
208
|
+
id: 'edit',
|
|
209
|
+
label: t('common.edit'),
|
|
210
|
+
icon: 'pencil',
|
|
211
|
+
onSelect: () => open(cycle),
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
type: 'item' as const,
|
|
215
|
+
id: 'delete',
|
|
216
|
+
label: t('common.delete'),
|
|
217
|
+
icon: 'trash-2',
|
|
218
|
+
danger: true,
|
|
219
|
+
onSelect: () => (confirming = cycle),
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
: []),
|
|
223
|
+
]
|
|
224
|
+
</script>
|
|
225
|
+
|
|
226
|
+
<ProjectShell
|
|
227
|
+
project={at.project}
|
|
228
|
+
pending={at.pending}
|
|
229
|
+
{slug}
|
|
230
|
+
projectKey={at.projectKey}
|
|
231
|
+
title={t('planning_cycles')}
|
|
232
|
+
subtitle={active ? t('cycle_running', { name: active.name }) : t('cycle_none_running')}
|
|
233
|
+
>
|
|
234
|
+
{#snippet headerActions()}
|
|
235
|
+
{#if canManage}
|
|
236
|
+
<Button size="sm" icon="plus" onclick={() => open(null)} data-testid="cycle-new">
|
|
237
|
+
{t('cycle_new')}
|
|
238
|
+
</Button>
|
|
239
|
+
{/if}
|
|
240
|
+
{/snippet}
|
|
241
|
+
|
|
242
|
+
{#snippet children()}
|
|
243
|
+
{#if cyclesQuery.isPending}
|
|
244
|
+
<p class="quiet">{t('common.loading')}</p>
|
|
245
|
+
{:else if !cycles.length}
|
|
246
|
+
<EmptyState
|
|
247
|
+
icon="refresh-cw"
|
|
248
|
+
title={t('planning_cycles_empty')}
|
|
249
|
+
description={t('planning_cycles_hint')}
|
|
250
|
+
>
|
|
251
|
+
{#snippet actions()}
|
|
252
|
+
{#if canManage}
|
|
253
|
+
<Button size="sm" icon="plus" onclick={() => open(null)}>{t('cycle_new')}</Button>
|
|
254
|
+
{/if}
|
|
255
|
+
{/snippet}
|
|
256
|
+
</EmptyState>
|
|
257
|
+
{:else}
|
|
258
|
+
<div class="cards">
|
|
259
|
+
{#each cycles as cycle (cycle.id)}
|
|
260
|
+
<PlanCard
|
|
261
|
+
icon="refresh-cw"
|
|
262
|
+
tone={toneOf(cycle)}
|
|
263
|
+
name={cycle.name}
|
|
264
|
+
href={issuesHref(cycle)}
|
|
265
|
+
state={stateOf(cycle)}
|
|
266
|
+
when={when(cycle)}
|
|
267
|
+
goal={cycle.goal}
|
|
268
|
+
progress={{ done: cycle.stats.done, total: cycle.stats.total }}
|
|
269
|
+
>
|
|
270
|
+
{#snippet meta()}
|
|
271
|
+
{#if cycle.stats.estimateTotal > 0}
|
|
272
|
+
<!-- In whatever this project estimates in, not always points. -->
|
|
273
|
+
<span class="chip">
|
|
274
|
+
{t('cycle_estimate', {
|
|
275
|
+
done: estimateLabel(cycle.stats.estimateDone, unit),
|
|
276
|
+
total: estimateLabel(cycle.stats.estimateTotal, unit),
|
|
277
|
+
})}
|
|
278
|
+
</span>
|
|
279
|
+
{/if}
|
|
280
|
+
{#if cycle.carryOverCount > 0}
|
|
281
|
+
<span class="chip">{t('cycle_carried', { count: cycle.carryOverCount })}</span>
|
|
282
|
+
{/if}
|
|
283
|
+
{/snippet}
|
|
284
|
+
{#snippet actions()}
|
|
285
|
+
{#if canManage && cycle.status === 'upcoming'}
|
|
286
|
+
<Button
|
|
287
|
+
size="xs"
|
|
288
|
+
variant="secondary"
|
|
289
|
+
disabled={Boolean(active)}
|
|
290
|
+
onclick={() => start.mutate(cycle.id)}
|
|
291
|
+
data-testid="cycle-start"
|
|
292
|
+
>
|
|
293
|
+
{t('cycle_start')}
|
|
294
|
+
</Button>
|
|
295
|
+
{:else if canManage && cycle.status === 'active'}
|
|
296
|
+
<Button
|
|
297
|
+
size="xs"
|
|
298
|
+
variant="secondary"
|
|
299
|
+
onclick={() => {
|
|
300
|
+
closing = cycle
|
|
301
|
+
rollTo = ''
|
|
302
|
+
}}
|
|
303
|
+
data-testid="cycle-complete"
|
|
304
|
+
>
|
|
305
|
+
{t('cycle_complete')}
|
|
306
|
+
</Button>
|
|
307
|
+
{/if}
|
|
308
|
+
<DropdownMenu items={menuFor(cycle)} align="end">
|
|
309
|
+
{#snippet trigger(props)}
|
|
310
|
+
<IconButton
|
|
311
|
+
{...props}
|
|
312
|
+
icon="ellipsis"
|
|
313
|
+
size={26}
|
|
314
|
+
label={t('cycle_actions', { name: cycle.name })}
|
|
315
|
+
/>
|
|
316
|
+
{/snippet}
|
|
317
|
+
</DropdownMenu>
|
|
318
|
+
{/snippet}
|
|
319
|
+
</PlanCard>
|
|
320
|
+
{/each}
|
|
321
|
+
</div>
|
|
322
|
+
{/if}
|
|
323
|
+
{/snippet}
|
|
324
|
+
</ProjectShell>
|
|
325
|
+
|
|
326
|
+
<Dialog
|
|
327
|
+
open={creating || editing !== null}
|
|
328
|
+
title={editing ? t('cycle_edit') : t('cycle_new')}
|
|
329
|
+
size="sm"
|
|
330
|
+
onOpenChange={(next: boolean) => {
|
|
331
|
+
if (!next) close()
|
|
332
|
+
}}
|
|
333
|
+
>
|
|
334
|
+
<div class="form">
|
|
335
|
+
<label class="frow">
|
|
336
|
+
<span class="lbl">{t('project_name')}</span>
|
|
337
|
+
<Input bind:value={draftName} data-testid="cycle-name" />
|
|
338
|
+
</label>
|
|
339
|
+
<div class="pair">
|
|
340
|
+
<label class="frow">
|
|
341
|
+
<span class="lbl">{t('cycle_starts')}</span>
|
|
342
|
+
<input class="date" type="date" bind:value={draftStart} data-testid="cycle-start-date" />
|
|
343
|
+
</label>
|
|
344
|
+
<label class="frow">
|
|
345
|
+
<span class="lbl">{t('cycle_ends')}</span>
|
|
346
|
+
<input class="date" type="date" bind:value={draftEnd} data-testid="cycle-end-date" />
|
|
347
|
+
</label>
|
|
348
|
+
</div>
|
|
349
|
+
{#if draftStart && draftEnd && draftEnd < draftStart}
|
|
350
|
+
<p class="warn">{t('cycle_range_error')}</p>
|
|
351
|
+
{/if}
|
|
352
|
+
<label class="frow">
|
|
353
|
+
<span class="lbl">{t('milestone_goal')}</span>
|
|
354
|
+
<Textarea bind:value={draftGoal} rows={2} data-testid="cycle-goal" />
|
|
355
|
+
</label>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
{#snippet footer()}
|
|
359
|
+
<Button variant="ghost" size="sm" onclick={close}>{t('common.cancel')}</Button>
|
|
360
|
+
<Button
|
|
361
|
+
size="sm"
|
|
362
|
+
disabled={!draftName.trim() || !draftStart || !draftEnd || draftEnd < draftStart}
|
|
363
|
+
loading={save.isPending}
|
|
364
|
+
onclick={() => save.mutate()}
|
|
365
|
+
data-testid="cycle-save"
|
|
366
|
+
>
|
|
367
|
+
{t('common.save')}
|
|
368
|
+
</Button>
|
|
369
|
+
{/snippet}
|
|
370
|
+
</Dialog>
|
|
371
|
+
|
|
372
|
+
<Dialog
|
|
373
|
+
open={closing !== null}
|
|
374
|
+
title={t('cycle_complete')}
|
|
375
|
+
size="sm"
|
|
376
|
+
onOpenChange={(next: boolean) => {
|
|
377
|
+
if (!next) closing = null
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<p class="body">{t('cycle_complete_body', { name: closing?.name ?? '' })}</p>
|
|
381
|
+
<div class="frow">
|
|
382
|
+
<span class="lbl">{t('cycle_roll_backlog')}</span>
|
|
383
|
+
<Select value={rollTo} options={rollOptions} onValueChange={(value: string) => (rollTo = value)} />
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
{#snippet footer()}
|
|
387
|
+
<Button variant="ghost" size="sm" onclick={() => (closing = null)}>{t('common.cancel')}</Button>
|
|
388
|
+
<Button
|
|
389
|
+
size="sm"
|
|
390
|
+
loading={complete.isPending}
|
|
391
|
+
onclick={() => complete.mutate()}
|
|
392
|
+
data-testid="cycle-complete-confirm"
|
|
393
|
+
>
|
|
394
|
+
{t('cycle_complete')}
|
|
395
|
+
</Button>
|
|
396
|
+
{/snippet}
|
|
397
|
+
</Dialog>
|
|
398
|
+
|
|
399
|
+
<Dialog
|
|
400
|
+
open={confirming !== null}
|
|
401
|
+
title={t('planning_remove', { name: confirming?.name ?? '' })}
|
|
402
|
+
size="sm"
|
|
403
|
+
onOpenChange={(next: boolean) => {
|
|
404
|
+
if (!next) confirming = null
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
<p class="body">{t('planning_remove_body', { name: confirming?.name ?? '' })}</p>
|
|
408
|
+
|
|
409
|
+
{#snippet footer()}
|
|
410
|
+
<Button variant="ghost" size="sm" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
|
|
411
|
+
<Button
|
|
412
|
+
size="sm"
|
|
413
|
+
variant="danger"
|
|
414
|
+
loading={remove.isPending}
|
|
415
|
+
onclick={() => confirming && remove.mutate(confirming.id)}
|
|
416
|
+
data-testid="cycle-delete-confirm"
|
|
417
|
+
>
|
|
418
|
+
{t('common.delete')}
|
|
419
|
+
</Button>
|
|
420
|
+
{/snippet}
|
|
421
|
+
</Dialog>
|
|
422
|
+
|
|
423
|
+
<style>
|
|
424
|
+
.cards {
|
|
425
|
+
display: flex;
|
|
426
|
+
flex-direction: column;
|
|
427
|
+
gap: 10px;
|
|
428
|
+
}
|
|
429
|
+
.chip {
|
|
430
|
+
display: inline-flex;
|
|
431
|
+
align-items: center;
|
|
432
|
+
height: 24px;
|
|
433
|
+
padding: 0 9px;
|
|
434
|
+
border-radius: var(--kern-r-md);
|
|
435
|
+
background: var(--kern-surface-chip);
|
|
436
|
+
color: var(--kern-ink-500);
|
|
437
|
+
font-size: 12px;
|
|
438
|
+
white-space: nowrap;
|
|
439
|
+
}
|
|
440
|
+
.quiet {
|
|
441
|
+
margin: 0;
|
|
442
|
+
font-size: 13px;
|
|
443
|
+
color: var(--kern-ink-400);
|
|
444
|
+
}
|
|
445
|
+
.form {
|
|
446
|
+
display: grid;
|
|
447
|
+
gap: 12px;
|
|
448
|
+
}
|
|
449
|
+
.pair {
|
|
450
|
+
display: grid;
|
|
451
|
+
grid-template-columns: 1fr 1fr;
|
|
452
|
+
gap: 10px;
|
|
453
|
+
}
|
|
454
|
+
.frow {
|
|
455
|
+
display: grid;
|
|
456
|
+
gap: 4px;
|
|
457
|
+
}
|
|
458
|
+
.lbl {
|
|
459
|
+
font-size: 12px;
|
|
460
|
+
color: var(--kern-ink-550);
|
|
461
|
+
}
|
|
462
|
+
.date {
|
|
463
|
+
/* the whole field opens a picker; a date input does not take free text, so it is not a caret */
|
|
464
|
+
cursor: pointer;
|
|
465
|
+
height: 36px;
|
|
466
|
+
padding: 0 12px;
|
|
467
|
+
border: 1px solid var(--kern-border-strong);
|
|
468
|
+
border-radius: var(--kern-r-lg);
|
|
469
|
+
background: var(--kern-surface-raised);
|
|
470
|
+
color: var(--kern-ink-800);
|
|
471
|
+
font: inherit;
|
|
472
|
+
font-size: 13.5px;
|
|
473
|
+
}
|
|
474
|
+
.date:focus-visible {
|
|
475
|
+
outline: none;
|
|
476
|
+
border-color: var(--kern-accent);
|
|
477
|
+
box-shadow: 0 0 0 3px var(--kern-ring);
|
|
478
|
+
}
|
|
479
|
+
.warn {
|
|
480
|
+
margin: 0;
|
|
481
|
+
font-size: 12.5px;
|
|
482
|
+
color: var(--kern-danger);
|
|
483
|
+
}
|
|
484
|
+
.body {
|
|
485
|
+
margin: 0 0 12px;
|
|
486
|
+
font-size: 13px;
|
|
487
|
+
}
|
|
488
|
+
</style>
|