@tuturuuu/ui 0.13.0 → 0.16.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 (96) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings/task-settings.tsx +55 -0
  12. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  13. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  14. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  15. package/src/components/ui/custom/statistic-card.tsx +104 -0
  16. package/src/components/ui/custom/structure.test.tsx +29 -0
  17. package/src/components/ui/custom/structure.tsx +103 -88
  18. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  20. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  23. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  24. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  25. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  26. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  27. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  28. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  29. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  30. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  31. package/src/components/ui/finance/invoices/utils.ts +56 -134
  32. package/src/components/ui/resizable.test.ts +15 -0
  33. package/src/components/ui/resizable.tsx +28 -6
  34. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  36. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  37. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  38. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
  40. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  42. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  43. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  44. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  45. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  46. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  47. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  48. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  49. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  50. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  51. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  52. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  53. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  54. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  55. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  56. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  57. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  58. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  59. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  60. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  61. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  62. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  63. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  64. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  65. package/src/components/ui/tu-do/projects/types.ts +4 -1
  66. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  67. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  68. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  69. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  70. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  71. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  72. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  73. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  74. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  75. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  76. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  83. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  84. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  85. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  86. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  87. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  88. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  89. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  90. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  91. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  92. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  93. package/src/hooks/task-actions-personal-external.ts +12 -6
  94. package/src/hooks/use-notifications.ts +14 -2
  95. package/src/hooks/use-user-config.ts +10 -2
  96. package/vitest.config.ts +4 -0
@@ -8,13 +8,12 @@ import { RadioGroup, RadioGroupItem } from '@tuturuuu/ui/radio-group';
8
8
  import { Separator } from '@tuturuuu/ui/separator';
9
9
  import { Switch } from '@tuturuuu/ui/switch';
10
10
  import { useTranslations } from 'next-intl';
11
+ import type { SidebarBehavior } from '../sidebar-context';
11
12
 
