@hed-hog/operations 0.0.303 → 0.0.305

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 (178) hide show
  1. package/README.md +200 -43
  2. package/dist/controllers/operations-approvals.controller.d.ts +9 -0
  3. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -0
  4. package/dist/controllers/operations-approvals.controller.js +64 -0
  5. package/dist/controllers/operations-approvals.controller.js.map +1 -0
  6. package/dist/controllers/operations-collaborators.controller.d.ts +223 -0
  7. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -0
  8. package/dist/controllers/operations-collaborators.controller.js +96 -0
  9. package/dist/controllers/operations-collaborators.controller.js.map +1 -0
  10. package/dist/controllers/operations-contracts.controller.d.ts +683 -0
  11. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -0
  12. package/dist/controllers/operations-contracts.controller.js +198 -0
  13. package/dist/controllers/operations-contracts.controller.js.map +1 -0
  14. package/dist/controllers/operations-org-structure.controller.d.ts +108 -0
  15. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -0
  16. package/dist/controllers/operations-org-structure.controller.js +143 -0
  17. package/dist/controllers/operations-org-structure.controller.js.map +1 -0
  18. package/dist/controllers/operations-projects.controller.d.ts +184 -0
  19. package/dist/controllers/operations-projects.controller.d.ts.map +1 -0
  20. package/dist/controllers/operations-projects.controller.js +87 -0
  21. package/dist/controllers/operations-projects.controller.js.map +1 -0
  22. package/dist/controllers/operations-tasks.controller.d.ts +85 -0
  23. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -0
  24. package/dist/controllers/operations-tasks.controller.js +90 -0
  25. package/dist/controllers/operations-tasks.controller.js.map +1 -0
  26. package/dist/controllers/operations-timesheets.controller.d.ts +99 -0
  27. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -0
  28. package/dist/controllers/operations-timesheets.controller.js +154 -0
  29. package/dist/controllers/operations-timesheets.controller.js.map +1 -0
  30. package/dist/dto/create-collaborator-type.dto.d.ts +10 -0
  31. package/dist/dto/create-collaborator-type.dto.d.ts.map +1 -0
  32. package/dist/dto/create-collaborator-type.dto.js +56 -0
  33. package/dist/dto/create-collaborator-type.dto.js.map +1 -0
  34. package/dist/dto/create-collaborator.dto.d.ts +42 -0
  35. package/dist/dto/create-collaborator.dto.d.ts.map +1 -0
  36. package/dist/dto/create-collaborator.dto.js +228 -0
  37. package/dist/dto/create-collaborator.dto.js.map +1 -0
  38. package/dist/dto/create-schedule-adjustment-request.dto.d.ts +17 -0
  39. package/dist/dto/create-schedule-adjustment-request.dto.d.ts.map +1 -0
  40. package/dist/dto/create-schedule-adjustment-request.dto.js +89 -0
  41. package/dist/dto/create-schedule-adjustment-request.dto.js.map +1 -0
  42. package/dist/dto/create-task.dto.d.ts +14 -0
  43. package/dist/dto/create-task.dto.d.ts.map +1 -0
  44. package/dist/dto/create-task.dto.js +83 -0
  45. package/dist/dto/create-task.dto.js.map +1 -0
  46. package/dist/dto/create-time-off-request.dto.d.ts +9 -0
  47. package/dist/dto/create-time-off-request.dto.d.ts.map +1 -0
  48. package/dist/dto/create-time-off-request.dto.js +54 -0
  49. package/dist/dto/create-time-off-request.dto.js.map +1 -0
  50. package/dist/dto/create-timesheet-entry.dto.d.ts +12 -0
  51. package/dist/dto/create-timesheet-entry.dto.d.ts.map +1 -0
  52. package/dist/dto/create-timesheet-entry.dto.js +75 -0
  53. package/dist/dto/create-timesheet-entry.dto.js.map +1 -0
  54. package/dist/dto/list-collaborator-types.dto.d.ts +4 -0
  55. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -0
  56. package/dist/dto/list-collaborator-types.dto.js +29 -0
  57. package/dist/dto/list-collaborator-types.dto.js.map +1 -0
  58. package/dist/dto/list-collaborators.dto.d.ts +8 -0
  59. package/dist/dto/list-collaborators.dto.d.ts.map +1 -0
  60. package/dist/dto/list-collaborators.dto.js +42 -0
  61. package/dist/dto/list-collaborators.dto.js.map +1 -0
  62. package/dist/dto/list-project-options.dto.d.ts +4 -0
  63. package/dist/dto/list-project-options.dto.d.ts.map +1 -0
  64. package/dist/dto/list-project-options.dto.js +8 -0
  65. package/dist/dto/list-project-options.dto.js.map +1 -0
  66. package/dist/dto/list-tasks.dto.d.ts +7 -0
  67. package/dist/dto/list-tasks.dto.d.ts.map +1 -0
  68. package/dist/dto/list-tasks.dto.js +38 -0
  69. package/dist/dto/list-tasks.dto.js.map +1 -0
  70. package/dist/dto/list-timesheet-entries.dto.d.ts +10 -0
  71. package/dist/dto/list-timesheet-entries.dto.d.ts.map +1 -0
  72. package/dist/dto/list-timesheet-entries.dto.js +54 -0
  73. package/dist/dto/list-timesheet-entries.dto.js.map +1 -0
  74. package/dist/dto/update-collaborator-type.dto.d.ts +4 -0
  75. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -0
  76. package/dist/dto/update-collaborator-type.dto.js +8 -0
  77. package/dist/dto/update-collaborator-type.dto.js.map +1 -0
  78. package/dist/dto/update-collaborator.dto.d.ts +4 -0
  79. package/dist/dto/update-collaborator.dto.d.ts.map +1 -0
  80. package/dist/dto/update-collaborator.dto.js +8 -0
  81. package/dist/dto/update-collaborator.dto.js.map +1 -0
  82. package/dist/dto/update-task.dto.d.ts +14 -0
  83. package/dist/dto/update-task.dto.d.ts.map +1 -0
  84. package/dist/dto/update-task.dto.js +84 -0
  85. package/dist/dto/update-task.dto.js.map +1 -0
  86. package/dist/operations.controller.d.ts +0 -1045
  87. package/dist/operations.controller.d.ts.map +1 -1
  88. package/dist/operations.controller.js +0 -429
  89. package/dist/operations.controller.js.map +1 -1
  90. package/dist/operations.module.d.ts.map +1 -1
  91. package/dist/operations.module.js +23 -2
  92. package/dist/operations.module.js.map +1 -1
  93. package/dist/operations.service.d.ts +429 -8
  94. package/dist/operations.service.d.ts.map +1 -1
  95. package/dist/operations.service.js +1931 -165
  96. package/dist/operations.service.js.map +1 -1
  97. package/dist/operations.service.spec.js +315 -1
  98. package/dist/operations.service.spec.js.map +1 -1
  99. package/dist/services/shared/operations-access.service.d.ts +16 -0
  100. package/dist/services/shared/operations-access.service.d.ts.map +1 -0
  101. package/dist/services/shared/operations-access.service.js +48 -0
  102. package/dist/services/shared/operations-access.service.js.map +1 -0
  103. package/hedhog/data/dashboard.yaml +20 -0
  104. package/hedhog/data/dashboard_component.yaml +274 -0
  105. package/hedhog/data/dashboard_component_role.yaml +174 -0
  106. package/hedhog/data/dashboard_item.yaml +299 -0
  107. package/hedhog/data/dashboard_role.yaml +20 -0
  108. package/hedhog/data/menu.yaml +30 -13
  109. package/hedhog/data/operations_collaborator_type.yaml +76 -0
  110. package/hedhog/data/route.yaml +196 -0
  111. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +231 -0
  112. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +125 -40
  113. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +740 -106
  114. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  115. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  116. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  117. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  118. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  119. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +38 -16
  120. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  121. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +1017 -649
  122. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  123. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  124. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +213 -0
  125. package/hedhog/frontend/app/_lib/api.ts.ejs +30 -1
  126. package/hedhog/frontend/app/_lib/types.ts.ejs +147 -39
  127. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +40 -9
  128. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  129. package/hedhog/frontend/app/approvals/page.tsx.ejs +116 -98
  130. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -0
  131. package/hedhog/frontend/app/collaborators/page.tsx.ejs +116 -72
  132. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  133. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +11 -9
  134. package/hedhog/frontend/app/departments/page.tsx.ejs +1 -1
  135. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  136. package/hedhog/frontend/app/projects/page.tsx.ejs +364 -133
  137. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +244 -120
  138. package/hedhog/frontend/app/team/page.tsx.ejs +15 -2
  139. package/hedhog/frontend/app/time-off/page.tsx.ejs +158 -82
  140. package/hedhog/frontend/app/timesheets/page.tsx.ejs +814 -357
  141. package/hedhog/frontend/messages/en.json +268 -53
  142. package/hedhog/frontend/messages/pt.json +484 -271
  143. package/hedhog/table/operations_collaborator.yaml +26 -13
  144. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -0
  145. package/hedhog/table/operations_collaborator_type.yaml +33 -0
  146. package/hedhog/table/operations_job_title.yaml +24 -0
  147. package/hedhog/table/operations_project.yaml +9 -0
  148. package/hedhog/table/operations_project_assignment.yaml +9 -0
  149. package/hedhog/table/operations_project_role.yaml +39 -0
  150. package/hedhog/table/operations_task.yaml +69 -0
  151. package/hedhog/table/operations_timesheet_entry.yaml +12 -0
  152. package/package.json +6 -6
  153. package/src/controllers/operations-approvals.controller.ts +24 -0
  154. package/src/controllers/operations-collaborators.controller.ts +60 -0
  155. package/src/controllers/operations-contracts.controller.ts +138 -0
  156. package/src/controllers/operations-org-structure.controller.ts +92 -0
  157. package/src/controllers/operations-projects.controller.ts +50 -0
  158. package/src/controllers/operations-tasks.controller.ts +63 -0
  159. package/src/controllers/operations-timesheets.controller.ts +100 -0
  160. package/src/dto/create-collaborator-type.dto.ts +43 -0
  161. package/src/dto/create-collaborator.dto.ts +223 -0
  162. package/src/dto/create-schedule-adjustment-request.dto.ts +91 -0
  163. package/src/dto/create-task.dto.ts +75 -0
  164. package/src/dto/create-time-off-request.dto.ts +53 -0
  165. package/src/dto/create-timesheet-entry.dto.ts +67 -0
  166. package/src/dto/list-collaborator-types.dto.ts +15 -0
  167. package/src/dto/list-collaborators.dto.ts +30 -0
  168. package/src/dto/list-project-options.dto.ts +3 -0
  169. package/src/dto/list-tasks.dto.ts +25 -0
  170. package/src/dto/list-timesheet-entries.dto.ts +40 -0
  171. package/src/dto/update-collaborator-type.dto.ts +3 -0
  172. package/src/dto/update-collaborator.dto.ts +3 -0
  173. package/src/dto/update-task.dto.ts +76 -0
  174. package/src/operations.controller.ts +1 -278
  175. package/src/operations.module.ts +23 -2
  176. package/src/operations.service.spec.ts +450 -0
  177. package/src/operations.service.ts +4507 -1561
  178. package/src/services/shared/operations-access.service.ts +52 -0
