@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.
- package/.claude/ralph-loop.local.md +9 -0
- package/README.md +172 -0
- package/bin/init.js +269 -0
- package/bun.lock +401 -0
- package/components.json +28 -0
- package/package.json +74 -0
- package/scripts/publish-npm.sh +202 -0
- package/src/AppShell.tsx +847 -0
- package/src/components/PageHeader.tsx +160 -0
- package/src/components/data-table/README.md +447 -0
- package/src/components/data-table/data-table-preferences.tsx +184 -0
- package/src/components/data-table/data-table-toolbar.tsx +118 -0
- package/src/components/data-table/data-table.tsx +37 -0
- package/src/components/data-table/index.ts +32 -0
- package/src/components/global-header/AllServicesButton.tsx +127 -0
- package/src/components/global-header/CategoriesButton.tsx +120 -0
- package/src/components/global-header/GlobalHeader.tsx +59 -0
- package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
- package/src/components/global-header/HeaderUtilities.tsx +243 -0
- package/src/components/global-header/ServicesMenu.tsx +246 -0
- package/src/components/layout/AppBreadcrumb.tsx +70 -0
- package/src/components/layout/AppFlashbar.tsx +95 -0
- package/src/components/layout/AppLayout.tsx +271 -0
- package/src/components/layout/AppNavigation.tsx +313 -0
- package/src/components/layout/AppSidebar.tsx +229 -0
- package/src/components/patterns/index.ts +14 -0
- package/src/components/patterns/p-alert-5.tsx +19 -0
- package/src/components/patterns/p-autocomplete-5.tsx +89 -0
- package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
- package/src/components/patterns/p-button-42.tsx +37 -0
- package/src/components/patterns/p-button-51.tsx +14 -0
- package/src/components/patterns/p-button-6.tsx +5 -0
- package/src/components/patterns/p-calendar-1.tsx +18 -0
- package/src/components/patterns/p-card-1.tsx +33 -0
- package/src/components/patterns/p-card-2.tsx +26 -0
- package/src/components/patterns/p-card-5.tsx +31 -0
- package/src/components/patterns/p-collapsible-7.tsx +121 -0
- package/src/components/patterns/p-command-6.tsx +113 -0
- package/src/components/patterns/p-dialog-1.tsx +56 -0
- package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
- package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
- package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
- package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
- package/src/components/patterns/p-empty-2.tsx +34 -0
- package/src/components/patterns/p-file-upload-1.tsx +72 -0
- package/src/components/patterns/p-filters-1.tsx +666 -0
- package/src/components/patterns/p-frame-2.tsx +26 -0
- package/src/components/patterns/p-tabs-2.tsx +129 -0
- package/src/components/reui/alert.tsx +92 -0
- package/src/components/reui/autocomplete.tsx +343 -0
- package/src/components/reui/badge.tsx +87 -0
- package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
- package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
- package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
- package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
- package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
- package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
- package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
- package/src/components/reui/data-grid/data-grid.tsx +209 -0
- package/src/components/reui/date-selector.tsx +1330 -0
- package/src/components/reui/filters.tsx +1869 -0
- package/src/components/reui/frame.tsx +134 -0
- package/src/components/reui/index.ts +17 -0
- package/src/components/reui/timeline.tsx +219 -0
- package/src/components/search/Autocomplete.tsx +183 -0
- package/src/components/search/AutocompleteClient.tsx +293 -0
- package/src/components/search/GlobalSearch.tsx +187 -0
- package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
- package/src/components/section-drawer/index.ts +19 -0
- package/src/components/section-drawer/section-drawer.css +665 -0
- package/src/components/section-drawer/section-drawer.tsx +467 -0
- package/src/components/sectioned-list-board/README.md +78 -0
- package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
- package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
- package/src/components/sectioned-list-board/index.ts +19 -0
- package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
- package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
- package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
- package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
- package/src/components/sectioned-list-board/types.ts +216 -0
- package/src/components/sectioned-list-table/README.md +80 -0
- package/src/components/sectioned-list-table/index.ts +14 -0
- package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
- package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
- package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
- package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
- package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
- package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
- package/src/components/sectioned-list-table/types.ts +120 -0
- package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
- package/src/components/ui/actions-dropdown.tsx +109 -0
- package/src/components/ui/assignee-selector.tsx +209 -0
- package/src/components/ui/avatar.tsx +107 -0
- package/src/components/ui/breadcrumb.tsx +109 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +376 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/command.tsx +182 -0
- package/src/components/ui/context-menu.tsx +250 -0
- package/src/components/ui/create-button-group.tsx +128 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +133 -0
- package/src/components/ui/dropdown-menu.tsx +255 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +248 -0
- package/src/components/ui/form.tsx +165 -0
- package/src/components/ui/index.ts +37 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/page-header.tsx +80 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/scroll-area.tsx +56 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +141 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +38 -0
- package/src/components/ui/switch.tsx +33 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-copy-to-clipboard.ts +37 -0
- package/src/hooks/use-file-upload.ts +415 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +1859 -0
- package/src/urls.ts +83 -0
- package/src/vite.d.ts +22 -0
- package/src/vite.js +241 -0
- package/tsconfig.base.json +18 -0
- 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
|
+
}
|