@tuturuuu/ui 0.5.0 → 0.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.
- package/CHANGELOG.md +29 -0
- package/package.json +41 -34
- package/src/components/ui/currency-input.tsx +65 -23
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
- package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
- package/src/components/ui/custom/combobox.test.tsx +141 -0
- package/src/components/ui/custom/combobox.tsx +105 -36
- package/src/components/ui/custom/settings/task-settings.tsx +50 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
- package/src/components/ui/custom/sidebar-context.tsx +68 -6
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
- package/src/components/ui/finance/finance-layout.tsx +2 -4
- package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
- package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
- package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
- package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
- package/src/components/ui/finance/transactions/form-types.ts +23 -0
- package/src/components/ui/finance/transactions/form.tsx +81 -22
- package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
- package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
- package/src/components/ui/finance/wallets/columns.test.ts +56 -0
- package/src/components/ui/finance/wallets/columns.tsx +196 -43
- package/src/components/ui/finance/wallets/form.test.tsx +79 -14
- package/src/components/ui/finance/wallets/form.tsx +41 -197
- package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
- package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
- package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
- package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
- package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
- package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
- package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
- package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
- package/src/components/ui/storefront/accent-button.tsx +33 -0
- package/src/components/ui/storefront/cart-summary.tsx +140 -0
- package/src/components/ui/storefront/empty-listings.tsx +32 -0
- package/src/components/ui/storefront/hero-panel.tsx +70 -0
- package/src/components/ui/storefront/image-panel.tsx +40 -0
- package/src/components/ui/storefront/index.ts +12 -0
- package/src/components/ui/storefront/listing-card.tsx +129 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
- package/src/components/ui/storefront/storefront-surface.tsx +235 -0
- package/src/components/ui/storefront/types.ts +99 -0
- package/src/components/ui/storefront/utils.ts +90 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
- package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
- package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
- package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
- package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
- package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
- package/src/hooks/useBoardRealtime.ts +54 -1
- package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
- package/src/hooks/useTaskUserRealtime.ts +338 -0
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
CommandSeparator,
|
|
15
15
|
} from '../command';
|
|
16
16
|
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
|
17
|
-
import { Separator } from '../separator';
|
|
18
17
|
|
|
19
18
|
export type ComboboxOption = {
|
|
20
19
|
value: string;
|
|
@@ -36,6 +35,8 @@ export type ComboboxAction = {
|
|
|
36
35
|
disabled?: boolean;
|
|
37
36
|
};
|
|
38
37
|
|
|
38
|
+
export type ComboboxCreateResult = string | ComboboxOption | undefined;
|
|
39
|
+
|
|
39
40
|
/** @deprecated Use ComboboxOption instead */
|
|
40
41
|
export type ComboboxOptions = ComboboxOption;
|
|
41
42
|
|
|
@@ -62,6 +63,8 @@ interface ComboboxProps {
|
|
|
62
63
|
emptyText?: string;
|
|
63
64
|
/** Text to display when creating a new item (used with onCreate) */
|
|
64
65
|
createText?: string;
|
|
66
|
+
/** Text to display while a new item is being created */
|
|
67
|
+
creatingText?: string;
|
|
65
68
|
/** Override label shown on the trigger button */
|
|
66
69
|
label?: React.ReactNode;
|
|
67
70
|
/** Additional class name for the container */
|
|
@@ -71,7 +74,7 @@ interface ComboboxProps {
|
|
|
71
74
|
/** Whether to select the first option by default */
|
|
72
75
|
useFirstValueAsDefault?: boolean;
|
|
73
76
|
/** Callback to create a new option from the search query */
|
|
74
|
-
onCreate?: (value: string) =>
|
|
77
|
+
onCreate?: (value: string) => unknown | Promise<unknown>;
|
|
75
78
|
/** Callback when search input changes */
|
|
76
79
|
onSearchChange?: (value: string) => void;
|
|
77
80
|
/** Whether there are more options to load */
|
|
@@ -102,6 +105,7 @@ export function Combobox({
|
|
|
102
105
|
searchPlaceholder,
|
|
103
106
|
emptyText,
|
|
104
107
|
createText,
|
|
108
|
+
creatingText,
|
|
105
109
|
label,
|
|
106
110
|
className,
|
|
107
111
|
disabled,
|
|
@@ -117,14 +121,31 @@ export function Combobox({
|
|
|
117
121
|
}: ComboboxProps) {
|
|
118
122
|
const [open, setOpen] = React.useState(false);
|
|
119
123
|
const [query, setQuery] = React.useState<string>('');
|
|
124
|
+
const [creating, setCreating] = React.useState(false);
|
|
125
|
+
const createInFlightRef = React.useRef(false);
|
|
120
126
|
const actionValuePrefix = '__combobox_action__';
|
|
127
|
+
const createValuePrefix = '__combobox_create__';
|
|
121
128
|
|
|
122
129
|
// Resolve text with fallbacks: explicit prop > t function > default
|
|
123
130
|
const resolvedEmptyText =
|
|
124
131
|
emptyText ?? t?.('common.empty') ?? 'No results found.';
|
|
125
132
|
const resolvedCreateText = createText ?? t?.('common.add') ?? 'Create';
|
|
133
|
+
const resolvedCreatingText =
|
|
134
|
+
creatingText ?? t?.('common.creating') ?? 'Creating...';
|
|
126
135
|
const resolvedSearchPlaceholder =
|
|
127
136
|
searchPlaceholder ?? placeholder ?? 'Search...';
|
|
137
|
+
const normalizedQuery = normalizeComboboxText(query);
|
|
138
|
+
const trimmedQuery = query.trim();
|
|
139
|
+
const hasExactQueryMatch = React.useMemo(() => {
|
|
140
|
+
if (!normalizedQuery) return false;
|
|
141
|
+
|
|
142
|
+
return options.some((option) =>
|
|
143
|
+
[option.label, option.value, option.searchValue]
|
|
144
|
+
.filter((value): value is string => typeof value === 'string')
|
|
145
|
+
.some((value) => normalizeComboboxText(value) === normalizedQuery)
|
|
146
|
+
);
|
|
147
|
+
}, [normalizedQuery, options]);
|
|
148
|
+
const canCreate = Boolean(onCreate && normalizedQuery && !hasExactQueryMatch);
|
|
128
149
|
|
|
129
150
|
React.useEffect(() => {
|
|
130
151
|
if (!open) {
|
|
@@ -206,8 +227,55 @@ export function Combobox({
|
|
|
206
227
|
);
|
|
207
228
|
};
|
|
208
229
|
|
|
230
|
+
const commitCreatedValue = React.useCallback(
|
|
231
|
+
(result: unknown) => {
|
|
232
|
+
if (!onChange) return;
|
|
233
|
+
|
|
234
|
+
const createdValue =
|
|
235
|
+
typeof result === 'string'
|
|
236
|
+
? result
|
|
237
|
+
: result &&
|
|
238
|
+
typeof result === 'object' &&
|
|
239
|
+
'value' in result &&
|
|
240
|
+
typeof result.value === 'string'
|
|
241
|
+
? result.value
|
|
242
|
+
: undefined;
|
|
243
|
+
|
|
244
|
+
if (!createdValue) return;
|
|
245
|
+
|
|
246
|
+
if (mode === 'multiple' && Array.isArray(selected)) {
|
|
247
|
+
if (!selected.includes(createdValue)) {
|
|
248
|
+
onChange([...selected, createdValue]);
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
onChange(createdValue);
|
|
254
|
+
},
|
|
255
|
+
[mode, onChange, selected]
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const handleCreate = React.useCallback(async () => {
|
|
259
|
+
if (!onCreate || !trimmedQuery || createInFlightRef.current) return;
|
|
260
|
+
|
|
261
|
+
createInFlightRef.current = true;
|
|
262
|
+
setCreating(true);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const result = await onCreate(trimmedQuery);
|
|
266
|
+
commitCreatedValue(result);
|
|
267
|
+
setOpen(false);
|
|
268
|
+
setQuery('');
|
|
269
|
+
} catch {
|
|
270
|
+
// Keep the popover open so callers can surface their own error UI.
|
|
271
|
+
} finally {
|
|
272
|
+
createInFlightRef.current = false;
|
|
273
|
+
setCreating(false);
|
|
274
|
+
}
|
|
275
|
+
}, [commitCreatedValue, onCreate, trimmedQuery]);
|
|
276
|
+
|
|
209
277
|
return (
|
|
210
|
-
<div className={cn('block', className)}>
|
|
278
|
+
<div className={cn('block min-w-0', className)}>
|
|
211
279
|
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
|
212
280
|
<PopoverTrigger asChild>
|
|
213
281
|
<Button
|
|
@@ -216,7 +284,7 @@ export function Combobox({
|
|
|
216
284
|
role="combobox"
|
|
217
285
|
aria-expanded={open}
|
|
218
286
|
className={cn(
|
|
219
|
-
'w-full justify-between',
|
|
287
|
+
'w-full min-w-0 justify-between overflow-hidden',
|
|
220
288
|
!selectedLabel && 'text-muted-foreground'
|
|
221
289
|
)}
|
|
222
290
|
disabled={disabled}
|
|
@@ -254,7 +322,7 @@ export function Combobox({
|
|
|
254
322
|
: selectedOption.icon}
|
|
255
323
|
</span>
|
|
256
324
|
)}
|
|
257
|
-
<span className="min-w-0 flex-1">
|
|
325
|
+
<span className="min-w-0 flex-1 truncate text-left">
|
|
258
326
|
{label ? (
|
|
259
327
|
label
|
|
260
328
|
) : (
|
|
@@ -281,13 +349,14 @@ export function Combobox({
|
|
|
281
349
|
</Button>
|
|
282
350
|
</PopoverTrigger>
|
|
283
351
|
<PopoverContent
|
|
284
|
-
className="z-9999 w-(--radix-popover-trigger-width) p-0"
|
|
352
|
+
className="z-9999 w-(--radix-popover-trigger-width) max-w-[calc(100vw-2rem)] p-0"
|
|
285
353
|
align="start"
|
|
286
354
|
sideOffset={4}
|
|
287
355
|
>
|
|
288
356
|
<Command
|
|
289
357
|
filter={(value, search) => {
|
|
290
358
|
if (value.startsWith(actionValuePrefix)) return 1;
|
|
359
|
+
if (value.startsWith(createValuePrefix)) return 1;
|
|
291
360
|
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
|
292
361
|
return 0;
|
|
293
362
|
}}
|
|
@@ -300,38 +369,11 @@ export function Combobox({
|
|
|
300
369
|
onSearchChange?.(value);
|
|
301
370
|
}}
|
|
302
371
|
/>
|
|
303
|
-
<CommandEmpty className="
|
|
304
|
-
|
|
305
|
-
{resolvedEmptyText}
|
|
306
|
-
</div>
|
|
307
|
-
{onCreate && (
|
|
308
|
-
<>
|
|
309
|
-
<Separator />
|
|
310
|
-
<Button
|
|
311
|
-
variant="ghost"
|
|
312
|
-
className="mt-1 w-full"
|
|
313
|
-
onClick={() => {
|
|
314
|
-
if (onCreate) {
|
|
315
|
-
onCreate(query);
|
|
316
|
-
setOpen(false);
|
|
317
|
-
setQuery('');
|
|
318
|
-
}
|
|
319
|
-
}}
|
|
320
|
-
disabled={!query || !onCreate}
|
|
321
|
-
>
|
|
322
|
-
<Plus className="mr-2 h-4 w-4 shrink-0" />
|
|
323
|
-
<div className="w-full truncate">
|
|
324
|
-
<span className="font-normal">{resolvedCreateText}</span>{' '}
|
|
325
|
-
<span className="underline decoration-dashed underline-offset-2">
|
|
326
|
-
{query}
|
|
327
|
-
</span>
|
|
328
|
-
</div>
|
|
329
|
-
</Button>
|
|
330
|
-
</>
|
|
331
|
-
)}
|
|
372
|
+
<CommandEmpty className="p-8 text-center text-muted-foreground text-sm">
|
|
373
|
+
{resolvedEmptyText}
|
|
332
374
|
</CommandEmpty>
|
|
333
375
|
<CommandList
|
|
334
|
-
className="max-h-
|
|
376
|
+
className="max-h-[min(20rem,calc(100dvh-8rem))] overflow-y-auto overscroll-contain"
|
|
335
377
|
onScroll={handleListScroll}
|
|
336
378
|
style={
|
|
337
379
|
{
|
|
@@ -346,6 +388,29 @@ export function Combobox({
|
|
|
346
388
|
<CommandSeparator />
|
|
347
389
|
</>
|
|
348
390
|
) : null}
|
|
391
|
+
{canCreate ? (
|
|
392
|
+
<>
|
|
393
|
+
<CommandGroup>
|
|
394
|
+
<CommandItem
|
|
395
|
+
value={`${createValuePrefix}:${trimmedQuery}`}
|
|
396
|
+
disabled={creating}
|
|
397
|
+
onSelect={handleCreate}
|
|
398
|
+
className="font-medium text-primary [&_svg]:text-primary"
|
|
399
|
+
>
|
|
400
|
+
<Plus className="h-4 w-4 shrink-0" />
|
|
401
|
+
<span className="min-w-0 flex-1 truncate">
|
|
402
|
+
<span className="font-normal">
|
|
403
|
+
{creating ? resolvedCreatingText : resolvedCreateText}
|
|
404
|
+
</span>{' '}
|
|
405
|
+
<span className="underline decoration-dashed underline-offset-2">
|
|
406
|
+
{trimmedQuery}
|
|
407
|
+
</span>
|
|
408
|
+
</span>
|
|
409
|
+
</CommandItem>
|
|
410
|
+
</CommandGroup>
|
|
411
|
+
<CommandSeparator />
|
|
412
|
+
</>
|
|
413
|
+
) : null}
|
|
349
414
|
<CommandGroup>
|
|
350
415
|
{options.map((option) => (
|
|
351
416
|
<CommandItem
|
|
@@ -463,3 +528,7 @@ export function Combobox({
|
|
|
463
528
|
</div>
|
|
464
529
|
);
|
|
465
530
|
}
|
|
531
|
+
|
|
532
|
+
function normalizeComboboxText(value: string) {
|
|
533
|
+
return value.trim().toLocaleLowerCase();
|
|
534
|
+
}
|
|
@@ -20,6 +20,11 @@ import { toast } from '@tuturuuu/ui/sonner';
|
|
|
20
20
|
import { Switch } from '@tuturuuu/ui/switch';
|
|
21
21
|
import { useTranslations } from 'next-intl';
|
|
22
22
|
import { useEffect } from 'react';
|
|
23
|
+
import {
|
|
24
|
+
normalizeTaskDialogPresentation,
|
|
25
|
+
TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID,
|
|
26
|
+
type TaskDialogPresentation,
|
|
27
|
+
} from '../../tu-do/shared/task-dialog-presentation';
|
|
23
28
|
import { TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID } from '../../tu-do/shared/task-due-date-visibility';
|
|
24
29
|
import {
|
|
25
30
|
clampTaskSoundEffectsVolume,
|
|
@@ -96,6 +101,9 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
96
101
|
const { data: submitShortcut, isLoading: submitShortcutLoading } =
|
|
97
102
|
useUserConfig('TASK_SUBMIT_SHORTCUT', 'enter');
|
|
98
103
|
const updateSubmitShortcut = useUpdateUserConfig();
|
|
104
|
+
const { data: dialogPresentationRaw, isLoading: dialogPresentationLoading } =
|
|
105
|
+
useUserConfig(TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID, 'compact');
|
|
106
|
+
const updateDialogPresentation = useUpdateUserConfig();
|
|
99
107
|
|
|
100
108
|
const { data: settings, isLoading } = useQuery({
|
|
101
109
|
queryKey: ['user-task-settings'],
|
|
@@ -159,6 +167,20 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
159
167
|
const normalizedSoundEffectsVolume = String(
|
|
160
168
|
clampTaskSoundEffectsVolume(soundEffectsVolume)
|
|
161
169
|
);
|
|
170
|
+
const dialogPresentation = normalizeTaskDialogPresentation(
|
|
171
|
+
dialogPresentationRaw
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const handleDialogPresentationChange = (value: string) => {
|
|
175
|
+
const nextValue: TaskDialogPresentation = normalizeTaskDialogPresentation(
|
|
176
|
+
value,
|
|
177
|
+
'compact'
|
|
178
|
+
);
|
|
179
|
+
updateDialogPresentation.mutate({
|
|
180
|
+
configId: TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID,
|
|
181
|
+
value: nextValue,
|
|
182
|
+
});
|
|
183
|
+
};
|
|
162
184
|
|
|
163
185
|
return (
|
|
164
186
|
<div className="space-y-8">
|
|
@@ -239,6 +261,34 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
239
261
|
</Select>
|
|
240
262
|
</SettingItemTab>
|
|
241
263
|
<Separator />
|
|
264
|
+
<SettingItemTab
|
|
265
|
+
title={t('dialog_presentation')}
|
|
266
|
+
description={t('dialog_presentation_description')}
|
|
267
|
+
>
|
|
268
|
+
<Select
|
|
269
|
+
value={dialogPresentation}
|
|
270
|
+
onValueChange={handleDialogPresentationChange}
|
|
271
|
+
disabled={
|
|
272
|
+
dialogPresentationLoading || updateDialogPresentation.isPending
|
|
273
|
+
}
|
|
274
|
+
>
|
|
275
|
+
<SelectTrigger
|
|
276
|
+
aria-label={t('dialog_presentation')}
|
|
277
|
+
className="w-36"
|
|
278
|
+
>
|
|
279
|
+
<SelectValue />
|
|
280
|
+
</SelectTrigger>
|
|
281
|
+
<SelectContent>
|
|
282
|
+
<SelectItem value="compact">
|
|
283
|
+
{t('dialog_presentation_compact')}
|
|
284
|
+
</SelectItem>
|
|
285
|
+
<SelectItem value="fullscreen">
|
|
286
|
+
{t('dialog_presentation_immersive')}
|
|
287
|
+
</SelectItem>
|
|
288
|
+
</SelectContent>
|
|
289
|
+
</Select>
|
|
290
|
+
</SettingItemTab>
|
|
291
|
+
<Separator />
|
|
242
292
|
<SettingItemTab
|
|
243
293
|
title={t('draft_mode')}
|
|
244
294
|
description={t('draft_mode_description')}
|
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
mockSetSoundEffectsEnabled: vi.fn(),
|
|
19
19
|
mockUpdateUserConfigMutate: vi.fn(),
|
|
20
20
|
mockConfigState: {
|
|
21
|
+
dialogPresentation: 'compact',
|
|
21
22
|
soundEffectsEnabled: true,
|
|
22
23
|
soundEffectsVolume: '35',
|
|
23
24
|
},
|
|
@@ -51,7 +52,9 @@ vi.mock('@tuturuuu/ui/hooks/use-user-config', () => ({
|
|
|
51
52
|
data:
|
|
52
53
|
configId === 'TASK_SOUND_EFFECTS_VOLUME'
|
|
53
54
|
? mockConfigState.soundEffectsVolume
|
|
54
|
-
:
|
|
55
|
+
: configId === 'TASK_DIALOG_DEFAULT_PRESENTATION'
|
|
56
|
+
? mockConfigState.dialogPresentation
|
|
57
|
+
: defaultValue,
|
|
55
58
|
isLoading: false,
|
|
56
59
|
}),
|
|
57
60
|
useUpdateUserConfig: () => ({
|
|
@@ -76,6 +79,7 @@ function renderWithQueryClient(children: ReactNode) {
|
|
|
76
79
|
describe('task sound settings controls', () => {
|
|
77
80
|
beforeEach(() => {
|
|
78
81
|
vi.clearAllMocks();
|
|
82
|
+
mockConfigState.dialogPresentation = 'compact';
|
|
79
83
|
mockConfigState.soundEffectsEnabled = true;
|
|
80
84
|
mockConfigState.soundEffectsVolume = '35';
|
|
81
85
|
vi.stubGlobal(
|
|
@@ -104,6 +108,22 @@ describe('task sound settings controls', () => {
|
|
|
104
108
|
expect(mockSetSoundEffectsEnabled).toHaveBeenCalledWith(false);
|
|
105
109
|
});
|
|
106
110
|
|
|
111
|
+
it('renders task dialog presentation setting and persists immersive mode', async () => {
|
|
112
|
+
renderWithQueryClient(<TaskSettings />);
|
|
113
|
+
|
|
114
|
+
expect(await screen.findByText('dialog_presentation')).toBeInTheDocument();
|
|
115
|
+
|
|
116
|
+
fireEvent.click(
|
|
117
|
+
screen.getByRole('combobox', { name: 'dialog_presentation' })
|
|
118
|
+
);
|
|
119
|
+
fireEvent.click(screen.getByText('dialog_presentation_immersive'));
|
|
120
|
+
|
|
121
|
+
expect(mockUpdateUserConfigMutate).toHaveBeenCalledWith({
|
|
122
|
+
configId: 'TASK_DIALOG_DEFAULT_PRESENTATION',
|
|
123
|
+
value: 'fullscreen',
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
107
127
|
it('renders the quick settings sound switch and persists changes', async () => {
|
|
108
128
|
renderWithQueryClient(<QuickSettingsPopover />);
|
|
109
129
|
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
} from 'react';
|
|
16
16
|
|
|
17
17
|
export const SIDEBAR_BEHAVIOR_COOKIE_NAME = 'sidebar-behavior';
|
|
18
|
+
export const SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME =
|
|
19
|
+
'sidebar-behavior-updated-at';
|
|
18
20
|
export const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
|
|
19
21
|
|
|
20
22
|
export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
|
|
@@ -33,7 +35,33 @@ export const SidebarContext = createContext<SidebarContextProps | undefined>(
|
|
|
33
35
|
);
|
|
34
36
|
|
|
35
37
|
// Persistent cookie options — ensures setting survives browser restarts
|
|
36
|
-
const
|
|
38
|
+
export const SIDEBAR_COOKIE_OPTIONS = {
|
|
39
|
+
maxAge: 365 * 24 * 60 * 60,
|
|
40
|
+
path: '/',
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
function parseSidebarBehaviorUpdatedAt(value: string | undefined | null) {
|
|
44
|
+
if (!value) return null;
|
|
45
|
+
|
|
46
|
+
const updatedAt = Number(value);
|
|
47
|
+
return Number.isSafeInteger(updatedAt) && updatedAt > 0 ? updatedAt : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getSidebarBehaviorUpdatedAtFromDocument() {
|
|
51
|
+
if (typeof document === 'undefined') return null;
|
|
52
|
+
|
|
53
|
+
const cookie = document.cookie
|
|
54
|
+
.split('; ')
|
|
55
|
+
.find((part) =>
|
|
56
|
+
part.startsWith(`${SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME}=`)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!cookie) return null;
|
|
60
|
+
|
|
61
|
+
return parseSidebarBehaviorUpdatedAt(
|
|
62
|
+
decodeURIComponent(cookie.split('=').slice(1).join('='))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
37
65
|
|
|
38
66
|
type SidebarRemoteBehaviorBridgeComponent = ComponentType<{
|
|
39
67
|
behavior: SidebarBehavior;
|
|
@@ -41,6 +69,7 @@ type SidebarRemoteBehaviorBridgeComponent = ComponentType<{
|
|
|
41
69
|
localOverrideVersion: number;
|
|
42
70
|
onApplyRemoteBehavior: (newBehavior: SidebarBehavior) => void;
|
|
43
71
|
onRemoteBehaviorAvailable: (remoteBehavior: SidebarBehavior) => void;
|
|
72
|
+
behaviorUpdatedAt: number | null;
|
|
44
73
|
userChangeVersion: number;
|
|
45
74
|
}>;
|
|
46
75
|
|
|
@@ -70,11 +99,16 @@ function useSidebarRemoteBehaviorBridge() {
|
|
|
70
99
|
export const SidebarProvider = ({
|
|
71
100
|
children,
|
|
72
101
|
initialBehavior,
|
|
102
|
+
initialBehaviorUpdatedAt,
|
|
73
103
|
}: {
|
|
74
104
|
children: ReactNode;
|
|
75
105
|
initialBehavior: SidebarBehavior;
|
|
106
|
+
initialBehaviorUpdatedAt?: number | null;
|
|
76
107
|
}) => {
|
|
77
108
|
const [behavior, setBehavior] = useState<SidebarBehavior>(initialBehavior);
|
|
109
|
+
const [behaviorUpdatedAt, setBehaviorUpdatedAt] = useState<number | null>(
|
|
110
|
+
() => initialBehaviorUpdatedAt ?? getSidebarBehaviorUpdatedAtFromDocument()
|
|
111
|
+
);
|
|
78
112
|
const [localOverride, setLocalOverrideRaw] = useLocalStorage(
|
|
79
113
|
'sidebar-local-override',
|
|
80
114
|
false
|
|
@@ -86,14 +120,41 @@ export const SidebarProvider = ({
|
|
|
86
120
|
const [localOverrideVersion, setLocalOverrideVersion] = useState(0);
|
|
87
121
|
const RemoteBehaviorBridge = useSidebarRemoteBehaviorBridge();
|
|
88
122
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (initialBehaviorUpdatedAt !== undefined) return;
|
|
125
|
+
|
|
126
|
+
const updatedAt = getSidebarBehaviorUpdatedAtFromDocument();
|
|
127
|
+
if (updatedAt !== null) setBehaviorUpdatedAt(updatedAt);
|
|
128
|
+
}, [initialBehaviorUpdatedAt]);
|
|
129
|
+
|
|
130
|
+
const applyBehavior = useCallback(
|
|
131
|
+
(
|
|
132
|
+
newBehavior: SidebarBehavior,
|
|
133
|
+
options: { markLocalChange?: boolean } = {}
|
|
134
|
+
) => {
|
|
135
|
+
setBehavior(newBehavior);
|
|
136
|
+
setCookie(
|
|
137
|
+
SIDEBAR_BEHAVIOR_COOKIE_NAME,
|
|
138
|
+
newBehavior,
|
|
139
|
+
SIDEBAR_COOKIE_OPTIONS
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (!options.markLocalChange) return;
|
|
143
|
+
|
|
144
|
+
const updatedAt = Date.now();
|
|
145
|
+
setBehaviorUpdatedAt(updatedAt);
|
|
146
|
+
setCookie(
|
|
147
|
+
SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME,
|
|
148
|
+
String(updatedAt),
|
|
149
|
+
SIDEBAR_COOKIE_OPTIONS
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
[]
|
|
153
|
+
);
|
|
93
154
|
|
|
94
155
|
const handleBehaviorChange = useCallback(
|
|
95
156
|
(newBehavior: SidebarBehavior) => {
|
|
96
|
-
applyBehavior(newBehavior);
|
|
157
|
+
applyBehavior(newBehavior, { markLocalChange: true });
|
|
97
158
|
|
|
98
159
|
if (!localOverride) {
|
|
99
160
|
setUserChangeVersion((version) => version + 1);
|
|
@@ -127,6 +188,7 @@ export const SidebarProvider = ({
|
|
|
127
188
|
{RemoteBehaviorBridge && (
|
|
128
189
|
<RemoteBehaviorBridge
|
|
129
190
|
behavior={behavior}
|
|
191
|
+
behaviorUpdatedAt={behaviorUpdatedAt}
|
|
130
192
|
localOverride={localOverride}
|
|
131
193
|
localOverrideVersion={localOverrideVersion}
|
|
132
194
|
onApplyRemoteBehavior={applyBehavior}
|
|
@@ -9,12 +9,14 @@ import { useEffect, useRef } from 'react';
|
|
|
9
9
|
type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
|
|
10
10
|
|
|
11
11
|
const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
|
|
12
|
+
const RECENT_LOCAL_BEHAVIOR_GRACE_MS = 5 * 60 * 1000;
|
|
12
13
|
|
|
13
14
|
const isValidBehavior = (value: string | undefined): value is SidebarBehavior =>
|
|
14
15
|
value === 'expanded' || value === 'collapsed' || value === 'hover';
|
|
15
16
|
|
|
16
17
|
interface SidebarRemoteBehaviorBridgeProps {
|
|
17
18
|
behavior: SidebarBehavior;
|
|
19
|
+
behaviorUpdatedAt: number | null;
|
|
18
20
|
localOverride: boolean;
|
|
19
21
|
localOverrideVersion: number;
|
|
20
22
|
onApplyRemoteBehavior: (newBehavior: SidebarBehavior) => void;
|
|
@@ -24,6 +26,7 @@ interface SidebarRemoteBehaviorBridgeProps {
|
|
|
24
26
|
|
|
25
27
|
export function SidebarRemoteBehaviorBridge({
|
|
26
28
|
behavior,
|
|
29
|
+
behaviorUpdatedAt,
|
|
27
30
|
localOverride,
|
|
28
31
|
localOverrideVersion,
|
|
29
32
|
onApplyRemoteBehavior,
|
|
@@ -39,6 +42,12 @@ export function SidebarRemoteBehaviorBridge({
|
|
|
39
42
|
const persistedUserChangeVersion = useRef(0);
|
|
40
43
|
const handledLocalOverrideVersion = useRef(0);
|
|
41
44
|
|
|
45
|
+
const hasRecentLocalBehavior =
|
|
46
|
+
typeof behaviorUpdatedAt === 'number' &&
|
|
47
|
+
Number.isFinite(behaviorUpdatedAt) &&
|
|
48
|
+
Date.now() - behaviorUpdatedAt >= 0 &&
|
|
49
|
+
Date.now() - behaviorUpdatedAt <= RECENT_LOCAL_BEHAVIOR_GRACE_MS;
|
|
50
|
+
|
|
42
51
|
useEffect(() => {
|
|
43
52
|
if (!remoteLoaded || !isValidBehavior(remoteBehavior)) return;
|
|
44
53
|
|
|
@@ -58,15 +67,25 @@ export function SidebarRemoteBehaviorBridge({
|
|
|
58
67
|
|
|
59
68
|
hasAppliedRemote.current = true;
|
|
60
69
|
|
|
61
|
-
if (remoteBehavior
|
|
62
|
-
|
|
70
|
+
if (remoteBehavior === behavior) return;
|
|
71
|
+
|
|
72
|
+
if (hasRecentLocalBehavior) {
|
|
73
|
+
updateConfig.mutate({
|
|
74
|
+
configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
|
|
75
|
+
value: behavior,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
63
78
|
}
|
|
79
|
+
|
|
80
|
+
onApplyRemoteBehavior(remoteBehavior);
|
|
64
81
|
}, [
|
|
65
82
|
behavior,
|
|
83
|
+
hasRecentLocalBehavior,
|
|
66
84
|
localOverride,
|
|
67
85
|
onApplyRemoteBehavior,
|
|
68
86
|
remoteBehavior,
|
|
69
87
|
remoteLoaded,
|
|
88
|
+
updateConfig,
|
|
70
89
|
userChangeVersion,
|
|
71
90
|
]);
|
|
72
91
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Navigation, type NavLink } from '@tuturuuu/ui/custom/navigation';
|
|
2
|
-
import {
|
|
2
|
+
import { FinanceLayoutControls } from '@tuturuuu/ui/finance/shared/finance-layout-controls';
|
|
3
3
|
import { QuickActions } from '@tuturuuu/ui/finance/shared/quick-actions';
|
|
4
4
|
import { getPermissions } from '@tuturuuu/utils/workspace-helper';
|
|
5
5
|
import { notFound, redirect } from 'next/navigation';
|
|
@@ -88,9 +88,7 @@ export default async function FinanceLayout({
|
|
|
88
88
|
return (
|
|
89
89
|
<>
|
|
90
90
|
<Navigation navLinks={navLinks} />
|
|
91
|
-
<
|
|
92
|
-
<FinanceNumbersVisibilityToggle />
|
|
93
|
-
</div>
|
|
91
|
+
<FinanceLayoutControls financePrefix={financePrefix} />
|
|
94
92
|
{children}
|
|
95
93
|
<QuickActions wsId={wsId} />
|
|
96
94
|
</>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from 'next-intl';
|
|
4
|
+
import { ToggleGroup, ToggleGroupItem } from '../../toggle-group';
|
|
5
|
+
import {
|
|
6
|
+
type FinanceBalanceMode,
|
|
7
|
+
useFinanceBalanceMode,
|
|
8
|
+
} from './use-finance-balance-mode';
|
|
9
|
+
|
|
10
|
+
export function FinanceBalanceModeToggle() {
|
|
11
|
+
const t = useTranslations('wallet-checkpoints');
|
|
12
|
+
const { mode, setMode } = useFinanceBalanceMode();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<ToggleGroup
|
|
16
|
+
type="single"
|
|
17
|
+
variant="outline"
|
|
18
|
+
size="sm"
|
|
19
|
+
value={mode}
|
|
20
|
+
onValueChange={(value) => {
|
|
21
|
+
if (value === 'ledger' || value === 'audited') {
|
|
22
|
+
setMode(value as FinanceBalanceMode);
|
|
23
|
+
}
|
|
24
|
+
}}
|
|
25
|
+
aria-label={t('balance_mode')}
|
|
26
|
+
>
|
|
27
|
+
<ToggleGroupItem value="ledger" aria-label={t('ledger_mode')}>
|
|
28
|
+
{t('ledger')}
|
|
29
|
+
</ToggleGroupItem>
|
|
30
|
+
<ToggleGroupItem value="audited" aria-label={t('audited_mode')}>
|
|
31
|
+
{t('audited')}
|
|
32
|
+
</ToggleGroupItem>
|
|
33
|
+
</ToggleGroup>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { FinanceBalanceModeToggle } from './balance-mode-toggle';
|
|
6
|
+
import { FinanceNumbersVisibilityToggle } from './numbers-visibility-toggle';
|
|
7
|
+
|
|
8
|
+
interface FinanceLayoutControlsProps {
|
|
9
|
+
className?: string;
|
|
10
|
+
financePrefix?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizePathname(pathname: string | null) {
|
|
14
|
+
if (!pathname) return '';
|
|
15
|
+
return pathname.replace(/\/+$/u, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isWalletIndexPath(pathname: string, financePrefix: string) {
|
|
19
|
+
const normalizedPrefix = financePrefix.replace(/\/+$/u, '');
|
|
20
|
+
if (normalizedPrefix) {
|
|
21
|
+
return pathname.endsWith(`${normalizedPrefix}/wallets`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return pathname.endsWith('/wallets');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function FinanceLayoutControls({
|
|
28
|
+
className,
|
|
29
|
+
financePrefix = '/finance',
|
|
30
|
+
}: FinanceLayoutControlsProps) {
|
|
31
|
+
const pathname = normalizePathname(usePathname());
|
|
32
|
+
|
|
33
|
+
if (isWalletIndexPath(pathname, financePrefix)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn('mb-4 flex flex-wrap justify-end gap-2', className)}>
|
|
39
|
+
<FinanceBalanceModeToggle />
|
|
40
|
+
<FinanceNumbersVisibilityToggle />
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -91,12 +91,20 @@ export function QuickActions({
|
|
|
91
91
|
</DropdownMenuItem>
|
|
92
92
|
)}
|
|
93
93
|
{canCreateWallets && (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
<>
|
|
95
|
+
<DropdownMenuItem
|
|
96
|
+
onClick={() => pushFinanceHref('/wallets?create=wallet')}
|
|
97
|
+
>
|
|
98
|
+
<Wallet className="mr-2 h-4 w-4" />
|
|
99
|
+
<span>{t('new_wallet')}</span>
|
|
100
|
+
</DropdownMenuItem>
|
|
101
|
+
<DropdownMenuItem
|
|
102
|
+
onClick={() => pushFinanceHref('/wallets?create=credit-card')}
|
|
103
|
+
>
|
|
104
|
+
<CreditCard className="mr-2 h-4 w-4" />
|
|
105
|
+
<span>{t('new_credit_card')}</span>
|
|
106
|
+
</DropdownMenuItem>
|
|
107
|
+
</>
|
|
100
108
|
)}
|
|
101
109
|
{canManageFinance && (
|
|
102
110
|
<DropdownMenuItem
|