@salesforce/ui-bundle-template-app-react-sample-b2e 11.20.0 → 11.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/uiBundles/propertymanagementapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/applications/applicationSearchService.ts +25 -43
  4. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/maintenanceRequests/maintenanceRequestSearchService.ts +31 -65
  5. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/maintenanceWorkers/maintenanceWorkerSearchService.ts +21 -43
  6. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/properties/propertySearchService.ts +33 -53
  7. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/components/applications/ApplicationCard.tsx +80 -0
  8. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/components/dashboard/GlobalSearchBar.tsx +2 -2
  9. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/components/layout/VerticalNav.tsx +28 -18
  10. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/components/maintenanceRequests/MaintenanceRequestCard.tsx +79 -52
  11. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/components/maintenanceWorkers/WorkerCard.tsx +87 -0
  12. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/components/search/UnifiedSearch.tsx +213 -0
  13. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/distinctValuesService.ts +84 -0
  14. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/searchService.ts +71 -0
  15. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/MergedSearchResults.tsx +263 -0
  16. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/Search.tsx +185 -0
  17. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SearchResults.tsx +77 -0
  18. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SourceSection.tsx +166 -0
  19. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/PaginationControls.tsx +185 -0
  20. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/ScopeSelector.tsx +55 -0
  21. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/SearchBar.tsx +55 -0
  22. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/SortControl.tsx +62 -0
  23. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/ActiveFilters.tsx +61 -0
  24. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/DefaultFilterPanel.tsx +125 -0
  25. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/{object-search/components → search/components/filters}/FilterContext.tsx +7 -20
  26. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  27. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/inputs/DateRangeFilter.tsx +160 -0
  28. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
  29. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +88 -0
  30. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
  31. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
  32. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
  33. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/DefaultResultRow.tsx +109 -0
  34. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/config.json +83 -0
  35. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/{object-search → search}/hooks/useAsyncData.ts +3 -10
  36. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useDistinctValues.ts +59 -0
  37. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useSearch.ts +703 -0
  38. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/index.ts +90 -0
  39. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/loadConfig.ts +14 -0
  40. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/queryBuilder.ts +156 -0
  41. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/types.ts +307 -0
  42. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/utils/debounce.ts +17 -0
  43. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/utils/fieldUtils.ts +14 -0
  44. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/utils/filterUtils.ts +285 -0
  45. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/utils/sortUtils.ts +34 -0
  46. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/lib/constants.ts +16 -29
  47. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/lib/routeConfig.ts +1 -0
  48. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/lib/searchConfig.ts +190 -0
  49. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/pages/ApplicationSearch.tsx +8 -385
  50. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/pages/Home.tsx +4 -2
  51. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/pages/MaintenanceRequestSearch.tsx +8 -458
  52. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/pages/MaintenanceWorkerSearch.tsx +8 -341
  53. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/pages/PropertySearch.tsx +7 -243
  54. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/pages/Search.tsx +16 -0
  55. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/routes.tsx +6 -0
  56. package/dist/force-app/main/default/uiBundles/propertymanagementapp/tsconfig.tsbuildinfo +1 -1
  57. package/dist/package-lock.json +2 -2
  58. package/dist/package.json +1 -1
  59. package/package.json +2 -2
  60. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/applications/query/distinctApplicationStatus.graphql +0 -19
  61. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/applications/query/searchApplications.graphql +0 -65
  62. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/maintenanceRequests/query/distinctMaintenanceRequestPriority.graphql +0 -19
  63. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/maintenanceRequests/query/distinctMaintenanceRequestStatus.graphql +0 -19
  64. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/maintenanceRequests/query/distinctMaintenanceRequestType.graphql +0 -19
  65. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/maintenanceRequests/query/searchMaintenanceRequests.graphql +0 -82
  66. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/maintenanceWorkers/query/distinctMaintenanceWorkerEmploymentType.graphql +0 -19
  67. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/maintenanceWorkers/query/searchMaintenanceWorkers.graphql +0 -64
  68. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/properties/query/distinctPropertyStatus.graphql +0 -19
  69. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/properties/query/distinctPropertyType.graphql +0 -19
  70. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/api/properties/query/searchProperties.graphql +0 -105
  71. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/components/shared/ObjectSearchErrorState.tsx +0 -45
  72. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/components/shared/UserAvatar.tsx +0 -35
  73. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/__examples__/api/accountSearchService.ts +0 -46
  74. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/__examples__/api/query/distinctAccountIndustries.graphql +0 -19
  75. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/__examples__/api/query/distinctAccountTypes.graphql +0 -19
  76. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/__examples__/api/query/getAccountDetail.graphql +0 -121
  77. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/__examples__/api/query/searchAccounts.graphql +0 -51
  78. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +0 -355
  79. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +0 -304
  80. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/__examples__/pages/Home.tsx +0 -34
  81. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/api/objectSearchService.ts +0 -82
  82. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/ActiveFilters.tsx +0 -89
  83. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/ObjectBreadcrumb.tsx +0 -66
  84. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/PaginationControls.tsx +0 -109
  85. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/SearchBar.tsx +0 -41
  86. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/SortControl.tsx +0 -143
  87. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/BooleanFilter.tsx +0 -78
  88. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/DateFilter.tsx +0 -128
  89. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/DateRangeFilter.tsx +0 -70
  90. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +0 -43
  91. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +0 -105
  92. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +0 -171
  93. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/SearchFilter.tsx +0 -52
  94. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/SelectFilter.tsx +0 -97
  95. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/components/filters/TextFilter.tsx +0 -93
  96. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/hooks/useDebouncedCallback.ts +0 -34
  97. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/hooks/useObjectSearchParams.ts +0 -254
  98. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/utils/debounce.ts +0 -25
  99. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/utils/fieldUtils.ts +0 -29
  100. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/utils/filterUtils.ts +0 -404
  101. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/object-search/utils/sortUtils.ts +0 -38
  102. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/lib/filterUtils.ts +0 -11
