| 1 | 'use client' |
| 2 | |
| 3 | import { Plus, X } from 'lucide-react' |
| 4 | import { useEffect, useMemo |
| 5 | import { Controller, useFieldArray |
| 6 | import { Button } |
| 7 | import { Checkbox } |
| 8 | import DatePicker from '@/components/ui/date-picker |
| 9 | import { DateTimePicker } |
| 10 | import { |
| 11 | Dialog, |
| 12 | DialogContent, |
| 13 | DialogDescription, |
| 14 | DialogFooter, |
| 15 | DialogHeader, |
| 16 | DialogTitle, |
| 17 | } from '@/components/ui/dialog' |
| 18 | import { |
| 19 | Field, |
| 20 | FieldDescription, |
| 21 | FieldError, |
| 22 | FieldGroup, |
| 23 | FieldLabel, |
| 24 | } from '@/components/ui/field' |
| 25 | import { Input } |
| 26 | import { |
| 27 | Select, |
| 28 | SelectContent, |
| 29 | SelectItem, |
| 30 | SelectTrigger, |
| 31 | SelectValue, |
| 32 | } from '@/components/ui/select' |
| 33 | import { TimePicker } |
| 34 | import { |
| 35 | CUSTOM_FIELD_TYPE_LABELS, |
| 36 | CUSTOM_FIELD_TYPES, |
| 37 | type CustomFieldFormValues, |
| 38 | type CustomFieldInitial, |
| 39 | type CustomFieldSubmitPayload, |
| 40 | type CustomFieldType, |
| 41 | isTemporalType, |
| 42 | parseDateOnlyAsLocal, |
| 43 | toCustomFieldFormValues, |
| 44 | toSubmittableCustomFieldPayload, |
| 45 | } from '@/lib/custom-fields' |
| 46 | |
| 47 | export function CustomFieldEditorDialog({ |
| 48 | open, |
| 49 | onOpenChange, |
| 50 | mode, |
| 51 | initial, |
| 52 | isPending, |
| 53 | onSubmit, |
| 54 | }: { |
| 55 | open: boolean |
| 56 | onOpenChange: (open |
| 57 | mode: 'create |
| 58 | initial?: CustomFieldInitial |
| 59 | isPending: boolean |
| 60 | onSubmit: (payload |
| 61 | }) { |
| 62 | const defaultValues = |
| 63 | () => toCustomFieldFormValues( |
| 64 | [initial] |
| 65 | ) |
| 66 | |
| 67 | const form = |
| 68 | defaultValues, |
| 69 | mode: 'onSubmit', |
| 70 | }) |
| 71 | |
| 72 | useEffect(() => |
| 73 | if (open) { |
| 74 | form.reset(defaultValues |
| 75 | } |
| 76 | }, [open, |
| 77 | |
| 78 | const type = |
| 79 | const useCurrent = |
| 80 | |
| 81 | const optionsArray = |
| 82 | control: form.control |
| 83 | name: 'options' as |
| 84 | }) |
| 85 | |
| 86 | const handleSubmit = |
| 87 | const payload = |
| 88 | if (!payload.label |
| 89 | form.setError('label |
| 90 | return |
| 91 | } |
| 92 | if (payload.type |
| 93 | form.setError('options |
| 94 | message: 'At least one option is required.', |
| 95 | }) |
| 96 | return |
| 97 | } |
| 98 | return onSubmit(payload |
| 99 | }) |
| 100 | |
| 101 | return ( |
| 102 | <Dialog onOpenChange={ |
| 103 | <DialogContent className=' |
| 104 | <DialogHeader> |
| 105 | <DialogTitle> |
| 106 | {mode === 'create |
| 107 | </DialogTitle> |
| 108 | <DialogDescription> |
| 109 | Configure how this field appears on the timesheet form. |
| 110 | </DialogDescription> |
| 111 | </DialogHeader> |
| 112 | <form onSubmit={handleSubmit |
| 113 | <FieldGroup> |
| 114 | <Controller |
| 115 | control={form.control |
| 116 | name='label' |
| 117 | render={({ field |
| 118 | <Field className=' |
| 119 | <FieldLabel>Label</FieldLabel |
| 120 | <Input {...field |
| 121 | {fieldState.invalid && |
| 122 | <FieldError errors={ |
| 123 | )} |
| 124 | </Field> |
| 125 | )} |
| 126 | /> |
| 127 | |
| 128 | <Controller |
| 129 | control={form.control |
| 130 | name='type' |
| 131 | render={({ field |
| 132 | <Field className=' |
| 133 | <FieldLabel>Type</FieldLabel |
| 134 | <Select |
| 135 | disabled={mode === |
| 136 | onValueChange={(v) |
| 137 | field.onChange(v |
| 138 | form.setValue('defaultValue |
| 139 | form.setValue('useCurrent |
| 140 | form.setValue('options |
| 141 | form.setValue('config |
| 142 | }} |
| 143 | value={field.value |
| 144 | > |
| 145 | <SelectTrigger className=' |
| 146 | <SelectValue /> |
| 147 | </SelectTrigger> |
| 148 | <SelectContent> |
| 149 | {CUSTOM_FIELD_TYPES.map(( |
| 150 | <SelectItem key={ |
| 151 | {CUSTOM_FIELD_TYPE_LABELS[t]} |
| 152 | </SelectItem> |
| 153 | ))} |
| 154 | </SelectContent> |
| 155 | </Select> |
| 156 | {mode === 'edit |
| 157 | <FieldDescription> |
| 158 | Type can't be changed after creation. Delete and recreate. |
| 159 | </FieldDescription> |
| 160 | )} |
| 161 | </Field> |
| 162 | )} |
| 163 | /> |
| 164 | |
| 165 | {type === 'select |
| 166 | <Field className=' |
| 167 | <FieldLabel>Options</FieldLabel |
| 168 | <div className='space-y-2 |
| 169 | {optionsArray.fields.map |
| 170 | <div className='flex gap-2 |
| 171 | <Input |
| 172 | {...form.register |
| 173 | placeholder={`Option ${idx |
| 174 | /> |
| 175 | <Button |
| 176 | aria-label={`Remove option ${idx |
| 177 | onClick={() => |
| 178 | size='icon' |
| 179 | type='button' |
| 180 | variant='ghost' |
| 181 | > |
| 182 | <X className=' |
| 183 | </Button> |
| 184 | </div> |
| 185 | ))} |
| 186 | <Button |
| 187 | onClick={() => |
| 188 | size='sm' |
| 189 | type='button' |
| 190 | variant='outline' |
| 191 | > |
| 192 | <Plus className=' |
| 193 | Add option |
| 194 | </Button> |
| 195 | </div> |
| 196 | {form.formState.errors |
| 197 | <FieldError |
| 198 | errors={[ |
| 199 | { |
| 200 | message: |
| 201 | ( |
| 202 | form.formState.errors |
| 203 | message?: string |
| 204 | } |
| 205 | ).message ?? ' |
| 206 | }, |
| 207 | ]} |
| 208 | /> |
| 209 | )} |
| 210 | </Field> |
| 211 | )} |
| 212 | |
| 213 | {type === 'number |
| 214 | <div className='grid grid-cols-2 gap-3 |
| 215 | <Field className=' |
| 216 | <FieldLabel>Min</FieldLabel |
| 217 | <Controller |
| 218 | control={form.control |
| 219 | name='config.min' |
| 220 | render={({ field |
| 221 | <Input |
| 222 | onChange={(e) |
| 223 | field.onChange( |
| 224 | e.target.value |
| 225 | ? undefined |
| 226 | : Number(e. |
| 227 | ) |
| 228 | } |
| 229 | type='number' |
| 230 | value={field.value |
| 231 | /> |
| 232 | )} |
| 233 | /> |
| 234 | </Field> |
| 235 | <Field className=' |
| 236 | <FieldLabel>Max</FieldLabel |
| 237 | <Controller |
| 238 | control={form.control |
| 239 | name='config.max' |
| 240 | render={({ field |
| 241 | <Input |
| 242 | onChange={(e) |
| 243 | field.onChange( |
| 244 | e.target.value |
| 245 | ? undefined |
| 246 | : Number(e. |
| 247 | ) |
| 248 | } |
| 249 | type='number' |
| 250 | value={field.value |
| 251 | /> |
| 252 | )} |
| 253 | /> |
| 254 | </Field> |
| 255 | </div> |
| 256 | )} |
| 257 | |
| 258 | {type === 'text |
| 259 | <Field className=' |
| 260 | <FieldLabel>Max length</FieldLabel |
| 261 | <Controller |
| 262 | control={form.control |
| 263 | name='config.maxLength' |
| 264 | render={({ field |
| 265 | <Input |
| 266 | onChange={(e) |
| 267 | field.onChange( |
| 268 | e.target.value |
| 269 | ? undefined |
| 270 | : Number(e. |
| 271 | ) |
| 272 | } |
| 273 | placeholder='500' |
| 274 | type='number' |
| 275 | value={field.value |
| 276 | /> |
| 277 | )} |
| 278 | /> |
| 279 | </Field> |
| 280 | )} |
| 281 | |
| 282 | {isTemporalType(type) |
| 283 | <Field |
| 284 | className='flex-row items-center gap-2' |
| 285 | orientation='horizontal' |
| 286 | > |
| 287 | <Controller |
| 288 | control={form.control |
| 289 | name='useCurrent' |
| 290 | render={({ field |
| 291 | <Checkbox |
| 292 | checked={field.value |
| 293 | onCheckedChange={(v) |
| 294 | /> |
| 295 | )} |
| 296 | /> |
| 297 | <FieldLabel className=' |
| 298 | Use current ({type === 'date |
| 299 | </FieldLabel> |
| 300 | </Field> |
| 301 | )} |
| 302 | |
| 303 | {!useCurrent && type |
| 304 | <Field className=' |
| 305 | <FieldLabel>Default</FieldLabel |
| 306 | <DefaultInput |
| 307 | control={form.control |
| 308 | options={form.watch |
| 309 | type={type} |
| 310 | /> |
| 311 | <FieldDescription> |
| 312 | Optional. Pre-fills the field when a new entry is created. |
| 313 | </FieldDescription> |
| 314 | </Field> |
| 315 | )} |
| 316 | |
| 317 | {type === 'checkbox |
| 318 | <Field |
| 319 | className='flex-row items-center gap-2' |
| 320 | orientation='horizontal' |
| 321 | > |
| 322 | <Controller |
| 323 | control={form.control |
| 324 | name='defaultValue' |
| 325 | render={({ field |
| 326 | <Checkbox |
| 327 | checked={field.value |
| 328 | onCheckedChange={(v) |
| 329 | field.onChange(v |
| 330 | } |
| 331 | /> |
| 332 | )} |
| 333 | /> |
| 334 | <FieldLabel className=' |
| 335 | Default to checked |
| 336 | </FieldLabel> |
| 337 | </Field> |
| 338 | )} |
| 339 | |
| 340 | <div className='flex flex-col gap-2 pt-1 |
| 341 | <Field |
| 342 | className='flex-row items-center gap-2' |
| 343 | orientation='horizontal' |
| 344 | > |
| 345 | <Controller |
| 346 | control={form.control |
| 347 | name='required' |
| 348 | render={({ field |
| 349 | <Checkbox |
| 350 | checked={field.value |
| 351 | onCheckedChange={(v) |
| 352 | /> |
| 353 | )} |
| 354 | /> |
| 355 | <FieldLabel className=' |
| 356 | </Field> |
| 357 | <Field |
| 358 | className='flex-row items-center gap-2' |
| 359 | orientation='horizontal' |
| 360 | > |
| 361 | <Controller |
| 362 | control={form.control |
| 363 | name='visibleToClient' |
| 364 | render={({ field |
| 365 | <Checkbox |
| 366 | checked={field.value |
| 367 | onCheckedChange={(v) |
| 368 | /> |
| 369 | )} |
| 370 | /> |
| 371 | <FieldLabel className=' |
| 372 | Visible to client |
| 373 | </FieldLabel> |
| 374 | </Field> |
| 375 | </div> |
| 376 | </FieldGroup> |
| 377 | |
| 378 | <DialogFooter className=' |
| 379 | <Button |
| 380 | onClick={() => |
| 381 | type='button' |
| 382 | variant='outline' |
| 383 | > |
| 384 | Cancel |
| 385 | </Button> |
| 386 | <Button loading={ |
| 387 | {mode === 'create |
| 388 | </Button> |
| 389 | </DialogFooter> |
| 390 | </form> |
| 391 | </DialogContent> |
| 392 | </Dialog> |
| 393 | ) |
| 394 | } |
| 395 | |
| 396 | function DefaultInput({ |
| 397 | type, |
| 398 | control, |
| 399 | options, |
| 400 | }: { |
| 401 | type: CustomFieldType |
| 402 | control: ReturnType |
| 403 | options: { |
| 404 | }) { |
| 405 | if (type === |
| 406 | const validOptions = |
| 407 | return ( |
| 408 | <Controller |
| 409 | control={control} |
| 410 | name='defaultValue' |
| 411 | render={({ field |
| 412 | <Select |
| 413 | onValueChange={(v) |
| 414 | value={field.value |
| 415 | > |
| 416 | <SelectTrigger className=' |
| 417 | <SelectValue placeholder=' |
| 418 | </SelectTrigger> |
| 419 | <SelectContent> |
| 420 | <SelectItem value=' |
| 421 | {validOptions.map(( |
| 422 | <SelectItem key={ |
| 423 | {opt} |
| 424 | </SelectItem> |
| 425 | ))} |
| 426 | </SelectContent> |
| 427 | </Select> |
| 428 | )} |
| 429 | /> |
| 430 | ) |
| 431 | } |
| 432 | |
| 433 | if (type === |
| 434 | return ( |
| 435 | <Controller |
| 436 | control={control} |
| 437 | name='defaultValue' |
| 438 | render={({ field |
| 439 | <DatePicker |
| 440 | disablePastDates={false} |
| 441 | onChange={(d) |
| 442 | if (!d) { |
| 443 | field.onChange(null) |
| 444 | return |
| 445 | } |
| 446 | const y = |
| 447 | const m = |
| 448 | const day = |
| 449 | field.onChange(`${ |
| 450 | }} |
| 451 | value={ |
| 452 | field.value |
| 453 | ? (parseDateOnlyAsLocal(field |
| 454 | : undefined |
| 455 | } |
| 456 | /> |
| 457 | )} |
| 458 | /> |
| 459 | ) |
| 460 | } |
| 461 | |
| 462 | if (type === |
| 463 | return ( |
| 464 | <Controller |
| 465 | control={control} |
| 466 | name='defaultValue' |
| 467 | render={({ field |
| 468 | <TimePicker onChange={ |
| 469 | )} |
| 470 | /> |
| 471 | ) |
| 472 | } |
| 473 | |
| 474 | if (type === |
| 475 | return ( |
| 476 | <Controller |
| 477 | control={control} |
| 478 | name='defaultValue' |
| 479 | render={({ field |
| 480 | <DateTimePicker |
| 481 | onChange={field.onChange |
| 482 | value={field.value |
| 483 | /> |
| 484 | )} |
| 485 | /> |
| 486 | ) |
| 487 | } |
| 488 | |
| 489 | if (type === |
| 490 | return ( |
| 491 | <Controller |
| 492 | control={control} |
| 493 | name='defaultValue' |
| 494 | render={({ field |
| 495 | <Input |
| 496 | onChange={(e) |
| 497 | field.onChange(e |
| 498 | } |
| 499 | type='number' |
| 500 | value={field.value |
| 501 | /> |
| 502 | )} |
| 503 | /> |
| 504 | ) |
| 505 | } |
| 506 | |
| 507 | return ( |
| 508 | <Controller |
| 509 | control={control} |
| 510 | name='defaultValue' |
| 511 | render={({ field |
| 512 | <Input |
| 513 | onChange={(e) |
| 514 | field.onChange(e |
| 515 | } |
| 516 | value={field.value |
| 517 | /> |
| 518 | )} |
| 519 | /> |
| 520 | ) |
| 521 | } |