SearchMultiBranchPicker.jsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. "use client";
  2. import React from "react";
  3. import { X } from "lucide-react";
  4. import { isValidBranchParam } from "@/lib/frontend/params";
  5. import { Badge } from "@/components/ui/badge";
  6. import { Button } from "@/components/ui/button";
  7. import { Checkbox } from "@/components/ui/checkbox";
  8. import { Input } from "@/components/ui/input";
  9. import { Label } from "@/components/ui/label";
  10. import { Skeleton } from "@/components/ui/skeleton";
  11. function normalizeTypedBranch(value) {
  12. if (typeof value !== "string") return null;
  13. const s = value.trim().toUpperCase();
  14. return s ? s : null;
  15. }
  16. export default function SearchMultiBranchPicker({
  17. branchesStatus,
  18. availableBranches,
  19. selectedBranches,
  20. onToggleBranch,
  21. onClearAllBranches,
  22. isSubmitting,
  23. }) {
  24. const listReady = branchesStatus === "ready";
  25. const listLoading = branchesStatus === "loading";
  26. const listError = branchesStatus === "error";
  27. const branchOptions = Array.isArray(availableBranches)
  28. ? availableBranches
  29. : [];
  30. const selected = Array.isArray(selectedBranches) ? selectedBranches : [];
  31. const selectedSet = React.useMemo(
  32. () => new Set(selected.map(String)),
  33. [selected]
  34. );
  35. const canClearAll =
  36. typeof onClearAllBranches === "function" &&
  37. selected.length > 0 &&
  38. !isSubmitting;
  39. // Fail-open manual add (only relevant if branch list failed)
  40. const [manual, setManual] = React.useState("");
  41. const typed = normalizeTypedBranch(manual);
  42. const canAddTyped =
  43. Boolean(typed && isValidBranchParam(typed)) && !selectedSet.has(typed);
  44. return (
  45. <div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
  46. <div className="space-y-3">
  47. <div className="flex items-start justify-between gap-4">
  48. <div className="space-y-1">
  49. <p className="text-sm font-medium">Niederlassungen auswählen</p>
  50. <p className="text-xs text-muted-foreground">
  51. Wählen Sie eine oder mehrere Niederlassungen. Die Suche wird nach
  52. jeder Änderung aktualisiert.
  53. </p>
  54. </div>
  55. <Button
  56. type="button"
  57. variant="outline"
  58. size="sm"
  59. disabled={!canClearAll}
  60. onClick={() => {
  61. if (!canClearAll) return;
  62. onClearAllBranches();
  63. }}
  64. title="Alle Niederlassungen abwählen"
  65. >
  66. Alle abwählen
  67. </Button>
  68. </div>
  69. {listLoading ? (
  70. <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
  71. {Array.from({ length: 15 }).map((_, i) => (
  72. <div key={i} className="flex items-center gap-2">
  73. <Skeleton className="h-4 w-4" />
  74. <Skeleton className="h-4 w-14" />
  75. </div>
  76. ))}
  77. </div>
  78. ) : null}
  79. {listError ? (
  80. <div className="space-y-3">
  81. <p className="text-xs text-muted-foreground">
  82. Niederlassungen konnten nicht geladen werden. Sie können NLxx
  83. manuell hinzufügen.
  84. </p>
  85. <div className="flex gap-2">
  86. <Input
  87. value={manual}
  88. onChange={(e) => setManual(e.target.value)}
  89. onKeyDown={(e) => {
  90. if (e.key !== "Enter") return;
  91. if (!canAddTyped) return;
  92. e.preventDefault();
  93. onToggleBranch(typed);
  94. setManual("");
  95. }}
  96. placeholder="z. B. NL17"
  97. disabled={isSubmitting}
  98. />
  99. <Button
  100. type="button"
  101. variant="outline"
  102. disabled={!canAddTyped || isSubmitting}
  103. onClick={() => {
  104. if (!canAddTyped) return;
  105. onToggleBranch(typed);
  106. setManual("");
  107. }}
  108. >
  109. Hinzufügen
  110. </Button>
  111. </div>
  112. <div className="space-y-2">
  113. <Label>Ausgewählt ({selected.length})</Label>
  114. {selected.length === 0 ? (
  115. <p className="text-xs text-muted-foreground">
  116. Keine Niederlassungen ausgewählt.
  117. </p>
  118. ) : (
  119. <div className="flex flex-wrap gap-2">
  120. {selected.map((b) => (
  121. <Badge key={b} variant="secondary" className="gap-1">
  122. <span>{b}</span>
  123. <button
  124. type="button"
  125. className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-muted/60"
  126. onClick={() => onToggleBranch(b)}
  127. disabled={isSubmitting}
  128. aria-label={`Entfernen: ${b}`}
  129. title={`Entfernen: ${b}`}
  130. >
  131. <X className="h-3 w-3" />
  132. </button>
  133. </Badge>
  134. ))}
  135. </div>
  136. )}
  137. </div>
  138. </div>
  139. ) : null}
  140. {listReady ? (
  141. <div className="space-y-2">
  142. <div className="text-xs text-muted-foreground">
  143. Ausgewählt: {selected.length}
  144. </div>
  145. {/*
  146. RHL-038 UI polish:
  147. - Use shadcn Checkbox + a “selectable card” label wrapper
  148. - Checked state highlights the whole tile (border + background)
  149. - Keep it compact so it still works with many branches
  150. */}
  151. <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-6">
  152. {branchOptions.map((b) => {
  153. const id = String(b);
  154. const checked = selectedSet.has(id);
  155. // Stable id for accessibility (Label <-> Checkbox association).
  156. const checkboxId = `branch-${id}`;
  157. return (
  158. <Label
  159. key={id}
  160. htmlFor={checkboxId}
  161. className={[
  162. "hover:bg-accent/50 flex items-start gap-3 rounded-lg border p-3 transition-colors",
  163. // When the checkbox inside is checked, highlight the tile.
  164. "has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50",
  165. "dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950",
  166. // Slightly reduce click affordance when submitting.
  167. isSubmitting ? "opacity-60" : "",
  168. ].join(" ")}
  169. title={id}
  170. >
  171. <Checkbox
  172. id={checkboxId}
  173. checked={checked}
  174. disabled={isSubmitting}
  175. // We keep the update logic in the parent: toggle by branch id.
  176. onCheckedChange={() => onToggleBranch(id)}
  177. className={[
  178. // Match the shadcn example “blue checked box” styling.
  179. "data-[state=checked]:border-blue-600 data-[state=checked]:bg-blue-600 data-[state=checked]:text-white",
  180. "dark:data-[state=checked]:border-blue-700 dark:data-[state=checked]:bg-blue-700",
  181. ].join(" ")}
  182. />
  183. <div className="grid gap-1.5 font-normal">
  184. <p className="text-xs font-medium">{id}</p>
  185. </div>
  186. </Label>
  187. );
  188. })}
  189. </div>
  190. </div>
  191. ) : null}
  192. </div>
  193. </div>
  194. );
  195. }