@mostrom/app-shell 0.1.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 (142) hide show
  1. package/.claude/ralph-loop.local.md +9 -0
  2. package/README.md +172 -0
  3. package/bin/init.js +269 -0
  4. package/bun.lock +401 -0
  5. package/components.json +28 -0
  6. package/package.json +74 -0
  7. package/scripts/publish-npm.sh +202 -0
  8. package/src/AppShell.tsx +847 -0
  9. package/src/components/PageHeader.tsx +160 -0
  10. package/src/components/data-table/README.md +447 -0
  11. package/src/components/data-table/data-table-preferences.tsx +184 -0
  12. package/src/components/data-table/data-table-toolbar.tsx +118 -0
  13. package/src/components/data-table/data-table.tsx +37 -0
  14. package/src/components/data-table/index.ts +32 -0
  15. package/src/components/global-header/AllServicesButton.tsx +127 -0
  16. package/src/components/global-header/CategoriesButton.tsx +120 -0
  17. package/src/components/global-header/GlobalHeader.tsx +59 -0
  18. package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
  19. package/src/components/global-header/HeaderUtilities.tsx +243 -0
  20. package/src/components/global-header/ServicesMenu.tsx +246 -0
  21. package/src/components/layout/AppBreadcrumb.tsx +70 -0
  22. package/src/components/layout/AppFlashbar.tsx +95 -0
  23. package/src/components/layout/AppLayout.tsx +271 -0
  24. package/src/components/layout/AppNavigation.tsx +313 -0
  25. package/src/components/layout/AppSidebar.tsx +229 -0
  26. package/src/components/patterns/index.ts +14 -0
  27. package/src/components/patterns/p-alert-5.tsx +19 -0
  28. package/src/components/patterns/p-autocomplete-5.tsx +89 -0
  29. package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
  30. package/src/components/patterns/p-button-42.tsx +37 -0
  31. package/src/components/patterns/p-button-51.tsx +14 -0
  32. package/src/components/patterns/p-button-6.tsx +5 -0
  33. package/src/components/patterns/p-calendar-1.tsx +18 -0
  34. package/src/components/patterns/p-card-1.tsx +33 -0
  35. package/src/components/patterns/p-card-2.tsx +26 -0
  36. package/src/components/patterns/p-card-5.tsx +31 -0
  37. package/src/components/patterns/p-collapsible-7.tsx +121 -0
  38. package/src/components/patterns/p-command-6.tsx +113 -0
  39. package/src/components/patterns/p-dialog-1.tsx +56 -0
  40. package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
  41. package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
  42. package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
  43. package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
  44. package/src/components/patterns/p-empty-2.tsx +34 -0
  45. package/src/components/patterns/p-file-upload-1.tsx +72 -0
  46. package/src/components/patterns/p-filters-1.tsx +666 -0
  47. package/src/components/patterns/p-frame-2.tsx +26 -0
  48. package/src/components/patterns/p-tabs-2.tsx +129 -0
  49. package/src/components/reui/alert.tsx +92 -0
  50. package/src/components/reui/autocomplete.tsx +343 -0
  51. package/src/components/reui/badge.tsx +87 -0
  52. package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
  53. package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
  54. package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
  55. package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
  56. package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
  57. package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
  58. package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
  59. package/src/components/reui/data-grid/data-grid.tsx +209 -0
  60. package/src/components/reui/date-selector.tsx +1330 -0
  61. package/src/components/reui/filters.tsx +1869 -0
  62. package/src/components/reui/frame.tsx +134 -0
  63. package/src/components/reui/index.ts +17 -0
  64. package/src/components/reui/timeline.tsx +219 -0
  65. package/src/components/search/Autocomplete.tsx +183 -0
  66. package/src/components/search/AutocompleteClient.tsx +293 -0
  67. package/src/components/search/GlobalSearch.tsx +187 -0
  68. package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
  69. package/src/components/section-drawer/index.ts +19 -0
  70. package/src/components/section-drawer/section-drawer.css +665 -0
  71. package/src/components/section-drawer/section-drawer.tsx +467 -0
  72. package/src/components/sectioned-list-board/README.md +78 -0
  73. package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
  74. package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
  75. package/src/components/sectioned-list-board/index.ts +19 -0
  76. package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
  77. package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
  78. package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
  79. package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
  80. package/src/components/sectioned-list-board/types.ts +216 -0
  81. package/src/components/sectioned-list-table/README.md +80 -0
  82. package/src/components/sectioned-list-table/index.ts +14 -0
  83. package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
  84. package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
  85. package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
  86. package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
  87. package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
  88. package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
  89. package/src/components/sectioned-list-table/types.ts +120 -0
  90. package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
  91. package/src/components/ui/actions-dropdown.tsx +109 -0
  92. package/src/components/ui/assignee-selector.tsx +209 -0
  93. package/src/components/ui/avatar.tsx +107 -0
  94. package/src/components/ui/breadcrumb.tsx +109 -0
  95. package/src/components/ui/button-group.tsx +83 -0
  96. package/src/components/ui/button.tsx +64 -0
  97. package/src/components/ui/calendar.tsx +220 -0
  98. package/src/components/ui/card.tsx +92 -0
  99. package/src/components/ui/chart.tsx +376 -0
  100. package/src/components/ui/checkbox.tsx +30 -0
  101. package/src/components/ui/collapsible.tsx +33 -0
  102. package/src/components/ui/command.tsx +182 -0
  103. package/src/components/ui/context-menu.tsx +250 -0
  104. package/src/components/ui/create-button-group.tsx +128 -0
  105. package/src/components/ui/dialog.tsx +156 -0
  106. package/src/components/ui/drawer.tsx +133 -0
  107. package/src/components/ui/dropdown-menu.tsx +255 -0
  108. package/src/components/ui/empty.tsx +104 -0
  109. package/src/components/ui/field.tsx +248 -0
  110. package/src/components/ui/form.tsx +165 -0
  111. package/src/components/ui/index.ts +37 -0
  112. package/src/components/ui/input-group.tsx +168 -0
  113. package/src/components/ui/input.tsx +21 -0
  114. package/src/components/ui/kbd.tsx +28 -0
  115. package/src/components/ui/label.tsx +22 -0
  116. package/src/components/ui/navigation-menu.tsx +168 -0
  117. package/src/components/ui/page-header.tsx +80 -0
  118. package/src/components/ui/popover.tsx +87 -0
  119. package/src/components/ui/scroll-area.tsx +56 -0
  120. package/src/components/ui/select.tsx +190 -0
  121. package/src/components/ui/separator.tsx +26 -0
  122. package/src/components/ui/sheet.tsx +141 -0
  123. package/src/components/ui/sidebar.tsx +726 -0
  124. package/src/components/ui/skeleton.tsx +13 -0
  125. package/src/components/ui/sonner.tsx +38 -0
  126. package/src/components/ui/switch.tsx +33 -0
  127. package/src/components/ui/tabs.tsx +91 -0
  128. package/src/components/ui/textarea.tsx +18 -0
  129. package/src/components/ui/toggle-group.tsx +83 -0
  130. package/src/components/ui/toggle.tsx +45 -0
  131. package/src/components/ui/tooltip.tsx +57 -0
  132. package/src/hooks/use-copy-to-clipboard.ts +37 -0
  133. package/src/hooks/use-file-upload.ts +415 -0
  134. package/src/hooks/use-mobile.ts +19 -0
  135. package/src/index.ts +95 -0
  136. package/src/lib/utils.ts +6 -0
  137. package/src/styles.css +1859 -0
  138. package/src/urls.ts +83 -0
  139. package/src/vite.d.ts +22 -0
  140. package/src/vite.js +241 -0
  141. package/tsconfig.base.json +18 -0
  142. package/tsconfig.json +24 -0