@@ -15,6 +15,7 @@ interface SectionCardProps {
15
15
  className?: string;
16
16
  compact?: boolean;
17
17
  descriptionMode?: 'inline' | 'tooltip';
18
+ actions?: ReactNode;
18
19
  }
19
20
 
20
21
  export function SectionCard({
@@ -24,38 +25,44 @@ export function SectionCard({
24
25
  className,
25
26
  compact = false,
26
27
  descriptionMode = 'inline',
28
+ actions,
27
29
  }: SectionCardProps) {
28
30
  return (
29
31
  <div className={cn(className, '')}>
30
- <div className={compact ? 'space-y-1 px-0 py-2.5' : undefined}>
31
- <div className="flex items-start justify-between gap-2">
32
- <div className="flex items-center gap-2">
32
+ <div className={compact ? 'space-y-1 px-0 py-2.5' : 'mb-4 space-y-2'}>
33
+ <div className="flex items-start justify-between gap-3">
34
+ <div className="space-y-1.5">
33
35
  <CardTitle
34
36
  className={compact ? 'text-sm font-semibold' : undefined}
35
37
  >
36
38
  {title}
37
39
  </CardTitle>
38
40
  {description && descriptionMode === 'tooltip' ? (
39
- <Tooltip>
40
- <TooltipTrigger asChild>
41
- <button
42
- type="button"
43
- className="cursor-help text-muted-foreground transition-colors hover:text-foreground"
44
- aria-label={description}
45
- >
46
- <Info className="size-4" />
47
- </button>
48
- </TooltipTrigger>
49
- <TooltipContent className="max-w-xs text-sm leading-relaxed">
50
- {description}
51
- </TooltipContent>
52
- </Tooltip>
41
+ <div className="flex items-center gap-2">
42
+ <Tooltip>
43
+ <TooltipTrigger asChild>
44
+ <button
45
+ type="button"
46
+ className="cursor-help text-muted-foreground transition-colors hover:text-foreground"
47
+ aria-label={description}
48
+ >
49
+ <Info className="size-4" />
50
+ </button>
51
+ </TooltipTrigger>
52
+ <TooltipContent className="max-w-xs text-sm leading-relaxed">
53
+ {description}
54
+ </TooltipContent>
55
+ </Tooltip>
56
+ </div>
53
57
  ) : null}
54
58
  </div>
59
+ {actions ? <div className="shrink-0">{actions}</div> : null}
55
60
  </div>
56
61
  {description && descriptionMode !== 'tooltip' ? (
57
62
  <CardDescription
58
- className={compact ? 'text-[11px] leading-relaxed' : undefined}
63
+ className={
64
+ compact ? 'text-[11px] leading-relaxed' : 'leading-relaxed'
65
+ }
59
66
  >
60
67
  {description}
61
68
  </CardDescription>
@@ -0,0 +1,609 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ Command,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ } from '@/components/ui/command';
12
+ import { Input } from '@/components/ui/input';
13
+ import { Label } from '@/components/ui/label';
14
+ import {
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
18
+ } from '@/components/ui/popover';
19
+ import {
20
+ Sheet,
21
+ SheetContent,
22
+ SheetDescription,
23
+ SheetHeader,
24
+ SheetTitle,
25
+ } from '@/components/ui/sheet';
26
+ import { useApp } from '@hed-hog/next-app-provider';
27
+ import { Check, ChevronsUpDown, Plus, X } from 'lucide-react';
28
+ import { useTranslations } from 'next-intl';
29
+ import { useEffect, useMemo, useRef, useState } from 'react';
30
+
31
+ export type SystemUserOption = {
32
+ id: number;
33
+ name: string;
34
+ };
35
+
36
+ type SystemUserSelectWithCreateProps = {
37
+ label: string;
38
+ value: string;
39
+ options: SystemUserOption[];
40
+ placeholder: string;
41
+ emptyLabel: string;
42
+ onChange: (value: string) => void;
43
+ onCreate: (
44
+ name: string,
45
+ email: string,
46
+ password: string
47
+ ) => Promise<SystemUserOption | null>;
48
+ createDescription?: string;
49
+ };
50
+
51
+ type UserListPayload = {
52
+ paginate: {
53
+ data: Array<{ id: number; name: string | null }>;
54
+ total: number;
55
+ lastPage: number;
56
+ page: number;
57
+ pageSize: number;
58
+ };
59
+ stats?: Record<string, unknown>;
60
+ };
61
+
62
+ const PAGE_SIZE = 10;
63
+
64
+ function mergeOptions(
65
+ base: SystemUserOption[],
66
+ extra: SystemUserOption[]
67
+ ): SystemUserOption[] {
68
+ const byId = new Map<number, SystemUserOption>();
69
+
70
+ for (const option of base) {
71
+ byId.set(option.id, option);
72
+ }
73
+
74
+ for (const option of extra) {
75
+ byId.set(option.id, option);
76
+ }
77
+
78
+ return Array.from(byId.values());
79
+ }
80
+
81
+ function parseUserListResponse(payload: UserListPayload): {
82
+ items: SystemUserOption[];
83
+ hasMore: boolean;
84
+ } {
85
+ const raw = payload?.paginate?.data ?? [];
86
+ const items = raw.map((u) => ({ id: u.id, name: u.name || `#${u.id}` }));
87
+ const hasMore =
88
+ (payload?.paginate?.page ?? 1) < (payload?.paginate?.lastPage ?? 1);
89
+ return { items, hasMore };
90
+ }
91
+
92
+ export function SystemUserSelectWithCreate({
93
+ label,
94
+ value,
95
+ options,
96
+ placeholder,
97
+ emptyLabel,
98
+ onChange,
99
+ onCreate,
100
+ createDescription,
101
+ }: SystemUserSelectWithCreateProps) {
102
+ const { request } = useApp();
103
+ const commonT = useTranslations('operations.Common');
104
+ const t = useTranslations('operations.CollaboratorFormPage');
105
+
106
+ const [open, setOpen] = useState(false);
107
+ const [search, setSearch] = useState('');
108
+ const [debouncedSearch, setDebouncedSearch] = useState('');
109
+ const [remoteOptions, setRemoteOptions] = useState<SystemUserOption[]>([]);
110
+ const [currentPage, setCurrentPage] = useState(1);
111
+ const [hasMore, setHasMore] = useState(false);
112
+ const [isLoadingOptions, setIsLoadingOptions] = useState(false);
113
+ const [isLoadingMoreOptions, setIsLoadingMoreOptions] = useState(false);
114
+ const [selectedValueOption, setSelectedValueOption] =
115
+ useState<SystemUserOption | null>(null);
116
+
117
+ const [createOpen, setCreateOpen] = useState(false);
118
+ const [newName, setNewName] = useState('');
119
+ const [newEmail, setNewEmail] = useState('');
120
+ const [newPassword, setNewPassword] = useState('');
121
+ const [isCreating, setIsCreating] = useState(false);
122
+
123
+ const parentScrollContainerRef = useRef<HTMLElement | null>(null);
124
+ const parentScrollTopRef = useRef(0);
125
+ const requestIdRef = useRef(0);
126
+
127
+ useEffect(() => {
128
+ const timeout = setTimeout(() => {
129
+ setDebouncedSearch(search);
130
+ }, 300);
131
+
132
+ return () => clearTimeout(timeout);
133
+ }, [search]);
134
+
135
+ const loadOwnerOptions = async (
136
+ page: number,
137
+ searchTerm: string,
138
+ reset: boolean
139
+ ) => {
140
+ const requestId = requestIdRef.current + 1;
141
+ requestIdRef.current = requestId;
142
+
143
+ if (reset) {
144
+ setIsLoadingOptions(true);
145
+ } else {
146
+ setIsLoadingMoreOptions(true);
147
+ }
148
+
149
+ try {
150
+ const params = new URLSearchParams();
151
+ params.set('page', String(page));
152
+ params.set('pageSize', String(PAGE_SIZE));
153
+
154
+ const normalizedSearch = searchTerm.trim();
155
+ if (normalizedSearch) {
156
+ params.set('search', normalizedSearch);
157
+ }
158
+
159
+ const response = await request<UserListPayload>({
160
+ url: `/user?${params.toString()}`,
161
+ method: 'GET',
162
+ });
163
+
164
+ if (requestIdRef.current !== requestId) {
165
+ return;
166
+ }
167
+
168
+ const parsed = parseUserListResponse(response.data);
169
+ setCurrentPage(page);
170
+ setHasMore(parsed.hasMore);
171
+ setRemoteOptions((current) =>
172
+ reset ? parsed.items : mergeOptions(current, parsed.items)
173
+ );
174
+ } finally {
175
+ if (requestIdRef.current === requestId) {
176
+ setIsLoadingOptions(false);
177
+ setIsLoadingMoreOptions(false);
178
+ }
179
+ }
180
+ };
181
+
182
+ useEffect(() => {
183
+ void loadOwnerOptions(1, debouncedSearch, true);
184
+ }, [debouncedSearch]);
185
+
186
+ useEffect(() => {
187
+ const normalizedValue = value.trim();
188
+
189
+ if (!normalizedValue) {
190
+ setSelectedValueOption(null);
191
+ return;
192
+ }
193
+
194
+ const matchedOption =
195
+ options.find((option) => String(option.id) === normalizedValue) ??
196
+ remoteOptions.find((option) => String(option.id) === normalizedValue) ??
197
+ null;
198
+
199
+ if (matchedOption) {
200
+ setSelectedValueOption(matchedOption);
201
+ return;
202
+ }
203
+
204
+ let active = true;
205
+
206
+ void request<UserListPayload>({
207
+ url: `/user/${normalizedValue}`,
208
+ method: 'GET',
209
+ })
210
+ .then((response) => {
211
+ if (!active) {
212
+ return;
213
+ }
214
+
215
+ const rawUser = response.data as
216
+ | { id?: number; name?: string | null }
217
+ | undefined;
218
+ const resolvedOption =
219
+ rawUser?.id && String(rawUser.id) === normalizedValue
220
+ ? {
221
+ id: rawUser.id,
222
+ name: rawUser.name || `#${rawUser.id}`,
223
+ }
224
+ : null;
225
+
226
+ if (resolvedOption) {
227
+ setSelectedValueOption(resolvedOption);
228
+ setRemoteOptions((current) => mergeOptions(current, [resolvedOption]));
229
+ return;
230
+ }
231
+
232
+ setSelectedValueOption({
233
+ id: Number(normalizedValue),
234
+ name: `#${normalizedValue}`,
235
+ });
236
+ })
237
+ .catch(() => {
238
+ if (active) {
239
+ setSelectedValueOption({
240
+ id: Number(normalizedValue),
241
+ name: `#${normalizedValue}`,
242
+ });
243
+ }
244
+ });
245
+
246
+ return () => {
247
+ active = false;
248
+ };
249
+ }, [options, remoteOptions, request, value]);
250
+
251
+ const knownOptions = useMemo(
252
+ () => mergeOptions(options, remoteOptions),
253
+ [options, remoteOptions]
254
+ );
255
+
256
+ const listedOptions = useMemo(
257
+ () => (debouncedSearch.trim() ? remoteOptions : knownOptions),
258
+ [debouncedSearch, remoteOptions, knownOptions]
259
+ );
260
+
261
+ const selectedOption =
262
+ knownOptions.find((option) => String(option.id) === value) ??
263
+ selectedValueOption ??
264
+ (value ? { id: Number(value), name: `#${value}` } : null);
265
+
266
+ const captureParentScrollPosition = (trigger: HTMLElement) => {
267
+ const parentSheetContent = trigger.closest(
268
+ '[data-radix-dialog-content]'
269
+ ) as HTMLElement | null;
270
+
271
+ if (!parentSheetContent) {
272
+ parentScrollContainerRef.current = null;
273
+ parentScrollTopRef.current = 0;
274
+ return;
275
+ }
276
+
277
+ parentScrollContainerRef.current = parentSheetContent;
278
+ parentScrollTopRef.current = parentSheetContent.scrollTop;
279
+ };
280
+
281
+ const restoreParentScrollPosition = () => {
282
+ const fallbackOpenDialog = (
283
+ Array.from(
284
+ document.querySelectorAll(
285
+ '[data-radix-dialog-content][data-state="open"]'
286
+ )
287
+ ) as HTMLElement[]
288
+ ).at(-1);
289
+
290
+ const container =
291
+ parentScrollContainerRef.current &&
292
+ document.body.contains(parentScrollContainerRef.current)
293
+ ? parentScrollContainerRef.current
294
+ : fallbackOpenDialog || null;
295
+
296
+ if (!container) {
297
+ return;
298
+ }
299
+
300
+ const restore = () => {
301
+ container.scrollTop = parentScrollTopRef.current;
302
+ };
303
+
304
+ requestAnimationFrame(restore);
305
+ setTimeout(restore, 0);
306
+ setTimeout(restore, 120);
307
+ };
308
+
309
+ const saveUser = async () => {
310
+ if (
311
+ !newName.trim() ||
312
+ !newEmail.trim() ||
313
+ !newPassword.trim() ||
314
+ isCreating
315
+ ) {
316
+ return;
317
+ }
318
+
319
+ setIsCreating(true);
320
+
321
+ try {
322
+ const created = await onCreate(
323
+ newName.trim(),
324
+ newEmail.trim(),
325
+ newPassword
326
+ );
327
+
328
+ if (!created) {
329
+ return;
330
+ }
331
+
332
+ setRemoteOptions((current) => mergeOptions([created], current));
333
+ onChange(String(created.id));
334
+ setNewName('');
335
+ setNewEmail('');
336
+ setNewPassword('');
337
+ setCreateOpen(false);
338
+ setOpen(false);
339
+ } finally {
340
+ setIsCreating(false);
341
+ }
342
+ };
343
+
344
+ return (
345
+ <>
346
+ <div className="grid gap-2">
347
+ <Label>{label}</Label>
348
+
349
+ <div className="flex w-full min-w-0 items-center gap-2">
350
+ <Popover
351
+ open={open}
352
+ onOpenChange={(nextOpen) => {
353
+ setOpen(nextOpen);
354
+
355
+ if (
356
+ nextOpen &&
357
+ !remoteOptions.length &&
358
+ !isLoadingOptions &&
359
+ !isLoadingMoreOptions
360
+ ) {
361
+ void loadOwnerOptions(1, debouncedSearch, true);
362
+ }
363
+
364
+ if (!nextOpen) {
365
+ setSearch('');
366
+ }
367
+ }}
368
+ >
369
+ <PopoverTrigger asChild>
370
+ <Button
371
+ type="button"
372
+ variant="outline"
373
+ role="combobox"
374
+ className="h-10 min-w-0 flex-1 justify-between overflow-hidden"
375
+ >
376
+ <span className="truncate text-left">
377
+ {selectedOption
378
+ ? `${selectedOption.name} (#${selectedOption.id})`
379
+ : emptyLabel}
380
+ </span>
381
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
382
+ </Button>
383
+ </PopoverTrigger>
384
+ <PopoverContent
385
+ className="p-0"
386
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
387
+ >
388
+ <Command shouldFilter={false}>
389
+ <CommandInput
390
+ placeholder={placeholder}
391
+ value={search}
392
+ onValueChange={(v) => setSearch(v)}
393
+ />
394
+ <CommandList>
395
+ <CommandEmpty>
396
+ <div className="space-y-2 p-2 text-center">
397
+ <p className="text-sm text-muted-foreground">
398
+ {commonT('states.emptyDescription')}
399
+ </p>
400
+ <Button
401
+ type="button"
402
+ variant="outline"
403
+ className="w-full"
404
+ onClick={(event) => {
405
+ captureParentScrollPosition(event.currentTarget);
406
+ setNewName(search.trim());
407
+ setOpen(false);
408
+ setCreateOpen(true);
409
+ }}
410
+ >
411
+ {t('actions.createUser')}
412
+ </Button>
413
+ </div>
414
+ </CommandEmpty>
415
+
416
+ <CommandGroup>
417
+ <CommandItem
418
+ value="none"
419
+ onSelect={() => {
420
+ onChange('');
421
+ setOpen(false);
422
+ }}
423
+ >
424
+ {!value ? (
425
+ <Check className="mr-2 h-4 w-4" />
426
+ ) : (
427
+ <span className="mr-2 h-4 w-4" />
428
+ )}
429
+ {emptyLabel}
430
+ </CommandItem>
431
+
432
+ {listedOptions.map((option) => (
433
+ <CommandItem
434
+ key={option.id}
435
+ value={`${option.name} #${option.id}`}
436
+ onSelect={() => {
437
+ onChange(String(option.id));
438
+ setOpen(false);
439
+ }}
440
+ >
441
+ {String(option.id) === value ? (
442
+ <Check className="mr-2 h-4 w-4" />
443
+ ) : (
444
+ <span className="mr-2 h-4 w-4" />
445
+ )}
446
+ <div className="min-w-0">
447
+ <div className="truncate">{option.name}</div>
448
+ <div className="truncate text-xs text-muted-foreground">
449
+ #{option.id}
450
+ </div>
451
+ </div>
452
+ </CommandItem>
453
+ ))}
454
+ </CommandGroup>
455
+
456
+ {hasMore ? (
457
+ <div className="border-t p-2">
458
+ <Button
459
+ type="button"
460
+ variant="ghost"
461
+ className="w-full"
462
+ disabled={isLoadingOptions || isLoadingMoreOptions}
463
+ onClick={() =>
464
+ void loadOwnerOptions(
465
+ currentPage + 1,
466
+ debouncedSearch,
467
+ false
468
+ )
469
+ }
470
+ >
471
+ {isLoadingOptions || isLoadingMoreOptions
472
+ ? `${commonT('actions.loadMore')}...`
473
+ : commonT('actions.loadMore')}
474
+ </Button>
475
+ </div>
476
+ ) : null}
477
+ </CommandList>
478
+ </Command>
479
+ </PopoverContent>
480
+ </Popover>
481
+
482
+ {value ? (
483
+ <Button
484
+ type="button"
485
+ variant="outline"
486
+ size="icon"
487
+ className="h-10 w-10 shrink-0"
488
+ disabled={isCreating}
489
+ onClick={() => {
490
+ onChange('');
491
+ }}
492
+ aria-label={commonT('actions.cancel')}
493
+ >
494
+ <X className="h-4 w-4" />
495
+ </Button>
496
+ ) : null}
497
+
498
+ <Button
499
+ type="button"
500
+ variant="outline"
501
+ size="icon"
502
+ className="h-10 w-10 shrink-0"
503
+ disabled={isCreating}
504
+ onClick={(event) => {
505
+ captureParentScrollPosition(event.currentTarget);
506
+ setNewName('');
507
+ setNewEmail('');
508
+ setNewPassword('');
509
+ setOpen(false);
510
+ setCreateOpen(true);
511
+ }}
512
+ aria-label={t('actions.createUser')}
513
+ >
514
+ <Plus className="h-4 w-4" />
515
+ </Button>
516
+ </div>
517
+ </div>
518
+
519
+ <Sheet
520
+ open={createOpen}
521
+ onOpenChange={(nextOpen) => {
522
+ setCreateOpen(nextOpen);
523
+
524
+ if (!nextOpen) {
525
+ setNewName('');
526
+ setNewEmail('');
527
+ setNewPassword('');
528
+ restoreParentScrollPosition();
529
+ }
530
+ }}
531
+ >
532
+ <SheetContent
533
+ className="w-full overflow-y-auto px-2 sm:max-w-md"
534
+ onCloseAutoFocus={(event) => event.preventDefault()}
535
+ >
536
+ <SheetHeader>
537
+ <SheetTitle>{t('actions.createUser')}</SheetTitle>
538
+ <SheetDescription>
539
+ {createDescription ?? t('fields.createUserDescription')}
540
+ </SheetDescription>
541
+ </SheetHeader>
542
+
543
+ <div className="mt-6 space-y-4">
544
+ <div className="space-y-2">
545
+ <Label>{t('fields.createUserName')}</Label>
546
+ <Input
547
+ value={newName}
548
+ placeholder={t('placeholders.createUserName')}
549
+ onChange={(e) => setNewName(e.target.value)}
550
+ onKeyDown={(e) => {
551
+ if (e.key === 'Enter') {
552
+ e.preventDefault();
553
+ void saveUser();
554
+ }
555
+ }}
556
+ />
557
+ </div>
558
+
559
+ <div className="space-y-2">
560
+ <Label>{t('fields.createUserEmail')}</Label>
561
+ <Input
562
+ type="email"
563
+ value={newEmail}
564
+ placeholder={t('placeholders.createUserEmail')}
565
+ onChange={(e) => setNewEmail(e.target.value)}
566
+ onKeyDown={(e) => {
567
+ if (e.key === 'Enter') {
568
+ e.preventDefault();
569
+ void saveUser();
570
+ }
571
+ }}
572
+ />
573
+ </div>
574
+
575
+ <div className="space-y-2">
576
+ <Label>{t('fields.createUserPassword')}</Label>
577
+ <Input
578
+ type="password"
579
+ value={newPassword}
580
+ placeholder={t('placeholders.createUserPassword')}
581
+ onChange={(e) => setNewPassword(e.target.value)}
582
+ onKeyDown={(e) => {
583
+ if (e.key === 'Enter') {
584
+ e.preventDefault();
585
+ void saveUser();
586
+ }
587
+ }}
588
+ />
589
+ </div>
590
+
591
+ <Button
592
+ type="button"
593
+ className="w-full"
594
+ disabled={
595
+ !newName.trim() ||
596
+ !newEmail.trim() ||
597
+ !newPassword.trim() ||
598
+ isCreating
599
+ }
600
+ onClick={() => void saveUser()}
601
+ >
602
+ {commonT('actions.save')}
603
+ </Button>
604
+ </div>
605
+ </SheetContent>
606
+ </Sheet>
607
+ </>
608
+ );
609
+ }