@@ -1,80 +1,107 @@
1
1
  import React from "react";
2
- import { Card } from "../ui/card";
3
- import { Button } from "../ui/button";
2
+ import { Wrench, MapPin, User, CalendarClock } from "lucide-react";
4
3
  import type { MaintenanceRequestSearchNode } from "../../api/maintenanceRequests/maintenanceRequestSearchService";
5
4
 
6
5
  interface MaintenanceRequestCardProps {
7
6
  request: MaintenanceRequestSearchNode;
8
- onView: (id: string) => void;
7
+ onClick?: (request: MaintenanceRequestSearchNode) => void;
9
8
  }
10
9
 
11
- const priorityConfig: Record<
12
- "Emergency" | "High" | "Standard",
13
- { color: string; label: string; emoji: string }
14
- > = {
15
- Emergency: { color: "text-red-500", label: "EMERGENCY (2HR)", emoji: "🔴" },
16
- High: { color: "text-orange-500", label: "HIGH (SAME DAY)", emoji: "🟠" },
17
- Standard: { color: "text-blue-500", label: "STANDARD", emoji: "🔵" },
10
+ const priorityStyles: Record<string, string> = {
11
+ Emergency: "bg-red-100 text-red-700",
12
+ High: "bg-orange-100 text-orange-700",
13
+ Standard: "bg-blue-100 text-blue-700",
18
14
  };
19
15
 
20
- const issueIcons: Record<string, string> = {
21
- Plumbing: "💧",
22
- HVAC: "❄️",
23
- Electrical: "🔌",
24
- Appliance: "🔧",
25
- Pest: "🐛",
16
+ const statusStyles: Record<string, string> = {
17
+ New: "bg-purple-100 text-purple-700",
18
+ Assigned: "bg-blue-100 text-blue-700",
19
+ "In Progress": "bg-yellow-100 text-yellow-700",
20
+ "On Hold": "bg-gray-100 text-gray-700",
21
+ Resolved: "bg-green-100 text-green-700",
26
22
  };
27
23
 
28
24
  export const MaintenanceRequestCard: React.FC<MaintenanceRequestCardProps> = ({
29
25
  request,
30
- onView,
26
+ onClick,
31
27
  }) => {
32
- const priorityValue = request.Priority__c?.value;
33
- const priority =
34
- priorityConfig[priorityValue as keyof typeof priorityConfig] || priorityConfig.Standard;
28
+ const name = request.Name?.value || "Request";
35
29
  const issueType = request.Type__c?.value || "General";
36
- const issueIcon = issueIcons[issueType] || "🔧";
37
- const propertyAddress = request.Property__r?.Address__c?.value || "Unknown Address";
30
+ const priority = request.Priority__c?.value || "Standard";
38
31
  const status = request.Status__c?.value || "New";
39
- const description = request.Description__c?.value || "";
40
- const scheduledDate = request.Scheduled__c?.value
41
- ? new Date(request.Scheduled__c.value).toLocaleString()
32
+ const description = request.Description__c?.value || "No description provided.";
33
+ const propertyAddress = request.Property__r?.Address__c?.value || "Unknown address";
34
+ const assignedWorker = request.Assigned_Worker__r?.Name?.value;
35
+ const scheduled = request.Scheduled__c?.value
36
+ ? new Date(request.Scheduled__c.value).toLocaleString(undefined, {
37
+ month: "short",
38
+ day: "numeric",
39
+ hour: "numeric",
40
+ minute: "2-digit",
41
+ })
42
42
  : undefined;
43
- const assignedWorker = request.Assigned_Worker__r?.Name?.value || "Unassigned";
43
+
44
+ const handleClick = () => onClick?.(request);
44
45
 
45
46
  return (
46
- <Card className="p-4 mb-3">
47
- <div className="space-y-2">
48
- <div className="flex items-start justify-between gap-4">
49
- <div className="flex items-start gap-2 flex-1">
50
- <span className={`${priority.color} font-bold text-sm`}>
51
- {priority.emoji} {priority.label}
52
- </span>
53
- <span className="text-gray-700 font-medium">{propertyAddress}</span>
54
- </div>
55
- <span className="text-sm text-gray-500 uppercase">{status}</span>
47
+ <div
48
+ role="button"
49
+ tabIndex={0}
50
+ aria-label={name}
51
+ className="h-full bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-purple focus-visible:ring-offset-2"
52
+ onClick={handleClick}
53
+ onKeyDown={(e) => {
54
+ if (e.key === "Enter" || e.key === " ") {
55
+ e.preventDefault();
56
+ handleClick();
57
+ }
58
+ }}
59
+ >
60
+ <div className="flex items-start justify-between gap-2">
61
+ <div className="flex items-center gap-2 min-w-0">
62
+ <Wrench className="w-5 h-5 text-purple-600 shrink-0" />
63
+ <h3 className="text-base font-semibold text-gray-900 truncate">{name}</h3>
56
64
  </div>
65
+ <span
66
+ className={`shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium ${
67
+ statusStyles[status] ?? "bg-gray-100 text-gray-700"
68
+ }`}
69
+ >
70
+ {status}
71
+ </span>
72
+ </div>
57
73
 
58
- <div className="flex items-center gap-2">
59
- <span className="text-xl">{issueIcon}</span>
60
- <span className="text-sm text-gray-700">
61
- {issueType} - {description}
62
- </span>
63
- </div>
74
+ <div className="flex flex-wrap items-center gap-2">
75
+ <span
76
+ className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
77
+ priorityStyles[priority] ?? priorityStyles.Standard
78
+ }`}
79
+ >
80
+ {priority}
81
+ </span>
82
+ <span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
83
+ {issueType}
84
+ </span>
85
+ </div>
64
86
 
65
- {assignedWorker && (
66
- <div className="text-sm text-gray-600">👷 Assigned to: {assignedWorker}</div>
67
- )}
87
+ <p className="text-sm text-gray-700 line-clamp-2">{description}</p>
68
88
 
69
- {scheduledDate && (
70
- <div className="flex items-center justify-between">
71
- <div className="text-sm text-gray-600">📅 Scheduled: {scheduledDate}</div>
72
- <Button onClick={() => onView(request.Id)} variant="outline" size="sm">
73
- View
74
- </Button>
89
+ <div className="mt-auto space-y-1 text-sm text-gray-600">
90
+ <div className="flex items-center gap-2">
91
+ <MapPin className="w-4 h-4 text-gray-400 shrink-0" />
92
+ <span className="truncate">{propertyAddress}</span>
93
+ </div>
94
+ <div className="flex items-center gap-2">
95
+ <User className="w-4 h-4 text-gray-400 shrink-0" />
96
+ <span className="truncate">{assignedWorker || "Unassigned"}</span>
97
+ </div>
98
+ {scheduled && (
99
+ <div className="flex items-center gap-2">
100
+ <CalendarClock className="w-4 h-4 text-gray-400 shrink-0" />
101
+ <span className="truncate">{scheduled}</span>
75
102
  </div>
76
103
  )}
77
104
  </div>
78
- </Card>
105
+ </div>
79
106
  );
80
107
  };
@@ -0,0 +1,87 @@
1
+ import React from "react";
2
+ import { HardHat, Phone, MapPin, Star, DollarSign } from "lucide-react";
3
+ import type { MaintenanceWorkerSearchNode } from "../../api/maintenanceWorkers/maintenanceWorkerSearchService";
4
+
5
+ interface WorkerCardProps {
6
+ worker: MaintenanceWorkerSearchNode;
7
+ onClick?: (worker: MaintenanceWorkerSearchNode) => void;
8
+ }
9
+
10
+ export const WorkerCard: React.FC<WorkerCardProps> = ({ worker, onClick }) => {
11
+ const name = worker.Name?.value || "Worker";
12
+ const employmentType = worker.Employment_Type__c?.value || worker.Type__c?.value;
13
+ const phone = worker.Phone__c?.value;
14
+ const location = worker.Location__c?.value;
15
+ const rating = worker.Rating__c?.value;
16
+ const hourlyRate = worker.Hourly_Rate__c?.value;
17
+ const isActive = worker.IsActive__c?.value;
18
+ const active = typeof isActive === "boolean" ? isActive : undefined;
19
+
20
+ const handleClick = () => onClick?.(worker);
21
+
22
+ return (
23
+ <div
24
+ role="button"
25
+ tabIndex={0}
26
+ aria-label={name}
27
+ className="h-full bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-purple focus-visible:ring-offset-2"
28
+ onClick={handleClick}
29
+ onKeyDown={(e) => {
30
+ if (e.key === "Enter" || e.key === " ") {
31
+ e.preventDefault();
32
+ handleClick();
33
+ }
34
+ }}
35
+ >
36
+ <div className="flex items-start justify-between gap-2">
37
+ <div className="flex items-center gap-3 min-w-0">
38
+ <div className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center shrink-0">
39
+ <HardHat className="w-5 h-5 text-purple-700" />
40
+ </div>
41
+ <div className="min-w-0">
42
+ <h3 className="text-base font-semibold text-gray-900 truncate">{name}</h3>
43
+ {employmentType && <p className="text-sm text-gray-500 truncate">{employmentType}</p>}
44
+ </div>
45
+ </div>
46
+ {active !== undefined && (
47
+ <span
48
+ className={`shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium ${
49
+ active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
50
+ }`}
51
+ >
52
+ {active ? "Active" : "Inactive"}
53
+ </span>
54
+ )}
55
+ </div>
56
+
57
+ <div className="mt-auto space-y-1 text-sm text-gray-600">
58
+ {phone && (
59
+ <div className="flex items-center gap-2">
60
+ <Phone className="w-4 h-4 text-gray-400 shrink-0" />
61
+ <span className="truncate">{phone}</span>
62
+ </div>
63
+ )}
64
+ {location && (
65
+ <div className="flex items-center gap-2">
66
+ <MapPin className="w-4 h-4 text-gray-400 shrink-0" />
67
+ <span className="truncate">{location}</span>
68
+ </div>
69
+ )}
70
+ <div className="flex items-center gap-4">
71
+ {rating != null && (
72
+ <div className="flex items-center gap-1">
73
+ <Star className="w-4 h-4 text-yellow-500 shrink-0" />
74
+ <span>{rating}</span>
75
+ </div>
76
+ )}
77
+ {hourlyRate != null && (
78
+ <div className="flex items-center gap-1">
79
+ <DollarSign className="w-4 h-4 text-gray-400 shrink-0" />
80
+ <span>{hourlyRate}/hr</span>
81
+ </div>
82
+ )}
83
+ </div>
84
+ </div>
85
+ </div>
86
+ );
87
+ };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * UnifiedSearch — the single search surface for the whole app.
3
+ *
4
+ * A thin wrapper over feature-react-search's drop-in `<Search>` (see
5
+ * `lib/searchConfig.ts` for the source definitions and the `pagination.mode:
6
+ * "merged"` that selects the combined-grid layout). The feature owns all the
7
+ * search chrome — search bar, scope selector, single-source filter/sort panel,
8
+ * the results count, the merged card grid, and one global pager over the
9
+ * cumulative result set. This component supplies only what's app-specific:
10
+ *
11
+ * - **Cards** — one per object type, via `renderResult` keyed by source.
12
+ * - **Detail modals** — opened on card click; maintenance requests and
13
+ * applications also support an inline status update. Because the search
14
+ * hook exposes no refetch, saved statuses are reflected optimistically via
15
+ * `statusOverrides`.
16
+ *
17
+ * The per-card source badge in the mixed ("All") grid is supplied by the feature
18
+ * automatically (each source's `label`, auto-colored) — no app code needed.
19
+ *
20
+ * One implementation serves both the unified `/search` page (no `restrictTo`)
21
+ * and each single-object page (`restrictTo="properties"` etc.): passing
22
+ * `restrictTo` locks the scope, hides the selector, and collapses the grid to
23
+ * that one source.
24
+ */
25
+
26
+ import { useCallback, useState } from "react";
27
+ import { Search, type SearchConfig } from "../../features/search";
28
+ import { toast } from "../ui/sonner";
29
+ import { searchConfig, SOURCE_KEYS, type SourceKey } from "../../lib/searchConfig";
30
+ import { PropertyCard } from "../properties/PropertyCard";
31
+ import { MaintenanceRequestCard } from "../maintenanceRequests/MaintenanceRequestCard";
32
+ import { WorkerCard } from "../maintenanceWorkers/WorkerCard";
33
+ import { ApplicationCard } from "../applications/ApplicationCard";
34
+ import { PropertyDetailsModal } from "../properties/PropertyDetailsModal";
35
+ import { MaintenanceDetailsModal } from "../maintenanceRequests/MaintenanceDetailsModal";
36
+ import { WorkerDetailsModal } from "../maintenanceWorkers/WorkerDetailsModal";
37
+ import { ApplicationDetailsModal } from "../applications/ApplicationDetailsModal";
38
+ import type { PropertySearchNode } from "../../api/properties/propertySearchService";
39
+ import type { MaintenanceRequestSearchNode } from "../../api/maintenanceRequests/maintenanceRequestSearchService";
40
+ import type { MaintenanceWorkerSearchNode } from "../../api/maintenanceWorkers/maintenanceWorkerSearchService";
41
+ import type { ApplicationSearchNode } from "../../api/applications/applicationSearchService";
42
+ import { updateMaintenanceStatus } from "../../api/maintenanceRequests/maintenanceRequests";
43
+ import { updateApplicationStatus } from "../../api/applications/applications";
44
+
45
+ const config: SearchConfig = searchConfig;
46
+
47
+ export interface UnifiedSearchProps {
48
+ title?: string;
49
+ subtitle?: string;
50
+ searchPlaceholder?: string;
51
+ /** Lock the search to a single source; hides the scope selector. */
52
+ restrictTo?: SourceKey;
53
+ }
54
+
55
+ /** Discriminated handle on the record whose detail modal is open. */
56
+ type Selected =
57
+ | { key: typeof SOURCE_KEYS.PROPERTIES; node: PropertySearchNode }
58
+ | { key: typeof SOURCE_KEYS.MAINTENANCE; node: MaintenanceRequestSearchNode }
59
+ | { key: typeof SOURCE_KEYS.WORKERS; node: MaintenanceWorkerSearchNode }
60
+ | { key: typeof SOURCE_KEYS.APPLICATIONS; node: ApplicationSearchNode };
61
+
62
+ type StatusNode = {
63
+ Id: string;
64
+ Status__c?: { value?: string | null; displayValue?: string | null } | null;
65
+ };
66
+
67
+ /** Returns a copy of `node` with its status replaced by an optimistic override, if any. */
68
+ function withStatusOverride<T extends StatusNode>(node: T, overrides: Record<string, string>): T {
69
+ const next = overrides[node.Id];
70
+ if (next == null) return node;
71
+ return { ...node, Status__c: { ...(node.Status__c ?? {}), value: next, displayValue: next } };
72
+ }
73
+
74
+ export function UnifiedSearch({
75
+ title = "Search",
76
+ subtitle,
77
+ searchPlaceholder = "Search…",
78
+ restrictTo,
79
+ }: UnifiedSearchProps) {
80
+ const [selected, setSelected] = useState<Selected | null>(null);
81
+ const [statusOverrides, setStatusOverrides] = useState<Record<string, string>>({});
82
+
83
+ // -- Optimistic status save -------------------------------------------------
84
+
85
+ const applyOptimisticStatus = useCallback((key: Selected["key"], id: string, status: string) => {
86
+ setStatusOverrides((prev) => ({ ...prev, [id]: status }));
87
+ setSelected((prev) => {
88
+ if (!prev || prev.key !== key || prev.node.Id !== id) return prev;
89
+ // Only the status-bearing sources (maintenance, applications) reach
90
+ // here; cast through StatusNode so the union's non-status members
91
+ // don't trip the type checker.
92
+ const current = prev.node as StatusNode;
93
+ return {
94
+ ...prev,
95
+ node: {
96
+ ...prev.node,
97
+ Status__c: { ...(current.Status__c ?? {}), value: status, displayValue: status },
98
+ },
99
+ } as Selected;
100
+ });
101
+ }, []);
102
+
103
+ const handleMaintenanceSave = useCallback(
104
+ async (id: string, status: string) => {
105
+ const ok = await updateMaintenanceStatus(id, status);
106
+ if (!ok) {
107
+ toast.error("Update failed", {
108
+ description: "Could not update the maintenance request. Please try again.",
109
+ });
110
+ throw new Error("Failed to update maintenance status");
111
+ }
112
+ applyOptimisticStatus(SOURCE_KEYS.MAINTENANCE, id, status);
113
+ toast.success("Status updated", {
114
+ description: "Maintenance request status has been updated.",
115
+ });
116
+ },
117
+ [applyOptimisticStatus],
118
+ );
119
+
120
+ const handleApplicationSave = useCallback(
121
+ async (id: string, status: string) => {
122
+ const ok = await updateApplicationStatus(id, status);
123
+ if (!ok) {
124
+ toast.error("Update failed", {
125
+ description: "Could not update the application. Please try again.",
126
+ });
127
+ throw new Error("Failed to update application status");
128
+ }
129
+ applyOptimisticStatus(SOURCE_KEYS.APPLICATIONS, id, status);
130
+ toast.success("Status updated", {
131
+ description: "Application status has been updated.",
132
+ });
133
+ },
134
+ [applyOptimisticStatus],
135
+ );
136
+
137
+ // -- Card rendering ---------------------------------------------------------
138
+
139
+ const renderResult = {
140
+ [SOURCE_KEYS.PROPERTIES]: (node: unknown) => {
141
+ const property = node as PropertySearchNode;
142
+ return (
143
+ <PropertyCard
144
+ property={property}
145
+ onClick={() => setSelected({ key: SOURCE_KEYS.PROPERTIES, node: property })}
146
+ />
147
+ );
148
+ },
149
+ [SOURCE_KEYS.MAINTENANCE]: (node: unknown) => {
150
+ const request = withStatusOverride(node as MaintenanceRequestSearchNode, statusOverrides);
151
+ return (
152
+ <MaintenanceRequestCard
153
+ request={request}
154
+ onClick={() => setSelected({ key: SOURCE_KEYS.MAINTENANCE, node: request })}
155
+ />
156
+ );
157
+ },
158
+ [SOURCE_KEYS.WORKERS]: (node: unknown) => {
159
+ const worker = node as MaintenanceWorkerSearchNode;
160
+ return (
161
+ <WorkerCard
162
+ worker={worker}
163
+ onClick={() => setSelected({ key: SOURCE_KEYS.WORKERS, node: worker })}
164
+ />
165
+ );
166
+ },
167
+ [SOURCE_KEYS.APPLICATIONS]: (node: unknown) => {
168
+ const application = withStatusOverride(node as ApplicationSearchNode, statusOverrides);
169
+ return (
170
+ <ApplicationCard
171
+ application={application}
172
+ onClick={() => setSelected({ key: SOURCE_KEYS.APPLICATIONS, node: application })}
173
+ />
174
+ );
175
+ },
176
+ };
177
+
178
+ return (
179
+ <>
180
+ <Search
181
+ config={config}
182
+ title={title}
183
+ subtitle={subtitle}
184
+ searchPlaceholder={searchPlaceholder}
185
+ restrictTo={restrictTo ? { kind: "sobject", key: restrictTo } : undefined}
186
+ renderResult={renderResult}
187
+ />
188
+
189
+ {selected?.key === SOURCE_KEYS.PROPERTIES && (
190
+ <PropertyDetailsModal property={selected.node} isOpen onClose={() => setSelected(null)} />
191
+ )}
192
+ {selected?.key === SOURCE_KEYS.MAINTENANCE && (
193
+ <MaintenanceDetailsModal
194
+ request={selected.node}
195
+ isOpen
196
+ onClose={() => setSelected(null)}
197
+ onSave={handleMaintenanceSave}
198
+ />
199
+ )}
200
+ {selected?.key === SOURCE_KEYS.WORKERS && (
201
+ <WorkerDetailsModal worker={selected.node} isOpen onClose={() => setSelected(null)} />
202
+ )}
203
+ {selected?.key === SOURCE_KEYS.APPLICATIONS && (
204
+ <ApplicationDetailsModal
205
+ application={selected.node}
206
+ isOpen
207
+ onClose={() => setSelected(null)}
208
+ onSave={handleApplicationSave}
209
+ />
210
+ )}
211
+ </>
212
+ );
213
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Fetches distinct values for a picklist field via the uiapi aggregate
3
+ * `groupBy` query.
4
+ *
5
+ * Used by the auto-render pathway when a picklist filter does not declare
6
+ * inline `options`. Each call hits the GraphQL endpoint once and returns the
7
+ * sorted set of distinct values plus their display labels.
8
+ */
9
+
10
+ import { createDataSDK } from "@salesforce/platform-sdk";
11
+
12
+ export interface PicklistOption {
13
+ value: string;
14
+ label: string;
15
+ }
16
+
17
+ const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
+
19
+ function assertValidIdentifier(name: string, kind: string): void {
20
+ if (!KEY_PATTERN.test(name)) {
21
+ throw new Error(
22
+ `Invalid ${kind} "${name}". Must match /^[A-Za-z_][A-Za-z0-9_]*$/ to be a valid GraphQL identifier.`,
23
+ );
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Builds and executes a `uiapi.aggregate.<objectName>(groupBy: { <field>: { group: true } })`
29
+ * query and extracts the distinct values.
30
+ */
31
+ export async function fetchDistinctValues(
32
+ objectName: string,
33
+ fieldName: string,
34
+ ): Promise<PicklistOption[]> {
35
+ assertValidIdentifier(objectName, "objectName");
36
+ assertValidIdentifier(fieldName, "fieldName");
37
+
38
+ const document = `query DistinctValues {
39
+ uiapi {
40
+ aggregate {
41
+ ${objectName}(groupBy: { ${fieldName}: { group: true } }) {
42
+ edges {
43
+ node {
44
+ aggregate @optional {
45
+ ${fieldName} @optional {
46
+ value
47
+ displayValue
48
+ label
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }`;
57
+
58
+ const data = await createDataSDK();
59
+ const response = await data.graphql!.query<unknown>({ query: document });
60
+
61
+ if (response.errors?.length) {
62
+ throw new Error(response.errors.map((e) => e.message).join("; "));
63
+ }
64
+
65
+ const root = response.data as Record<string, unknown> | undefined;
66
+ const aggregate = (root?.uiapi as Record<string, unknown> | undefined)?.aggregate as
67
+ | Record<string, unknown>
68
+ | undefined;
69
+ const objectAgg = aggregate?.[objectName] as
70
+ | { edges?: Array<{ node?: { aggregate?: Record<string, unknown> } }> }
71
+ | undefined;
72
+ const edges = objectAgg?.edges ?? [];
73
+
74
+ return edges
75
+ .map((edge) => {
76
+ const field = edge?.node?.aggregate?.[fieldName] as
77
+ | { value?: string | null; displayValue?: string | null; label?: string | null }
78
+ | undefined;
79
+ const value = field?.value;
80
+ if (value == null || value === "") return null;
81
+ return { value, label: field?.label ?? field?.displayValue ?? value };
82
+ })
83
+ .filter((opt): opt is PicklistOption => opt !== null);
84
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Executes a search request and parses results into a per-source map.
3
+ *
4
+ * Today there's only one adapter — SObjects via the platform-sdk uiapi GraphQL
5
+ * bridge. Future adapters (CMS, REST, etc.) can run alongside this one and
6
+ * have their results merged into the same `Record<sourceKey, SourceResult>`
7
+ * by the hook.
8
+ */
9
+
10
+ import { createDataSDK } from "@salesforce/platform-sdk";
11
+ import { buildSearchQuery, type SourceRequest } from "../queryBuilder";
12
+ import type { SourceResult } from "../types";
13
+
14
+ interface RawSourceResult {
15
+ edges?: Array<{ node?: unknown }> | null;
16
+ pageInfo?: {
17
+ hasNextPage?: boolean | null;
18
+ hasPreviousPage?: boolean | null;
19
+ startCursor?: string | null;
20
+ endCursor?: string | null;
21
+ } | null;
22
+ totalCount?: number | null;
23
+ }
24
+
25
+ /**
26
+ * Runs a single multi-aliased GraphQL request against uiapi.query.
27
+ *
28
+ * Returns a plain object keyed by source.key. A source whose alias is missing
29
+ * from the response (e.g. due to a partial GraphQL error) is omitted; the
30
+ * caller can detect this by `result[sourceKey] == null`.
31
+ */
32
+ export async function runSearch(requests: SourceRequest[]): Promise<Record<string, SourceResult>> {
33
+ const { document, variables } = buildSearchQuery(requests);
34
+
35
+ const data = await createDataSDK();
36
+ const response = await data.graphql!.query<unknown, Record<string, unknown>>({
37
+ query: document,
38
+ variables,
39
+ });
40
+
41
+ if (response.errors?.length) {
42
+ throw new Error(response.errors.map((e) => e.message).join("; "));
43
+ }
44
+
45
+ const root = response.data as Record<string, unknown> | undefined;
46
+ const queryRoot = (root?.uiapi as Record<string, unknown> | undefined)?.query as
47
+ | Record<string, RawSourceResult | null | undefined>
48
+ | undefined;
49
+
50
+ const out: Record<string, SourceResult> = {};
51
+ for (const request of requests) {
52
+ const raw = queryRoot?.[request.source.key];
53
+ if (raw == null) continue;
54
+ const nodes = (raw.edges ?? [])
55
+ .map((edge) => edge?.node)
56
+ .filter((node): node is unknown => node != null);
57
+ out[request.source.key] = {
58
+ nodes,
59
+ pageInfo: raw.pageInfo
60
+ ? {
61
+ hasNextPage: raw.pageInfo.hasNextPage ?? false,
62
+ hasPreviousPage: raw.pageInfo.hasPreviousPage ?? false,
63
+ startCursor: raw.pageInfo.startCursor ?? null,
64
+ endCursor: raw.pageInfo.endCursor ?? null,
65
+ }
66
+ : null,
67
+ totalCount: raw.totalCount ?? null,
68
+ };
69
+ }
70
+ return out;
71
+ }