@asteby/metacore-runtime-react 23.2.0 → 23.4.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.
@@ -56,6 +56,14 @@ import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
56
56
  import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
57
57
  import { humanizeToken } from '../dynamic-columns-helpers'
58
58
  import { formatDateCell } from '../dynamic-columns'
59
+ import {
60
+ OptionBadge,
61
+ statusColorFor,
62
+ useIsDarkTheme,
63
+ type DisplayOption,
64
+ } from '../display-value'
65
+ import { MediaValue, RichText } from '../rich-url'
66
+ import { generateBadgeStyles } from '@asteby/metacore-ui/lib'
59
67
  import { CollectionCell, type ItemField } from '../collection-cell'
60
68
  import type { ActionFieldDef, RelationMeta } from '../types'
61
69
  import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
@@ -442,6 +450,21 @@ export function isMoneyField(field: FieldDef, value: any): boolean {
442
450
  )
443
451
  }
444
452
 
453
+ // filterVisibleFields decides which declared fields render in the form for a
454
+ // given mode. `hidden` fields never render. A `readonly` (server/system-
455
+ // generated) field is EXCLUDED on create — the user can't set a value the
456
+ // server will overwrite — but stays visible on edit/view (rendered disabled).
457
+ export function filterVisibleFields(
458
+ fields: FieldDef[] | undefined,
459
+ mode: 'view' | 'edit' | 'create',
460
+ ): FieldDef[] {
461
+ return (fields ?? []).filter(f => {
462
+ if (f.hidden) return false
463
+ if (mode === 'create' && f.readonly) return false
464
+ return true
465
+ })
466
+ }
467
+
445
468
  export function DynamicRecordDialog({
446
469
  open,
447
470
  onOpenChange,
@@ -557,7 +580,7 @@ export function DynamicRecordDialog({
557
580
  }
558
581
  } catch (err) {
559
582
  console.error('[DynamicRecordDialog] load error:', err)
560
- if (!seed) toast.error('Error al cargar los datos')
583
+ if (!seed) toast.error(t('dynamic.load_error', { defaultValue: 'No se pudieron cargar los datos' }))
561
584
  } finally {
562
585
  if (!cancelled) setLoading(false)
563
586
  }
@@ -662,7 +685,7 @@ export function DynamicRecordDialog({
662
685
  try {
663
686
  if (isCreate && onCreate) {
664
687
  const created = await onCreate(formValues)
665
- toast.success(modalMeta?.messages?.created || 'Registro creado correctamente')
688
+ toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
666
689
  onSaved?.(created ?? undefined)
667
690
  onOpenChange(false)
668
691
  return
@@ -670,7 +693,7 @@ export function DynamicRecordDialog({
670
693
 
671
694
  if (!isCreate && recordId && onUpdate) {
672
695
  const updated = await onUpdate(String(recordId), formValues)
673
- toast.success(modalMeta?.messages?.updated || 'Guardado correctamente')
696
+ toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' }))
674
697
  onSaved?.(updated ?? undefined)
675
698
  onOpenChange(false)
676
699
  return
@@ -693,16 +716,18 @@ export function DynamicRecordDialog({
693
716
  // endpoint returns a raw English string that would leak into the toast.
694
717
  toast.success(
695
718
  modalMeta?.messages?.[isCreate ? 'created' : 'updated']
696
- || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'),
719
+ || (isCreate
720
+ ? t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' })
721
+ : t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })),
697
722
  )
698
723
  // Hand the persisted record back so callers can auto-select it.
699
724
  onSaved?.(res.data?.data ?? res.data ?? undefined)
700
725
  onOpenChange(false)
701
726
  } else {
702
- toast.error(res.data?.message || 'Error al guardar')
727
+ toast.error(res.data?.message || t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }))
703
728
  }
704
729
  } catch (err: any) {
705
- toast.error(err?.response?.data?.message || 'Error al guardar')
730
+ toast.error(err?.response?.data?.message || t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }))
706
731
  } finally {
707
732
  setSaving(false)
708
733
  }
@@ -716,7 +741,7 @@ export function DynamicRecordDialog({
716
741
  onOpenChange(false)
717
742
  } catch (err: any) {
718
743
  console.error('[DynamicRecordDialog] delete error:', err)
719
- toast.error(err?.response?.data?.message || err?.message || 'Error al eliminar')
744
+ toast.error(err?.response?.data?.message || err?.message || t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
720
745
  } finally {
721
746
  setDeleting(false)
722
747
  }
@@ -724,11 +749,7 @@ export function DynamicRecordDialog({
724
749
 
725
750
  const title = modalMeta ? config.getTitle(modalMeta, t) : ''
726
751
 
727
- const visibleFields = modalMeta?.fields?.filter(f => {
728
- if (f.hidden) return false
729
- if (isCreate && f.readonly) return false
730
- return true
731
- }) ?? []
752
+ const visibleFields = filterVisibleFields(modalMeta?.fields, mode)
732
753
 
733
754
  return (
734
755
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -877,19 +898,27 @@ interface FieldRowProps {
877
898
  }
878
899
 
879
900
  function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
880
- const isReadonly = field.readonly || mode === 'view'
901
+ // A `readonly` field is server/system-generated (e.g. the GitHub addon's
902
+ // `number`/`github_url`, filled by the API after the outbound create). On
903
+ // CREATE it is excluded from the form entirely (see `visibleFields`); on EDIT
904
+ // it stays visible but is NOT editable — rendered as a disabled, muted input
905
+ // so the user sees its value without being able to change it. View mode keeps
906
+ // the rich read-only renderer.
907
+ const isEditReadonly = mode === 'edit' && !!field.readonly
881
908
 
882
909
  return (
883
910
  <div className="flex flex-col gap-1.5">
884
911
  <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
885
912
  {field.label}
886
- {field.required && mode !== 'view' && (
913
+ {field.required && mode !== 'view' && !isEditReadonly && (
887
914
  <span className="text-destructive ml-0.5">*</span>
888
915
  )}
889
916
  </Label>
890
917
 
891
- {isReadonly ? (
918
+ {mode === 'view' ? (
892
919
  <ViewValue field={field} value={value} record={record} />
920
+ ) : isEditReadonly ? (
921
+ <ReadonlyEditField field={field} value={value} />
893
922
  ) : (
894
923
  <EditField field={field} value={value} onChange={onChange} record={record} />
895
924
  )}
@@ -897,6 +926,24 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
897
926
  )
898
927
  }
899
928
 
929
+ // ReadonlyEditField — the edit-mode rendering of a `readonly` (system-generated)
930
+ // field: a disabled, muted input that shows the current value without allowing
931
+ // edits. Booleans render as a disabled switch to match their editable
932
+ // counterpart; everything else renders the formatted display value in a disabled
933
+ // text input.
934
+ export function ReadonlyEditField({ field, value }: { field: FieldDef; value: any }) {
935
+ if (field.type === 'boolean' || typeof value === 'boolean') {
936
+ return (
937
+ <div className="flex items-center gap-2 py-1">
938
+ <Switch checked={!!value} disabled />
939
+ <span className="text-sm text-muted-foreground">{value ? 'Sí' : 'No'}</span>
940
+ </div>
941
+ )
942
+ }
943
+ const display = formatDisplayValue(value, field)
944
+ return <Input value={display === '—' ? '' : display} disabled readOnly className="text-muted-foreground" />
945
+ }
946
+
900
947
  // RelationViewValue — read-only FK lead. Resolves the relation's label + image
901
948
  // from (1) the sibling object the table served, then (2) the canonical options
902
949
  // endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
@@ -975,8 +1022,22 @@ export function ViewValue({
975
1022
  const timeZone = timeZoneProp ?? ctxTimeZone
976
1023
  const currency = currencyProp ?? ctxCurrency
977
1024
 
1025
+ // Declarative display hint the backend stamps (mirrors the table column's
1026
+ // `cellStyle`). The table renders each cell off `cellStyle ?? type`; the
1027
+ // detail view keys off the SAME resolved renderer so both stay in lock-step
1028
+ // (a `datetime` display on a numeric column, a `url` display on a text
1029
+ // column, a `status`/`badge` pill, …).
1030
+ const renderAs = field.cellStyle ?? field.type
1031
+
978
1032
  // created_by / avatar resolver sibling → name (+ avatar) instead of "—".
979
- if (field.type === 'avatar' || field.key === 'created_by' || field.key === 'created_by_id') {
1033
+ if (
1034
+ field.type === 'avatar' ||
1035
+ renderAs === 'avatar' ||
1036
+ renderAs === 'creator' ||
1037
+ renderAs === 'user' ||
1038
+ field.key === 'created_by' ||
1039
+ field.key === 'created_by_id'
1040
+ ) {
980
1041
  const user = createdBySibling(rawValue, record)
981
1042
  if (user) {
982
1043
  return (
@@ -1055,16 +1116,25 @@ export function ViewValue({
1055
1116
  return <IconNameViewValue name={value} />
1056
1117
  }
1057
1118
 
1058
- if (field.type === 'url' && value) {
1119
+ // URL/link display (matches the table's `url`/`link` cell). Triggers on the
1120
+ // stamped display type — not just the storage `type` — so a text column
1121
+ // carrying `cellStyle:'url'` (e.g. `github_url`) renders as a clickable
1122
+ // external link, opening in a new tab, truncated.
1123
+ if ((renderAs === 'url' || renderAs === 'link') && value) {
1124
+ // Shared media renderer (same as the table cell): an image URL shows a
1125
+ // larger inline thumbnail, a file a chip, else a compact link chip with
1126
+ // a smart label — never the raw 120-char URL.
1059
1127
  return (
1060
- <a
1061
- href={value}
1062
- target="_blank"
1063
- rel="noreferrer"
1064
- className="text-sm text-primary hover:underline truncate"
1065
- >
1066
- {value}
1067
- </a>
1128
+ <div className="py-1">
1129
+ <MediaValue
1130
+ url={String(value)}
1131
+ getImageUrl={getImageUrl}
1132
+ label={field.styleConfig?.label as string | undefined}
1133
+ icon={field.styleConfig?.icon as string | undefined}
1134
+ thumbHeight={200}
1135
+ maxLabelWidth={360}
1136
+ />
1137
+ </div>
1068
1138
  )
1069
1139
  }
1070
1140
 
@@ -1088,9 +1158,16 @@ export function ViewValue({
1088
1158
 
1089
1159
  // Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
1090
1160
  // day); instants render in the org timezone with a full-precision tooltip.
1091
- if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp') {
1092
- const renderAs = field.type === 'date' ? 'date' : field.type
1093
- const formatted = formatDateCell(value, renderAs, es, timeZone)
1161
+ // Keys off the display type (`cellStyle ?? type`) so a numeric/epoch column
1162
+ // stamped `datetime` (e.g. `synced_at`) formats as a date, never raw digits.
1163
+ if (
1164
+ renderAs === 'date' ||
1165
+ renderAs === 'datetime' ||
1166
+ renderAs === 'timestamp' ||
1167
+ renderAs === 'timestamptz'
1168
+ ) {
1169
+ const dateRenderAs = renderAs === 'date' ? 'date' : renderAs
1170
+ const formatted = formatDateCell(value, dateRenderAs, es, timeZone)
1094
1171
  if (formatted) {
1095
1172
  return (
1096
1173
  <p className="text-sm py-1" title={formatted.title}>
@@ -1101,27 +1178,56 @@ export function ViewValue({
1101
1178
  return <p className="text-sm py-1 text-muted-foreground">—</p>
1102
1179
  }
1103
1180
 
1104
- // Enum/option field with served options → colored/iconed badge using the
1105
- // served label (e.g. "Almacenable" instead of "storable").
1181
+ // Enum/option field with served options → the SAME colored/iconed pill the
1182
+ // table renders (shared `OptionBadge`): resolved color, thumbnail/icon and
1183
+ // the localized option label (e.g. "Almacenable" instead of "storable").
1106
1184
  const opt = servedOption(field, value)
1107
1185
  if (opt) {
1108
- const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon'> = {
1109
- image: opt.image ? getImageUrl(opt.image) : null,
1110
- color: opt.color ?? null,
1111
- icon: opt.icon ?? null,
1112
- }
1113
1186
  return (
1114
- <Badge
1115
- variant="secondary"
1116
- className="w-fit flex items-center gap-1"
1117
- style={opt.color && !opt.icon ? { backgroundColor: opt.color, color: '#fff', borderColor: 'transparent' } : undefined}
1118
- >
1119
- <OptionLead option={lead} size={16} />
1120
- {opt.label}
1121
- </Badge>
1187
+ <div className="py-1">
1188
+ <OptionBadge
1189
+ option={{
1190
+ value: String(opt.value ?? value ?? ''),
1191
+ label: opt.label,
1192
+ color: opt.color ?? undefined,
1193
+ icon: opt.icon ?? undefined,
1194
+ image: opt.image ?? undefined,
1195
+ }}
1196
+ getImageUrl={getImageUrl}
1197
+ />
1198
+ </div>
1122
1199
  )
1123
1200
  }
1124
1201
 
1202
+ // Array of scalars / label objects (e.g. github `labels`, tags, a
1203
+ // group-badge list) → a row of pills, mirroring the table's `tags` /
1204
+ // `relation-badge-list` cells. Checked before the structured-object branch
1205
+ // so a flat label array never renders as a mini-table.
1206
+ if (
1207
+ Array.isArray(value) &&
1208
+ (renderAs === 'tags' ||
1209
+ renderAs === 'relation-badge-list' ||
1210
+ value.every((v) => v === null || typeof v !== 'object' || 'label' in v || 'name' in v))
1211
+ ) {
1212
+ return <BadgeListViewValue items={value} getImageUrl={getImageUrl} />
1213
+ }
1214
+
1215
+ // Status / badge / select display with no served option list — a bare enum
1216
+ // token (e.g. a kanban `stage` like "backlog"). Render a colored pill with a
1217
+ // semantic/value-derived color and a localized-or-humanized label, matching
1218
+ // the table's `status`/`badge` cells.
1219
+ if (
1220
+ (renderAs === 'status' ||
1221
+ renderAs === 'badge' ||
1222
+ renderAs === 'select' ||
1223
+ renderAs === 'option') &&
1224
+ value !== null &&
1225
+ value !== undefined &&
1226
+ typeof value !== 'object'
1227
+ ) {
1228
+ return <StatusBadgeViewValue field={field} value={value} t={t} />
1229
+ }
1230
+
1125
1231
  // Structured value (jsonb column, e.g. fiscal_data) with no label/name/title
1126
1232
  // to surface — render readable key/value pairs instead of falling through to
1127
1233
  // String(value) ("[object Object]").
@@ -1137,16 +1243,32 @@ export function ViewValue({
1137
1243
  }
1138
1244
 
1139
1245
  const display = formatDisplayValue(value, field)
1246
+ // Free text may embed URLs (a github body, notes, a long-text field). Turn
1247
+ // them into rich chips / inline thumbnails with the shared linkifier instead
1248
+ // of showing raw URLs — the rest of the text is preserved verbatim.
1249
+ const hasUrl = display !== '—' && /(https?:\/\/|www\.)\S/i.test(display)
1140
1250
 
1141
- if (field.type === 'textarea') {
1251
+ if (field.type === 'textarea' || renderAs === 'textarea' || renderAs === 'long-text') {
1142
1252
  return (
1143
- <p className="text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px]">
1144
- {display}
1253
+ <p className="text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px] [&_img]:my-1">
1254
+ {hasUrl ? (
1255
+ <RichText text={display} getImageUrl={getImageUrl} imageHeight={200} />
1256
+ ) : (
1257
+ display
1258
+ )}
1145
1259
  </p>
1146
1260
  )
1147
1261
  }
1148
1262
 
1149
- return <p className="text-sm py-1">{display}</p>
1263
+ return (
1264
+ <p className="text-sm py-1">
1265
+ {hasUrl ? (
1266
+ <RichText text={display} getImageUrl={getImageUrl} imageHeight={160} />
1267
+ ) : (
1268
+ display
1269
+ )}
1270
+ </p>
1271
+ )
1150
1272
  }
1151
1273
 
1152
1274
  // IconNameViewValue — read view for a column whose value is a lucide icon name
@@ -1163,6 +1285,79 @@ function IconNameViewValue({ name }: { name: string }) {
1163
1285
  )
1164
1286
  }
1165
1287
 
1288
+ // StatusBadgeViewValue — a bare enum/status token (no served option list) as a
1289
+ // colored pill: a semantic/value-derived color (same `statusColorFor` the table
1290
+ // uses) plus a localized-or-humanized label. Mirrors the table's `status`/
1291
+ // `badge` cell so a kanban `stage` ("backlog") reads as a colored, translated
1292
+ // badge instead of the raw token.
1293
+ function StatusBadgeViewValue({
1294
+ field,
1295
+ value,
1296
+ t,
1297
+ }: {
1298
+ field: FieldDef
1299
+ value: any
1300
+ t: (key: string, options?: any) => string
1301
+ }) {
1302
+ const isDark = useIsDarkTheme()
1303
+ const token = String(value)
1304
+ // Prefer an explicit per-option color served on the field (metadata.stages /
1305
+ // options), else derive a semantic color from the token.
1306
+ const declared = field.options?.find((o) => String(o.value) === token)
1307
+ const color = declared?.color || statusColorFor(token)
1308
+ // Localized label: the option's already-localized label wins, then a manifest
1309
+ // i18n key matching the raw token, then a humanized fallback.
1310
+ const label =
1311
+ declared?.label ?? t(token, { defaultValue: humanizeToken(token) })
1312
+ return (
1313
+ <div className="py-1">
1314
+ <Badge
1315
+ variant="outline"
1316
+ className="border-0 flex w-fit items-center gap-1"
1317
+ style={generateBadgeStyles(color, { isDark })}
1318
+ >
1319
+ {label}
1320
+ </Badge>
1321
+ </div>
1322
+ )
1323
+ }
1324
+
1325
+ // BadgeListViewValue — an array of scalars / label objects as a row of pills
1326
+ // (github `labels`, tags, a group-badge list). Objects carrying a `color` render
1327
+ // as colored `OptionBadge`s; plain strings render as neutral secondary pills.
1328
+ function BadgeListViewValue({
1329
+ items,
1330
+ getImageUrl,
1331
+ }: {
1332
+ items: any[]
1333
+ getImageUrl: GetImageUrl
1334
+ }) {
1335
+ if (!items || items.length === 0) {
1336
+ return <p className="text-sm py-1 text-muted-foreground">—</p>
1337
+ }
1338
+ return (
1339
+ <div className="flex flex-wrap gap-1 py-1">
1340
+ {items.map((item, i) => {
1341
+ if (item !== null && typeof item === 'object') {
1342
+ const opt: DisplayOption = {
1343
+ value: String(item.value ?? item.id ?? item.name ?? item.label ?? i),
1344
+ label: String(item.label ?? item.name ?? item.value ?? ''),
1345
+ color: item.color ?? undefined,
1346
+ icon: item.icon ?? undefined,
1347
+ image: item.image ?? item.avatar ?? undefined,
1348
+ }
1349
+ return <OptionBadge key={i} option={opt} getImageUrl={getImageUrl} />
1350
+ }
1351
+ return (
1352
+ <Badge key={i} variant="secondary" className="w-fit">
1353
+ {String(item)}
1354
+ </Badge>
1355
+ )
1356
+ })}
1357
+ </div>
1358
+ )
1359
+ }
1360
+
1166
1361
  // StructuredViewValue renders a jsonb object/array that has no resolvable label.
1167
1362
  // It delegates to the shared `CollectionCell` in `'inline'` mode so the detail
1168
1363
  // view gets the SAME pro rendering as the table: a declared `item_fields` schema
@@ -1392,6 +1587,7 @@ export function EditField({ field, value, onChange, record }: {
1392
1587
  }
1393
1588
 
1394
1589
  function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
1590
+ const { t } = useTranslation()
1395
1591
  const api = useApi()
1396
1592
  const model = useContext(ModelContext)
1397
1593
  const getImageUrl = useContext(ImageUrlContext)
@@ -1411,7 +1607,7 @@ function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef;
1411
1607
  const url = res.data?.data?.url || res.data?.url
1412
1608
  if (url) onChange(url)
1413
1609
  } catch {
1414
- toast.error('Error al subir imagen')
1610
+ toast.error(t('dynamic.image_upload_error', { defaultValue: 'No se pudo subir la imagen' }))
1415
1611
  } finally {
1416
1612
  setUploading(false)
1417
1613
  if (inputRef.current) inputRef.current.value = ''
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared display-value primitives.
3
+ *
4
+ * These are the building blocks the dynamic TABLE (`dynamic-columns.tsx`) uses
5
+ * to render "pro" cells — colored option badges, relation thumbnails, semantic
6
+ * status colors, dark-mode detection. They live here (instead of inline in the
7
+ * table) so the read-only DETAIL DIALOG (`dialogs/dynamic-record.tsx`) can reuse
8
+ * the EXACT same rendering. Ecosystem rule: shared primitives, zero copy-paste —
9
+ * table and dialog must not drift.
10
+ */
11
+ import React from 'react'
12
+ import { Avatar, AvatarImage, AvatarFallback, Badge } from '@asteby/metacore-ui'
13
+ import {
14
+ generateBadgeStyles,
15
+ getInitials,
16
+ optionColor,
17
+ } from '@asteby/metacore-ui/lib'
18
+ import { DynamicIcon } from './dynamic-icon'
19
+
20
+ /**
21
+ * `true` when the host document is in dark mode. Observes the `<html>` class so
22
+ * badge colors re-derive on theme toggle. SSR/no-DOM safe (starts light).
23
+ */
24
+ export function useIsDarkTheme(): boolean {
25
+ const [isDark, setIsDark] = React.useState(
26
+ () =>
27
+ typeof document !== 'undefined' &&
28
+ document.documentElement.classList.contains('dark')
29
+ )
30
+ React.useEffect(() => {
31
+ if (typeof document === 'undefined') return
32
+ const sync = () =>
33
+ setIsDark(document.documentElement.classList.contains('dark'))
34
+ sync()
35
+ const observer = new MutationObserver(sync)
36
+ observer.observe(document.documentElement, {
37
+ attributes: true,
38
+ attributeFilter: ['class'],
39
+ })
40
+ return () => observer.disconnect()
41
+ }, [])
42
+ return isDark
43
+ }
44
+
45
+ /**
46
+ * Semantic status → badge color. Used by the `status` cell/value when no
47
+ * explicit `options` color is declared. Generic, value-driven mapping.
48
+ */
49
+ export function statusColorFor(value: string): string {
50
+ const v = value.toLowerCase()
51
+ if (
52
+ ['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open']
53
+ .includes(v)
54
+ )
55
+ return '#22c55e'
56
+ if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
57
+ return '#eab308'
58
+ if (
59
+ ['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed']
60
+ .includes(v)
61
+ )
62
+ return '#ef4444'
63
+ return '#6b7280'
64
+ }
65
+
66
+ /**
67
+ * Tiny square thumbnail for a resolved relation/option that carries an `image`
68
+ * (brand logo, product photo, customer/user avatar). Uses the same Avatar
69
+ * primitive as the `avatar`/`creator` cells so a broken/loading image
70
+ * gracefully falls back to the record's initials. Sized small (inline style so
71
+ * an addon-arbitrary Tailwind class never gets dropped by a consuming app's
72
+ * class scan). Rendered inline alongside a label — never alone.
73
+ */
74
+ export const RelationThumbnail: React.FC<{
75
+ src: string
76
+ alt: string
77
+ getImageUrl?: (path: string) => string
78
+ size?: number
79
+ }> = ({ src, alt, getImageUrl, size = 18 }) => (
80
+ <Avatar
81
+ className="shrink-0 rounded-sm ring-1 ring-border/40"
82
+ style={{ width: size, height: size }}
83
+ >
84
+ <AvatarImage
85
+ src={getImageUrl ? getImageUrl(src) : src}
86
+ alt={alt}
87
+ className="object-cover"
88
+ />
89
+ <AvatarFallback className="rounded-sm bg-primary/10 text-[8px] font-bold text-primary">
90
+ {getInitials(alt)}
91
+ </AvatarFallback>
92
+ </Avatar>
93
+ )
94
+
95
+ export interface DisplayOption {
96
+ value: string
97
+ label: string
98
+ icon?: string
99
+ color?: string
100
+ image?: string
101
+ }
102
+
103
+ /**
104
+ * Colored option badge — the canonical "pro" pill for a select/status/badge
105
+ * value. Explicit backend `color` wins; otherwise a stable, cohesive color is
106
+ * derived from the option value so "dead" gray badges come alive. Inline style
107
+ * (hex-derived) so it works regardless of the host's tailwind safelist —
108
+ * addon-arbitrary classes aren't in the host scan.
109
+ */
110
+ export const OptionBadge: React.FC<{
111
+ option: DisplayOption
112
+ getImageUrl?: (path: string) => string
113
+ /** Accepted for call-site compatibility with the table; unused (option.label wins). */
114
+ fallback?: string
115
+ }> = ({ option, getImageUrl }) => {
116
+ const isDark = useIsDarkTheme()
117
+ const colorSource = option.color || optionColor(option.value || option.label)
118
+ const colorStyles = generateBadgeStyles(colorSource, { isDark })
119
+ return (
120
+ <Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
121
+ {option.image ? (
122
+ <RelationThumbnail
123
+ src={option.image}
124
+ alt={option.label}
125
+ getImageUrl={getImageUrl}
126
+ size={16}
127
+ />
128
+ ) : (
129
+ option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />
130
+ )}
131
+ <span>{option.label}</span>
132
+ </Badge>
133
+ )
134
+ }