12
13
  interface SidebarSettingsProps {
13
14
  useSidebar: () => {
14
- behavior: 'expanded' | 'collapsed' | 'hover';
15
- handleBehaviorChange: (
16
- behavior: 'expanded' | 'collapsed' | 'hover'
17
- ) => void;
15
+ behavior: SidebarBehavior;
16
+ handleBehaviorChange: (behavior: SidebarBehavior) => void;
18
17
  localOverride: boolean;
19
18
  setLocalOverride: (value: boolean) => void;
20
19
  };
@@ -45,9 +44,9 @@ export default function SidebarSettings({ useSidebar }: SidebarSettingsProps) {
45
44
  <RadioGroup
46
45
  value={behavior}
47
46
  onValueChange={(value) =>
48
- handleBehaviorChange(value as 'expanded' | 'collapsed' | 'hover')
47
+ handleBehaviorChange(value as SidebarBehavior)
49
48
  }
50
- className="grid grid-cols-3 gap-4"
49
+ className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
51
50
  >
52
51
  <div>
53
52
  <RadioGroupItem
@@ -84,6 +83,19 @@ export default function SidebarSettings({ useSidebar }: SidebarSettingsProps) {
84
83
  {t('settings.preferences.sidebar_hover')}
85
84
  </Label>
86
85
  </div>
86
+ <div>
87
+ <RadioGroupItem
88
+ value="hidden"
89
+ id="hidden"
90
+ className="peer sr-only"
91
+ />
92
+ <Label
93
+ htmlFor="hidden"
94
+ className="flex cursor-pointer flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
95
+ >
96
+ {t('settings.preferences.sidebar_hidden')}
97
+ </Label>
98
+ </div>
87
99
  </RadioGroup>
88
100
  </SettingItemTab>
89
101
 
@@ -7,6 +7,7 @@ import {
7
7
  TASK_NAVIGATION_LEADERBOARDS_CONFIG_ID,
8
8
  TASK_NAVIGATION_PROGRESS_CONFIG_ID,
9
9
  TASK_NAVIGATION_STATS_CONFIG_ID,
10
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
10
11
  } from '@tuturuuu/internal-api/users';
11
12
  import type { Workspace } from '@tuturuuu/types';
12
13
  import { SettingItemTab } from '@tuturuuu/ui/custom/settings-item-tab';
@@ -34,6 +35,11 @@ import {
34
35
  type TaskDialogPresentation,
35
36
  } from '../../tu-do/shared/task-dialog-presentation';
36
37
  import { TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID } from '../../tu-do/shared/task-due-date-visibility';
38
+ import {
39
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
40
+ normalizeTaskQuickCreateTargetList,
41
+ type TaskQuickCreateTargetList,
42
+ } from '../../tu-do/shared/task-quick-create-target-list';
37
43
  import {
38
44
  clampTaskSoundEffectsVolume,
39
45
  DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
@@ -142,6 +148,14 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
142
148
  const { data: dialogPresentationRaw, isLoading: dialogPresentationLoading } =
143
149
  useUserConfig(TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID, 'compact');
144
150
  const updateDialogPresentation = useUpdateUserConfig();
151
+ const {
152
+ data: quickCreateTargetListRaw,
153
+ isLoading: quickCreateTargetListLoading,
154
+ } = useUserConfig(
155
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
156
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST
157
+ );
158
+ const updateQuickCreateTargetList = useUpdateUserConfig();
145
159
 
146
160
  const { data: settings, isLoading } = useQuery({
147
161
  queryKey: ['user-task-settings'],
@@ -211,6 +225,9 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
211
225
  const dialogPresentation = normalizeTaskDialogPresentation(
212
226
  dialogPresentationRaw
213
227
  );
228
+ const quickCreateTargetList = normalizeTaskQuickCreateTargetList(
229
+ quickCreateTargetListRaw
230
+ );
214
231
 
215
232
  const handleDialogPresentationChange = (value: string) => {
216
233
  const nextValue: TaskDialogPresentation = normalizeTaskDialogPresentation(
@@ -223,6 +240,15 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
223
240
  });
224
241
  };
225
242
 
243
+ const handleQuickCreateTargetListChange = (value: string) => {
244
+ const nextValue: TaskQuickCreateTargetList =
245
+ normalizeTaskQuickCreateTargetList(value);
246
+ updateQuickCreateTargetList.mutate({
247
+ configId: TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
248
+ value: nextValue,
249
+ });
250
+ };
251
+
226
252
  return (
227
253
  <div className="space-y-8">
228
254
  <div className="grid gap-6">
@@ -330,6 +356,35 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
330
356
  </Select>
331
357
  </SettingItemTab>
332
358
  <Separator />
359
+ <SettingItemTab
360
+ title={t('quick_create_target_list')}
361
+ description={t('quick_create_target_list_description')}
362
+ >
363
+ <Select
364
+ value={quickCreateTargetList}
365
+ onValueChange={handleQuickCreateTargetListChange}
366
+ disabled={
367
+ quickCreateTargetListLoading ||
368
+ updateQuickCreateTargetList.isPending
369
+ }
370
+ >
371
+ <SelectTrigger
372
+ aria-label={t('quick_create_target_list')}
373
+ className="w-44"
374
+ >
375
+ <SelectValue />
376
+ </SelectTrigger>
377
+ <SelectContent>
378
+ <SelectItem value="default_list">
379
+ {t('quick_create_target_list_default')}
380
+ </SelectItem>
381
+ <SelectItem value="hovered_list">
382
+ {t('quick_create_target_list_hovered')}
383
+ </SelectItem>
384
+ </SelectContent>
385
+ </Select>
386
+ </SettingItemTab>
387
+ <Separator />
333
388
  <SettingItemTab
334
389
  title={t('draft_mode')}
335
390
  description={t('draft_mode_description')}
@@ -64,6 +64,7 @@ export interface SettingsNavItem {
64
64
  label: string;
65
65
  icon: ComponentType<{ className?: string }>;
66
66
  description?: string;
67
+ hideContentHeader?: boolean;
67
68
  disabled?: boolean;
68
69
  keywords?: string[];
69
70
  aliases?: string[];
@@ -157,6 +158,7 @@ export function SettingsDialogShell({
157
158
  allNavItems.find((item) => item.name === activeTab) ||
158
159
  allNavItems.find((item) => !item.disabled) ||
159
160
  allNavItems[0];
161
+ const showContentHeader = !activeItem?.hideContentHeader;
160
162
 
161
163
  const ensureSearchEngine = useCallback(() => {
162
164
  if (searchEngineFactory || searchEngineLoadRef.current) return;
@@ -511,18 +513,22 @@ export function SettingsDialogShell({
511
513
  </header>
512
514
  <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
513
515
  <div className="mx-auto w-full max-w-4xl space-y-6">
514
- <div className="space-y-1">
515
- <h2 className="font-semibold text-lg tracking-tight">
516
- {activeItem?.label}
517
- </h2>
518
- <p className="text-muted-foreground text-sm">
519
- {activeItem?.description ||
520
- t('settings.manage_settings', {
521
- label: activeItem?.label?.toLowerCase() ?? '',
522
- })}
523
- </p>
524
- </div>
525
- <Separator />
516
+ {showContentHeader && (
517
+ <>
518
+ <div className="space-y-1">
519
+ <h2 className="font-semibold text-lg tracking-tight">
520
+ {activeItem?.label}
521
+ </h2>
522
+ <p className="text-muted-foreground text-sm">
523
+ {activeItem?.description ||
524
+ t('settings.manage_settings', {
525
+ label: activeItem?.label?.toLowerCase() ?? '',
526
+ })}
527
+ </p>
528
+ </div>
529
+ <Separator />
530
+ </>
531
+ )}
526
532
  {children}
527
533
  </div>
528
534
  </div>
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useLocalStorage } from '@tuturuuu/ui/hooks/use-local-storage';
4
+ import { getTuturuuuBrowserSharedCookieOptions } from '@tuturuuu/utils/shared-cookie';
4
5
  import { setCookie } from 'cookies-next';
5
6
  import {
6
7
  type ComponentType,
@@ -19,7 +20,7 @@ export const SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME =
19
20
  'sidebar-behavior-updated-at';
20
21
  export const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
21
22
 
22
- export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
23
+ export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover' | 'hidden';
23
24
 
24
25
  interface SidebarContextProps {
25
26
  behavior: SidebarBehavior;
@@ -40,6 +41,10 @@ export const SIDEBAR_COOKIE_OPTIONS = {
40
41
  path: '/',
41
42
  } as const;
42
43
 
44
+ export function getSidebarCookieOptions() {
45
+ return getTuturuuuBrowserSharedCookieOptions(SIDEBAR_COOKIE_OPTIONS);
46
+ }
47
+
43
48
  function parseSidebarBehaviorUpdatedAt(value: string | undefined | null) {
44
49
  if (!value) return null;
45
50
 
@@ -73,6 +78,19 @@ type SidebarRemoteBehaviorBridgeComponent = ComponentType<{
73
78
  userChangeVersion: number;
74
79
  }>;
75
80
 
81
+ function isEditableShortcutTarget(target: EventTarget | null) {
82
+ if (!(target instanceof HTMLElement)) return false;
83
+
84
+ if (target.isContentEditable) return true;
85
+
86
+ const tagName = target.tagName.toLowerCase();
87
+ if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
88
+ return true;
89
+ }
90
+
91
+ return Boolean(target.closest('[contenteditable="true"], [role="textbox"]'));
92
+ }
93
+
76
94
  function useSidebarRemoteBehaviorBridge() {
77
95
  const [RemoteBehaviorBridge, setRemoteBehaviorBridge] =
78
96
  useState<SidebarRemoteBehaviorBridgeComponent | null>(null);
@@ -136,7 +154,7 @@ export const SidebarProvider = ({
136
154
  setCookie(
137
155
  SIDEBAR_BEHAVIOR_COOKIE_NAME,
138
156
  newBehavior,
139
- SIDEBAR_COOKIE_OPTIONS
157
+ getSidebarCookieOptions()
140
158
  );
141
159
 
142
160
  if (!options.markLocalChange) return;
@@ -146,7 +164,7 @@ export const SidebarProvider = ({
146
164
  setCookie(
147
165
  SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME,
148
166
  String(updatedAt),
149
- SIDEBAR_COOKIE_OPTIONS
167
+ getSidebarCookieOptions()
150
168
  );
151
169
  },
152
170
  []
@@ -175,6 +193,41 @@ export const SidebarProvider = ({
175
193
  [applyBehavior, remoteBehavior, setLocalOverrideRaw]
176
194
  );
177
195
 
196
+ useEffect(() => {
197
+ const handleKeyDown = (event: KeyboardEvent) => {
198
+ if (
199
+ event.defaultPrevented ||
200
+ event.key.toLowerCase() !== 'b' ||
201
+ !(event.metaKey || event.ctrlKey) ||
202
+ event.shiftKey ||
203
+ isEditableShortcutTarget(event.target)
204
+ ) {
205
+ return;
206
+ }
207
+
208
+ if (event.altKey) {
209
+ event.preventDefault();
210
+ handleBehaviorChange('hidden');
211
+ return;
212
+ }
213
+
214
+ event.preventDefault();
215
+ handleBehaviorChange(
216
+ behavior === 'expanded'
217
+ ? 'collapsed'
218
+ : behavior === 'hidden'
219
+ ? 'collapsed'
220
+ : 'expanded'
221
+ );
222
+ };
223
+
224
+ window.addEventListener('keydown', handleKeyDown);
225
+
226
+ return () => {
227
+ window.removeEventListener('keydown', handleKeyDown);
228
+ };
229
+ }, [behavior, handleBehaviorChange]);
230
+
178
231
  return (
179
232
  <SidebarContext.Provider
180
233
  value={{
@@ -208,3 +261,5 @@ export const useSidebar = () => {
208
261
  }
209
262
  return context;
210
263
  };
264
+
265
+ export const useOptionalSidebar = () => useContext(SidebarContext);
@@ -5,14 +5,16 @@ import {
5
5
  useUserConfig,
6
6
  } from '@tuturuuu/ui/hooks/use-user-config';
7
7
  import { useEffect, useRef } from 'react';
8
-
9
- type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
8
+ import type { SidebarBehavior } from './sidebar-context';
10
9
 
11
10
  const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
12
11
  const RECENT_LOCAL_BEHAVIOR_GRACE_MS = 5 * 60 * 1000;
13
12
 
14
13
  const isValidBehavior = (value: string | undefined): value is SidebarBehavior =>
15
- value === 'expanded' || value === 'collapsed' || value === 'hover';
14
+ value === 'expanded' ||
15
+ value === 'collapsed' ||
16
+ value === 'hover' ||
17
+ value === 'hidden';
16
18
 
17
19
  interface SidebarRemoteBehaviorBridgeProps {
18
20
  behavior: SidebarBehavior;
@@ -0,0 +1,104 @@
1
+ import { Progress } from '@tuturuuu/ui/progress';
2
+ import Link from 'next/link';
3
+
4
+ interface Props {
5
+ title?: string;
6
+ value?: string | number | null;
7
+ limit?: number;
8
+ displayValue?: React.ReactNode;
9
+ displayLimit?: React.ReactNode;
10
+ progress?: number;
11
+ href?: string;
12
+ className?: string;
13
+ onClick?: () => void;
14
+ }
15
+
16
+ const StatisticCard = ({
17
+ title,
18
+ value,
19
+ limit,
20
+ displayValue,
21
+ displayLimit,
22
+ progress,
23
+ href,
24
+ className,
25
+ onClick,
26
+ }: Props) => {
27
+ const generateOuterColor = (enableHoverEffect: boolean) =>
28
+ `${
29
+ enableHoverEffect
30
+ ? 'border-border hover:bg-foreground/[0.025] dark:hover:bg-foreground/5'
31
+ : 'border-border/50'
32
+ }`;
33
+
34
+ const progressValue =
35
+ progress !== undefined
36
+ ? progress
37
+ : typeof value === 'number' && limit
38
+ ? (value / limit) * 100
39
+ : 0;
40
+
41
+ const progressColor =
42
+ progressValue >= 100
43
+ ? 'bg-dynamic-red'
44
+ : progressValue >= 90
45
+ ? 'bg-dynamic-yellow'
46
+ : '';
47
+
48
+ const cardContent = (
49
+ <div className="flex h-full flex-col justify-between">
50
+ <div>
51
+ <div className="line-clamp-1 p-1 text-center font-semibold text-lg">
52
+ {title}
53
+ </div>
54
+ <div
55
+ className={`m-2 mt-0 line-clamp-1 rounded border border-border/30 bg-foreground/5 p-4 text-center font-bold text-2xl text-foreground ${
56
+ onClick || href
57
+ ? 'transition-all duration-300 group-hover:rounded-lg'
58
+ : ''
59
+ }`}
60
+ >
61
+ {displayValue ?? (value != null ? value : 'N/A')}
62
+ {(displayLimit || limit) && (
63
+ <span className="font-normal text-base text-muted-foreground">
64
+ {' '}
65
+ / {displayLimit ?? limit}
66
+ </span>
67
+ )}
68
+ </div>
69
+ </div>
70
+ {(limit || displayLimit || progress !== undefined) && (
71
+ <div className="px-4 pb-4">
72
+ <Progress value={progressValue} indicatorClassName={progressColor} />
73
+ </div>
74
+ )}
75
+ </div>
76
+ );
77
+
78
+ if (href)
79
+ return (
80
+ <Link
81
+ href={href}
82
+ onClick={onClick}
83
+ className={`group flex flex-col rounded-lg border transition-all duration-300 ${
84
+ onClick || href ? 'hover:rounded-xl' : 'cursor-default'
85
+ } ${generateOuterColor(!!onClick || !!href)} ${className || ''}`}
86
+ >
87
+ {cardContent}
88
+ </Link>
89
+ );
90
+
91
+ return (
92
+ <button
93
+ type="button"
94
+ onClick={onClick}
95
+ className={`group flex flex-col rounded-lg border transition duration-300 ${
96
+ onClick || href ? 'hover:rounded-xl' : 'cursor-default'
97
+ } ${generateOuterColor(!!onClick || !!href)} ${className || ''}`}
98
+ >
99
+ {cardContent}
100
+ </button>
101
+ );
102
+ };
103
+
104
+ export default StatisticCard;
@@ -0,0 +1,29 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { Structure } from './structure';
5
+
6
+ describe('Structure', () => {
7
+ it('removes sidebar chrome and desktop padding when the sidebar is hidden', () => {
8
+ const { container } = render(
9
+ <Structure
10
+ isCollapsed
11
+ setIsCollapsed={vi.fn()}
12
+ mobileHeader={<span>Mobile header</span>}
13
+ sidebarContent={<span>Sidebar content</span>}
14
+ sidebarHeader={<span>Sidebar header</span>}
15
+ sidebarHidden
16
+ >
17
+ <span>Page content</span>
18
+ </Structure>
19
+ );
20
+
21
+ expect(screen.getByText('Page content')).toBeInTheDocument();
22
+ expect(screen.queryByText('Mobile header')).not.toBeInTheDocument();
23
+ expect(screen.queryByText('Sidebar header')).not.toBeInTheDocument();
24
+ expect(screen.queryByText('Sidebar content')).not.toBeInTheDocument();
25
+ expect(container.querySelector('aside')).not.toBeInTheDocument();
26
+ expect(container.querySelector('nav')).not.toBeInTheDocument();
27
+ expect(container.querySelector('main')).toHaveClass('md:pl-0');
28
+ });
29
+ });