@@ -0,0 +1,891 @@
1
+ import * as React from "react";
2
+ import {
3
+ Check,
4
+ ChevronDown,
5
+ ChevronRight,
6
+ Plus,
7
+ CalendarDays,
8
+ User,
9
+ X,
10
+ FileText,
11
+ } from "lucide-react";
12
+ import { Button } from "@/components/ui/button";
13
+ import { Separator } from "@/components/ui/separator";
14
+ import { Textarea } from "@/components/ui/textarea";
15
+ import { Calendar } from "@/components/ui/calendar";
16
+ import {
17
+ Avatar,
18
+ AvatarImage,
19
+ AvatarFallback,
20
+ } from "@/components/ui/avatar";
21
+ import { Badge } from "@/components/reui/badge";
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from "@/components/ui/select";
29
+ import {
30
+ Popover,
31
+ PopoverContent,
32
+ PopoverTrigger,
33
+ } from "@/components/ui/popover";
34
+ import { AssigneeSelector, type Assignee } from "@/components/ui/assignee-selector";
35
+
36
+ // ============================================================================
37
+ // Types
38
+ // ============================================================================
39
+
40
+ export interface DealClient {
41
+ id: string;
42
+ name: string;
43
+ avatar?: string;
44
+ }
45
+
46
+ export interface DealOwner {
47
+ id: string;
48
+ name: string;
49
+ avatar?: string;
50
+ }
51
+
52
+ export interface DealProject {
53
+ id: string;
54
+ name: string;
55
+ color: string;
56
+ section?: string;
57
+ }
58
+
59
+ export interface DealSubtask {
60
+ id: string;
61
+ title: string;
62
+ completed: boolean;
63
+ }
64
+
65
+ export interface DealAttachment {
66
+ id: string;
67
+ name: string;
68
+ type: string;
69
+ size?: number;
70
+ }
71
+
72
+ export interface DealDrawerContentProps {
73
+ // ============ Default Fields ============
74
+ /** Assignee */
75
+ assignee?: Assignee | null;
76
+ /** Available assignees for selection */
77
+ availableAssignees?: Assignee[];
78
+ /** Due date */
79
+ dueDate?: string | Date | null;
80
+ /** Priority */
81
+ priority?: "High" | "Medium" | "Low" | null;
82
+ /** Description */
83
+ description?: string;
84
+ /** Subtasks */
85
+ subtasks?: DealSubtask[];
86
+ /** Attachments */
87
+ attachments?: DealAttachment[];
88
+
89
+ // ============ Deals Section (Client + Stage) ============
90
+ /** Client this deal is with */
91
+ client?: DealClient | null;
92
+ /** Available clients for selection */
93
+ availableClients?: DealClient[];
94
+ /** Current deal stage */
95
+ dealStage?: string | null;
96
+ /** Available stages for the deal pipeline */
97
+ availableStages?: string[];
98
+
99
+ // ============ Deals-Specific Fields ============
100
+ /** Deal value */
101
+ value?: number | null;
102
+ /** Currency code (default: USD) */
103
+ currency?: string;
104
+ /** Expected close date (deals-specific) */
105
+ expectedClose?: string | Date | null;
106
+
107
+ // ============ Callbacks ============
108
+ onAssigneeChange?: (assignee: Assignee | null) => void;
109
+ onDueDateChange?: (date: Date | null) => void;
110
+ onPriorityChange?: (priority: "High" | "Medium" | "Low" | null) => void;
111
+ onDescriptionChange?: (description: string) => void;
112
+ onClientChange?: (client: DealClient | null) => void;
113
+ onDealStageChange?: (stage: string | null) => void;
114
+ onValueChange?: (value: number | null) => void;
115
+ onExpectedCloseChange?: (date: Date | null) => void;
116
+ onAddSubtask?: () => void;
117
+ onToggleSubtask?: (subtaskId: string, completed: boolean) => void;
118
+ onSubtaskTitleChange?: (subtaskId: string, title: string) => void;
119
+ onAddAttachment?: () => void;
120
+ /** Callback when add deal association (+) button is clicked in Deals header */
121
+ onAddDealAssociation?: () => void;
122
+ }
123
+
124
+ // ============================================================================
125
+ // Helpers
126
+ // ============================================================================
127
+
128
+ function getInitials(name: string): string {
129
+ return name
130
+ .split(" ")
131
+ .map((n) => n[0])
132
+ .join("")
133
+ .toUpperCase()
134
+ .slice(0, 2);
135
+ }
136
+
137
+ function formatCurrency(value: number, currency: string = "USD"): string {
138
+ return new Intl.NumberFormat("en-US", {
139
+ style: "currency",
140
+ currency,
141
+ minimumFractionDigits: 0,
142
+ maximumFractionDigits: 0,
143
+ }).format(value);
144
+ }
145
+
146
+ function parseDate(date: string | Date | null | undefined): Date | undefined {
147
+ if (!date) return undefined;
148
+ if (date instanceof Date) return date;
149
+ if (typeof date === "string") {
150
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
151
+ const [year, month, day] = date.split("-").map(Number);
152
+ return new Date(year, month - 1, day);
153
+ }
154
+ return new Date(date);
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ function formatDate(date: string | Date | null | undefined): { text: string; isOverdue: boolean } {
160
+ if (!date) return { text: "No date", isOverdue: false };
161
+
162
+ const d = parseDate(date);
163
+ if (!d) return { text: "No date", isOverdue: false };
164
+
165
+ const now = new Date();
166
+ now.setHours(0, 0, 0, 0);
167
+ const dateOnly = new Date(d);
168
+ dateOnly.setHours(0, 0, 0, 0);
169
+ const isOverdue = dateOnly < now;
170
+
171
+ const yesterday = new Date(now);
172
+ yesterday.setDate(yesterday.getDate() - 1);
173
+ if (dateOnly.toDateString() === yesterday.toDateString()) {
174
+ return { text: "Yesterday", isOverdue: true };
175
+ }
176
+
177
+ if (dateOnly.toDateString() === now.toDateString()) {
178
+ return { text: "Today", isOverdue: false };
179
+ }
180
+
181
+ const tomorrow = new Date(now);
182
+ tomorrow.setDate(tomorrow.getDate() + 1);
183
+ if (dateOnly.toDateString() === tomorrow.toDateString()) {
184
+ return { text: "Tomorrow", isOverdue: false };
185
+ }
186
+
187
+ return {
188
+ text: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
189
+ isOverdue,
190
+ };
191
+ }
192
+
193
+ function getPriorityVariant(priority: string | null | undefined): "warning-light" | "destructive-light" | "success-light" | "outline" {
194
+ switch (priority) {
195
+ case "High":
196
+ return "destructive-light";
197
+ case "Medium":
198
+ return "warning-light";
199
+ case "Low":
200
+ return "success-light";
201
+ default:
202
+ return "outline";
203
+ }
204
+ }
205
+
206
+ // ============================================================================
207
+ // DealDrawerContent Component
208
+ // ============================================================================
209
+
210
+ export function DealDrawerContent({
211
+ // Default fields
212
+ assignee,
213
+ availableAssignees = [],
214
+ dueDate,
215
+ priority,
216
+ description,
217
+ subtasks = [],
218
+ attachments = [],
219
+ // Deals section (Client + Stage)
220
+ client,
221
+ availableClients = [],
222
+ dealStage,
223
+ availableStages = ["New opportunities", "Active opportunities", "Late stage", "Closed won", "Closed lost"],
224
+ // Deals-specific
225
+ value,
226
+ currency = "USD",
227
+ expectedClose,
228
+ // Callbacks
229
+ onAssigneeChange,
230
+ onDueDateChange,
231
+ onPriorityChange,
232
+ onDescriptionChange,
233
+ onClientChange,
234
+ onDealStageChange,
235
+ onValueChange,
236
+ onExpectedCloseChange,
237
+ onAddSubtask,
238
+ onToggleSubtask,
239
+ onSubtaskTitleChange,
240
+ onAddAttachment,
241
+ onAddDealAssociation,
242
+ }: DealDrawerContentProps) {
243
+ // State for client selector popover
244
+ const [clientSelectorOpen, setClientSelectorOpen] = React.useState(false);
245
+
246
+ // Local state
247
+ const [localPriority, setLocalPriority] = React.useState(priority);
248
+ const [isEditingValue, setIsEditingValue] = React.useState(false);
249
+ const [editedValue, setEditedValue] = React.useState(value?.toString() ?? "");
250
+ const [isEditingDescription, setIsEditingDescription] = React.useState(false);
251
+ const [editedDescription, setEditedDescription] = React.useState(description ?? "");
252
+ const [editingSubtaskId, setEditingSubtaskId] = React.useState<string | null>(null);
253
+ const [editedSubtaskTitle, setEditedSubtaskTitle] = React.useState("");
254
+
255
+ // Dialog/Popover states
256
+ const [assigneeSelectorOpen, setAssigneeSelectorOpen] = React.useState(false);
257
+ const [dueDatePopoverOpen, setDueDatePopoverOpen] = React.useState(false);
258
+ const [expectedClosePopoverOpen, setExpectedClosePopoverOpen] = React.useState(false);
259
+ const [dealStageSelectOpen, setDealStageSelectOpen] = React.useState(false);
260
+ const [prioritySelectOpen, setPrioritySelectOpen] = React.useState(false);
261
+
262
+ // Section collapse state
263
+ const [collapsedSections, setCollapsedSections] = React.useState<Set<string>>(new Set());
264
+
265
+ // Refs
266
+ const valueInputRef = React.useRef<HTMLInputElement>(null);
267
+ const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
268
+ const subtaskTitleInputRef = React.useRef<HTMLInputElement>(null);
269
+ const subtaskSaveHandledRef = React.useRef(false);
270
+
271
+ // Sync effects
272
+ React.useEffect(() => {
273
+ setLocalPriority(priority);
274
+ }, [priority]);
275
+
276
+ React.useEffect(() => {
277
+ setEditedValue(value?.toString() ?? "");
278
+ }, [value]);
279
+
280
+ React.useEffect(() => {
281
+ setEditedDescription(description ?? "");
282
+ }, [description]);
283
+
284
+ React.useEffect(() => {
285
+ if (isEditingValue && valueInputRef.current) {
286
+ valueInputRef.current.focus();
287
+ valueInputRef.current.select();
288
+ }
289
+ }, [isEditingValue]);
290
+
291
+ React.useEffect(() => {
292
+ if (isEditingDescription && descriptionRef.current) {
293
+ descriptionRef.current.focus();
294
+ }
295
+ }, [isEditingDescription]);
296
+
297
+ React.useEffect(() => {
298
+ if (editingSubtaskId && subtaskTitleInputRef.current) {
299
+ subtaskTitleInputRef.current.focus();
300
+ subtaskTitleInputRef.current.select();
301
+ }
302
+ }, [editingSubtaskId]);
303
+
304
+ // Handlers
305
+ const toggleSection = (sectionId: string) => {
306
+ setCollapsedSections((prev) => {
307
+ const next = new Set(prev);
308
+ if (next.has(sectionId)) {
309
+ next.delete(sectionId);
310
+ } else {
311
+ next.add(sectionId);
312
+ }
313
+ return next;
314
+ });
315
+ };
316
+
317
+ const handlePriorityChange = (val: string) => {
318
+ const newPriority = val === "none" ? null : (val as "High" | "Medium" | "Low");
319
+ setLocalPriority(newPriority);
320
+ onPriorityChange?.(newPriority);
321
+ };
322
+
323
+ const handleValueClick = () => {
324
+ if (onValueChange) {
325
+ setIsEditingValue(true);
326
+ }
327
+ };
328
+
329
+ const handleValueBlur = () => {
330
+ setIsEditingValue(false);
331
+ const numValue = parseFloat(editedValue.replace(/[^0-9.-]/g, ""));
332
+ if (!isNaN(numValue) && numValue !== value) {
333
+ onValueChange?.(numValue);
334
+ }
335
+ };
336
+
337
+ const handleValueKeyDown = (e: React.KeyboardEvent) => {
338
+ if (e.key === "Enter") {
339
+ handleValueBlur();
340
+ } else if (e.key === "Escape") {
341
+ setIsEditingValue(false);
342
+ setEditedValue(value?.toString() ?? "");
343
+ }
344
+ };
345
+
346
+ const handleDescriptionClick = () => {
347
+ if (onDescriptionChange) {
348
+ setIsEditingDescription(true);
349
+ }
350
+ };
351
+
352
+ const handleDescriptionBlur = () => {
353
+ setIsEditingDescription(false);
354
+ if (editedDescription !== (description ?? "")) {
355
+ onDescriptionChange?.(editedDescription);
356
+ }
357
+ };
358
+
359
+ const handleClearDueDate = (e: React.MouseEvent) => {
360
+ e.stopPropagation();
361
+ onDueDateChange?.(null);
362
+ };
363
+
364
+ const handleClearExpectedClose = (e: React.MouseEvent) => {
365
+ e.stopPropagation();
366
+ onExpectedCloseChange?.(null);
367
+ };
368
+
369
+ const handleSubtaskTitleDoubleClick = (event: React.MouseEvent, subtask: DealSubtask) => {
370
+ event.stopPropagation();
371
+ if (!onSubtaskTitleChange) return;
372
+ setEditingSubtaskId(subtask.id);
373
+ setEditedSubtaskTitle(subtask.title);
374
+ };
375
+
376
+ const handleSubtaskTitleSave = () => {
377
+ if (!editingSubtaskId) return;
378
+ const currentSubtask = subtasks.find((subtask) => subtask.id === editingSubtaskId);
379
+ const nextTitle = editedSubtaskTitle.trim();
380
+ if (currentSubtask && nextTitle && nextTitle !== currentSubtask.title) {
381
+ onSubtaskTitleChange?.(editingSubtaskId, nextTitle);
382
+ }
383
+ setEditingSubtaskId(null);
384
+ setEditedSubtaskTitle("");
385
+ };
386
+
387
+ const handleSubtaskTitleCancel = () => {
388
+ setEditingSubtaskId(null);
389
+ setEditedSubtaskTitle("");
390
+ };
391
+
392
+ // Computed values
393
+ const formattedDueDate = formatDate(dueDate);
394
+ const formattedExpectedClose = formatDate(expectedClose);
395
+ const currentPriority = localPriority ?? priority;
396
+ const dealStageOptions = React.useMemo(() => {
397
+ if (!dealStage || availableStages.includes(dealStage)) return availableStages;
398
+ return [dealStage, ...availableStages];
399
+ }, [availableStages, dealStage]);
400
+ const selectedDueDate = parseDate(dueDate);
401
+ const selectedExpectedClose = parseDate(expectedClose);
402
+ const completedSubtasks = subtasks.filter((s) => s.completed).length;
403
+
404
+ return (
405
+ <>
406
+ {/* Metadata */}
407
+ <div className="section-drawer-metadata">
408
+ {/* Assignee (Default) - uses popover mode */}
409
+ <AssigneeSelector
410
+ open={assigneeSelectorOpen}
411
+ onOpenChange={setAssigneeSelectorOpen}
412
+ assignees={availableAssignees}
413
+ selectedId={assignee?.id}
414
+ onSelect={(selected) => {
415
+ onAssigneeChange?.(selected);
416
+ }}
417
+ mode="popover"
418
+ align="start"
419
+ >
420
+ <div
421
+ className={`section-drawer-metadata-row ${onAssigneeChange ? "clickable" : ""}`}
422
+ >
423
+ <span className="section-drawer-metadata-label">Assignee</span>
424
+ <div className="section-drawer-metadata-value">
425
+ {assignee ? (
426
+ <div className="section-drawer-assignee">
427
+ <Avatar size="sm">
428
+ {assignee.avatar ? (
429
+ <AvatarImage src={assignee.avatar} alt={assignee.name} />
430
+ ) : null}
431
+ <AvatarFallback>{getInitials(assignee.name)}</AvatarFallback>
432
+ </Avatar>
433
+ <span>{assignee.name}</span>
434
+ </div>
435
+ ) : (
436
+ <div className="section-drawer-no-assignee">
437
+ <div className="section-drawer-empty-avatar">
438
+ <User className="size-3" />
439
+ </div>
440
+ <span className="text-muted-foreground">No assignee</span>
441
+ </div>
442
+ )}
443
+ </div>
444
+ </div>
445
+ </AssigneeSelector>
446
+
447
+ {/* Due Date (Default) */}
448
+ <Popover open={dueDatePopoverOpen} onOpenChange={setDueDatePopoverOpen}>
449
+ <PopoverTrigger asChild>
450
+ <div className="section-drawer-metadata-row clickable">
451
+ <span className="section-drawer-metadata-label">Due date</span>
452
+ <div className="section-drawer-metadata-value">
453
+ <div className={`section-drawer-due-date ${formattedDueDate.isOverdue ? "overdue" : ""}`}>
454
+ <CalendarDays className="size-4" />
455
+ <span>{formattedDueDate.text}</span>
456
+ {dueDate && (
457
+ <Button
458
+ variant="ghost"
459
+ size="icon-xs"
460
+ className="section-drawer-clear-btn"
461
+ onClick={handleClearDueDate}
462
+ >
463
+ <X className="size-3" />
464
+ </Button>
465
+ )}
466
+ </div>
467
+ </div>
468
+ </div>
469
+ </PopoverTrigger>
470
+ <PopoverContent className="w-auto p-0" align="start">
471
+ <Calendar
472
+ mode="single"
473
+ selected={selectedDueDate}
474
+ onSelect={(date) => {
475
+ onDueDateChange?.(date ?? null);
476
+ setDueDatePopoverOpen(false);
477
+ }}
478
+ initialFocus
479
+ />
480
+ {dueDate && (
481
+ <div className="border-t p-2">
482
+ <Button
483
+ variant="ghost"
484
+ size="sm"
485
+ className="w-full text-muted-foreground"
486
+ onClick={() => {
487
+ onDueDateChange?.(null);
488
+ setDueDatePopoverOpen(false);
489
+ }}
490
+ >
491
+ Clear date
492
+ </Button>
493
+ </div>
494
+ )}
495
+ </PopoverContent>
496
+ </Popover>
497
+ </div>
498
+
499
+ {/* Deals Section (Client + Deal Stage) */}
500
+ <div className="section-drawer-projects-section">
501
+ <div className="section-drawer-projects-header">
502
+ <span className="section-drawer-projects-label">
503
+ Deals
504
+ <Badge variant="outline" size="sm" className="ml-2">
505
+ {client ? 1 : 0}
506
+ </Badge>
507
+ </span>
508
+ <div className="flex items-center gap-2">
509
+ <Button variant="ghost" size="icon-xs" onClick={onAddDealAssociation}>
510
+ <Plus className="size-3" />
511
+ </Button>
512
+ </div>
513
+ </div>
514
+ {/* Client selector row - separate from Deal Stage to avoid click conflicts */}
515
+ <Popover open={clientSelectorOpen} onOpenChange={setClientSelectorOpen}>
516
+ <PopoverTrigger asChild>
517
+ <div className="section-drawer-metadata-row clickable">
518
+ <span className="section-drawer-metadata-label">Client</span>
519
+ <div className="section-drawer-metadata-value">
520
+ <div className="section-drawer-project-item">
521
+ {client ? (
522
+ <>
523
+ <Avatar size="xs">
524
+ {client.avatar ? (
525
+ <AvatarImage src={client.avatar} alt={client.name} />
526
+ ) : null}
527
+ <AvatarFallback className="text-xs">{getInitials(client.name)}</AvatarFallback>
528
+ </Avatar>
529
+ <span className="section-drawer-project-name">{client.name}</span>
530
+ </>
531
+ ) : (
532
+ <span className="text-muted-foreground">Select client</span>
533
+ )}
534
+ </div>
535
+ </div>
536
+ </div>
537
+ </PopoverTrigger>
538
+ <PopoverContent className="w-64 p-2" align="start">
539
+ <div className="space-y-1">
540
+ {availableClients.length > 0 ? (
541
+ availableClients.map((c) => (
542
+ <div
543
+ key={c.id}
544
+ className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer hover:bg-accent"
545
+ onClick={() => {
546
+ onClientChange?.(c);
547
+ setClientSelectorOpen(false);
548
+ }}
549
+ >
550
+ <Avatar size="xs">
551
+ {c.avatar ? (
552
+ <AvatarImage src={c.avatar} alt={c.name} />
553
+ ) : null}
554
+ <AvatarFallback className="text-xs">{getInitials(c.name)}</AvatarFallback>
555
+ </Avatar>
556
+ <span className="text-sm">{c.name}</span>
557
+ {client?.id === c.id && <Check className="size-4 ml-auto" />}
558
+ </div>
559
+ ))
560
+ ) : (
561
+ <p className="text-sm text-muted-foreground px-2 py-1">No clients available</p>
562
+ )}
563
+ {client && (
564
+ <>
565
+ <Separator className="my-1" />
566
+ <div
567
+ className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer hover:bg-accent text-muted-foreground"
568
+ onClick={() => {
569
+ onClientChange?.(null);
570
+ setClientSelectorOpen(false);
571
+ }}
572
+ >
573
+ <X className="size-4" />
574
+ <span className="text-sm">Clear client</span>
575
+ </div>
576
+ </>
577
+ )}
578
+ </div>
579
+ </PopoverContent>
580
+ </Popover>
581
+ {/* Deal Stage row - separate from Client to avoid click conflicts */}
582
+ <div
583
+ className={`section-drawer-metadata-row ${onDealStageChange ? "clickable" : ""}`}
584
+ onClick={() => {
585
+ if (onDealStageChange) {
586
+ setDealStageSelectOpen(true);
587
+ }
588
+ }}
589
+ >
590
+ <span className="section-drawer-metadata-label">Deal stage</span>
591
+ <div className="section-drawer-metadata-value">
592
+ <Select
593
+ open={dealStageSelectOpen}
594
+ onOpenChange={setDealStageSelectOpen}
595
+ value={dealStage ?? "none"}
596
+ onValueChange={(val) => onDealStageChange?.(val === "none" ? null : val)}
597
+ disabled={!onDealStageChange}
598
+ >
599
+ <SelectTrigger
600
+ className="w-auto border-0 bg-transparent h-auto p-0 shadow-none focus:ring-0"
601
+ onClick={(e) => e.stopPropagation()}
602
+ >
603
+ <SelectValue>
604
+ {dealStage ? (
605
+ <span>{dealStage}</span>
606
+ ) : (
607
+ <span className="text-muted-foreground">No stage</span>
608
+ )}
609
+ </SelectValue>
610
+ </SelectTrigger>
611
+ <SelectContent className="z-[11020]" position="popper" align="start">
612
+ <SelectItem value="none">No stage</SelectItem>
613
+ {dealStageOptions.map((s) => (
614
+ <SelectItem key={s} value={s}>{s}</SelectItem>
615
+ ))}
616
+ </SelectContent>
617
+ </Select>
618
+ </div>
619
+ </div>
620
+ {/* Priority inside Deals section */}
621
+ <div
622
+ className={`section-drawer-priority-row ${onPriorityChange ? "clickable" : ""}`}
623
+ onClick={() => {
624
+ if (onPriorityChange) {
625
+ setPrioritySelectOpen(true);
626
+ }
627
+ }}
628
+ >
629
+ <span className="section-drawer-priority-label">Priority</span>
630
+ <div className="section-drawer-priority-value">
631
+ <Select
632
+ open={prioritySelectOpen}
633
+ onOpenChange={setPrioritySelectOpen}
634
+ value={currentPriority ?? "none"}
635
+ onValueChange={handlePriorityChange}
636
+ disabled={!onPriorityChange}
637
+ >
638
+ <SelectTrigger
639
+ className="w-auto border-0 bg-transparent h-auto p-0 shadow-none focus:ring-0"
640
+ onClick={(e) => e.stopPropagation()}
641
+ >
642
+ <SelectValue>
643
+ {currentPriority ? (
644
+ <Badge variant={getPriorityVariant(currentPriority)} size="sm">
645
+ {currentPriority}
646
+ </Badge>
647
+ ) : (
648
+ <span className="text-muted-foreground">—</span>
649
+ )}
650
+ </SelectValue>
651
+ </SelectTrigger>
652
+ <SelectContent className="z-[11020]" position="popper" align="start">
653
+ <SelectItem value="none">No priority</SelectItem>
654
+ <SelectItem value="High">
655
+ <Badge variant="destructive-light" size="sm">High</Badge>
656
+ </SelectItem>
657
+ <SelectItem value="Medium">
658
+ <Badge variant="warning-light" size="sm">Medium</Badge>
659
+ </SelectItem>
660
+ <SelectItem value="Low">
661
+ <Badge variant="success-light" size="sm">Low</Badge>
662
+ </SelectItem>
663
+ </SelectContent>
664
+ </Select>
665
+ </div>
666
+ </div>
667
+ </div>
668
+
669
+ {/* Value and Expected Close (Deals-specific) */}
670
+ <div className="section-drawer-metadata">
671
+ {/* Value (Deals-specific) */}
672
+ {(value !== undefined || onValueChange) && (
673
+ <div
674
+ className={`section-drawer-metadata-row ${onValueChange ? "clickable" : ""}`}
675
+ onClick={handleValueClick}
676
+ >
677
+ <span className="section-drawer-metadata-label">Value</span>
678
+ <div className="section-drawer-metadata-value">
679
+ {isEditingValue ? (
680
+ <input
681
+ ref={valueInputRef}
682
+ type="text"
683
+ value={editedValue}
684
+ onChange={(e) => setEditedValue(e.target.value)}
685
+ onBlur={handleValueBlur}
686
+ onKeyDown={handleValueKeyDown}
687
+ className="section-drawer-value-input"
688
+ placeholder="Enter value"
689
+ />
690
+ ) : (
691
+ <span>{value ? formatCurrency(value, currency) : "—"}</span>
692
+ )}
693
+ </div>
694
+ </div>
695
+ )}
696
+
697
+ {/* Expected Close (Deals-specific) - matches Due Date structure exactly */}
698
+ {(expectedClose !== undefined || onExpectedCloseChange) && (
699
+ <Popover open={expectedClosePopoverOpen} onOpenChange={setExpectedClosePopoverOpen}>
700
+ <PopoverTrigger asChild>
701
+ <div className="section-drawer-metadata-row clickable">
702
+ <span className="section-drawer-metadata-label">Expected close</span>
703
+ <div className="section-drawer-metadata-value">
704
+ <div className={`section-drawer-due-date ${formattedExpectedClose.isOverdue ? "overdue" : ""}`}>
705
+ <CalendarDays className="size-4" />
706
+ <span>{formattedExpectedClose.text}</span>
707
+ {expectedClose && (
708
+ <Button
709
+ variant="ghost"
710
+ size="icon-xs"
711
+ className="section-drawer-clear-btn"
712
+ onClick={handleClearExpectedClose}
713
+ >
714
+ <X className="size-3" />
715
+ </Button>
716
+ )}
717
+ </div>
718
+ </div>
719
+ </div>
720
+ </PopoverTrigger>
721
+ <PopoverContent className="w-auto p-0" align="start">
722
+ <Calendar
723
+ mode="single"
724
+ selected={selectedExpectedClose}
725
+ onSelect={(date) => {
726
+ onExpectedCloseChange?.(date ?? null);
727
+ setExpectedClosePopoverOpen(false);
728
+ }}
729
+ initialFocus
730
+ />
731
+ {expectedClose && (
732
+ <div className="border-t p-2">
733
+ <Button
734
+ variant="ghost"
735
+ size="sm"
736
+ className="w-full text-muted-foreground"
737
+ onClick={() => {
738
+ onExpectedCloseChange?.(null);
739
+ setExpectedClosePopoverOpen(false);
740
+ }}
741
+ >
742
+ Clear date
743
+ </Button>
744
+ </div>
745
+ )}
746
+ </PopoverContent>
747
+ </Popover>
748
+ )}
749
+ </div>
750
+
751
+ <Separator />
752
+
753
+ {/* Description (Default) */}
754
+ <div className="section-drawer-section">
755
+ <h3 className="section-drawer-section-title">Description</h3>
756
+ {isEditingDescription ? (
757
+ <Textarea
758
+ ref={descriptionRef}
759
+ value={editedDescription}
760
+ onChange={(e) => setEditedDescription(e.target.value)}
761
+ onBlur={handleDescriptionBlur}
762
+ className="section-drawer-description-textarea"
763
+ placeholder="What is this task about?"
764
+ />
765
+ ) : (
766
+ <p
767
+ className="section-drawer-description-placeholder"
768
+ onClick={handleDescriptionClick}
769
+ style={{ cursor: onDescriptionChange ? "pointer" : "default" }}
770
+ >
771
+ {description || "What is this task about?"}
772
+ </p>
773
+ )}
774
+ </div>
775
+
776
+ {/* Subtasks (Default) */}
777
+ <div className="section-drawer-section" data-testid="subtasks-section">
778
+ <div className="section-drawer-section-header">
779
+ <h3
780
+ className="section-drawer-section-title collapsible"
781
+ onClick={() => toggleSection("subtasks")}
782
+ >
783
+ {collapsedSections.has("subtasks") ? (
784
+ <ChevronRight className="size-4 section-drawer-section-chevron collapsed" />
785
+ ) : (
786
+ <ChevronDown className="size-4 section-drawer-section-chevron" />
787
+ )}
788
+ Subtasks
789
+ <Badge variant="outline" size="sm" className="ml-2">
790
+ {completedSubtasks}/{subtasks.length}
791
+ </Badge>
792
+ </h3>
793
+ {onAddSubtask && (
794
+ <Button variant="ghost" size="icon-xs" onClick={onAddSubtask}>
795
+ <Plus className="size-3" />
796
+ </Button>
797
+ )}
798
+ </div>
799
+ {!collapsedSections.has("subtasks") && subtasks.length > 0 && (
800
+ <div className="section-drawer-subtask-list">
801
+ {subtasks.map((subtask) => {
802
+ const isEditingSubtask = editingSubtaskId === subtask.id;
803
+ return (
804
+ <div
805
+ key={subtask.id}
806
+ className={`section-drawer-subtask-item ${subtask.completed ? "completed" : ""}`}
807
+ >
808
+ <div
809
+ className={`section-drawer-subtask-checkbox ${subtask.completed ? "checked" : ""}`}
810
+ onClick={() => onToggleSubtask?.(subtask.id, !subtask.completed)}
811
+ >
812
+ {subtask.completed && <Check className="size-3" />}
813
+ </div>
814
+ {isEditingSubtask ? (
815
+ <input
816
+ ref={subtaskTitleInputRef}
817
+ type="text"
818
+ value={editedSubtaskTitle}
819
+ onChange={(event) => setEditedSubtaskTitle(event.target.value)}
820
+ onBlur={() => {
821
+ if (subtaskSaveHandledRef.current) {
822
+ subtaskSaveHandledRef.current = false;
823
+ return;
824
+ }
825
+ handleSubtaskTitleSave();
826
+ }}
827
+ onKeyDown={(event) => {
828
+ if (event.key === "Enter") {
829
+ subtaskSaveHandledRef.current = true;
830
+ handleSubtaskTitleSave();
831
+ } else if (event.key === "Escape") {
832
+ subtaskSaveHandledRef.current = true;
833
+ handleSubtaskTitleCancel();
834
+ }
835
+ }}
836
+ className="section-drawer-subtask-title-input"
837
+ onClick={(event) => event.stopPropagation()}
838
+ />
839
+ ) : (
840
+ <span
841
+ className="section-drawer-subtask-title"
842
+ onClick={(event) => event.stopPropagation()}
843
+ onDoubleClick={(event) => handleSubtaskTitleDoubleClick(event, subtask)}
844
+ >
845
+ {subtask.title}
846
+ </span>
847
+ )}
848
+ </div>
849
+ );
850
+ })}
851
+ </div>
852
+ )}
853
+ </div>
854
+
855
+ {/* Attachments (Default) */}
856
+ <div className="section-drawer-section">
857
+ <div className="section-drawer-section-header">
858
+ <h3
859
+ className="section-drawer-section-title collapsible"
860
+ onClick={() => toggleSection("attachments")}
861
+ >
862
+ {collapsedSections.has("attachments") ? (
863
+ <ChevronRight className="size-4 section-drawer-section-chevron collapsed" />
864
+ ) : (
865
+ <ChevronDown className="size-4 section-drawer-section-chevron" />
866
+ )}
867
+ Attachments
868
+ <Badge variant="outline" size="sm" className="ml-2">
869
+ {attachments.length}
870
+ </Badge>
871
+ </h3>
872
+ {onAddAttachment && (
873
+ <Button variant="ghost" size="icon-xs" onClick={onAddAttachment}>
874
+ <Plus className="size-3" />
875
+ </Button>
876
+ )}
877
+ </div>
878
+ {!collapsedSections.has("attachments") && attachments.length > 0 && (
879
+ <div className="section-drawer-attachment-list">
880
+ {attachments.map((attachment) => (
881
+ <div key={attachment.id} className="section-drawer-attachment-item">
882
+ <FileText className="size-4" />
883
+ <span>{attachment.name}</span>
884
+ </div>
885
+ ))}
886
+ </div>
887
+ )}
888
+ </div>
889
+ </>
890
+ );
891
+ }