@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +50 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
  28. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  29. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  30. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  31. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  32. package/src/components/ui/finance/wallets/form.tsx +41 -197
  33. package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
  34. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  35. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  36. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  40. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  41. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
  42. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
  43. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  44. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  45. package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
  46. package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
  47. package/src/components/ui/storefront/accent-button.tsx +33 -0
  48. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  49. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  50. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  51. package/src/components/ui/storefront/image-panel.tsx +40 -0
  52. package/src/components/ui/storefront/index.ts +12 -0
  53. package/src/components/ui/storefront/listing-card.tsx +129 -0
  54. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  55. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  56. package/src/components/ui/storefront/types.ts +99 -0
  57. package/src/components/ui/storefront/utils.ts +90 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  59. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  60. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  61. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  62. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  63. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  64. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  65. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
  66. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  67. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  68. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  69. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  70. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  71. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  72. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  73. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  85. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
  86. package/src/hooks/useBoardRealtime.ts +54 -1
  87. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  88. 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) => void;
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="flex flex-col items-center justify-center p-1">
304
- <div className="p-8 text-muted-foreground text-sm">
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-50 overflow-y-auto overscroll-contain"
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
- : defaultValue,
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 COOKIE_OPTIONS = { maxAge: 365 * 24 * 60 * 60, path: '/' } as 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
- const applyBehavior = useCallback((newBehavior: SidebarBehavior) => {
90
- setBehavior(newBehavior);
91
- setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, newBehavior, COOKIE_OPTIONS);
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 !== behavior) {
62
- onApplyRemoteBehavior(remoteBehavior);
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 { FinanceNumbersVisibilityToggle } from '@tuturuuu/ui/finance/shared/numbers-visibility-toggle';
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
- <div className="mb-4 flex justify-end">
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
- <DropdownMenuItem
95
- onClick={() => pushFinanceHref('/wallets?create=wallet')}
96
- >
97
- <Wallet className="mr-2 h-4 w-4" />
98
- <span>{t('new_wallet')}</span>
99
- </DropdownMenuItem>
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