VariantCandidates.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { useMemo } from 'react';
  2. import { useQueries } from '@tanstack/react-query';
  3. import { ArrowDown, ArrowUp, Layers, Printer as PrinterIcon } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../../api/client';
  6. /** One sliced file offered as an alternative for a cross-model job (#671). */
  7. export interface VariantCandidate {
  8. id: number;
  9. filename: string;
  10. sliced_for_model: string | null;
  11. }
  12. interface VariantCandidatesProps {
  13. candidates: VariantCandidate[];
  14. onReorder: (next: VariantCandidate[]) => void;
  15. /** file id -> chosen plate, for the multi-plate candidates only. */
  16. plateByFile: Record<number, number | null>;
  17. onPlateChange: (fileId: number, plateId: number | null) => void;
  18. /** Show the set without offering to change it — the edit-queue-item case,
  19. * where reordering would need a variant-level API that doesn't exist. */
  20. readOnly?: boolean;
  21. /** Replaces the help line under the heading when read-only. */
  22. readOnlyNote?: string;
  23. }
  24. /**
  25. * The ordered candidate list for a cross-model print (#671).
  26. *
  27. * Only two things are configured per candidate: its order, and — when the file
  28. * holds more than one plate — which plate to run. Everything else on the modal
  29. * (filament overrides, print options, schedule) stays shared, because that is
  30. * already how model-based assignment works: the printer is unknown at queue
  31. * time, so there is nothing per-machine to configure. The AMS mapping in
  32. * particular is deliberately absent — the scheduler computes it against the
  33. * printer it actually picks, exactly as it does for a single-model job.
  34. *
  35. * Order is the answer to "which would you rather have when both are free", so
  36. * it is explicit rather than left to whichever printer the matcher saw first.
  37. * Move buttons instead of drag: the list is two or three rows, and buttons work
  38. * from the keyboard without a drag-and-drop dependency.
  39. */
  40. export function VariantCandidates({
  41. candidates,
  42. onReorder,
  43. plateByFile,
  44. onPlateChange,
  45. readOnly = false,
  46. readOnlyNote,
  47. }: VariantCandidatesProps) {
  48. const { t } = useTranslation();
  49. const plateQueries = useQueries({
  50. // Read-only mode shows a job that is already queued — its plates were
  51. // chosen when it was created, so there is nothing to fetch or offer.
  52. queries: readOnly
  53. ? []
  54. : candidates.map((c) => ({
  55. queryKey: ['library-file-plates', c.id],
  56. queryFn: () => api.getLibraryFilePlates(c.id),
  57. staleTime: 60_000,
  58. })),
  59. });
  60. const platesByFile = useMemo(() => {
  61. const out: Record<number, { index: number; name: string | null }[]> = {};
  62. candidates.forEach((c, i) => {
  63. const data = plateQueries[i]?.data;
  64. if (data?.is_multi_plate && data.plates.length > 1) {
  65. out[c.id] = data.plates.map((p) => ({ index: p.index, name: p.name }));
  66. }
  67. });
  68. return out;
  69. }, [candidates, plateQueries]);
  70. const move = (from: number, to: number) => {
  71. if (to < 0 || to >= candidates.length) return;
  72. const next = [...candidates];
  73. const [moved] = next.splice(from, 1);
  74. next.splice(to, 0, moved);
  75. onReorder(next);
  76. };
  77. return (
  78. <div className="mb-4">
  79. <div className="flex items-center gap-2 mb-2">
  80. <PrinterIcon className="w-4 h-4 text-bambu-gray" />
  81. <span className="text-sm text-bambu-gray">{t('printModal.variants.title')}</span>
  82. </div>
  83. <p className="text-xs text-bambu-gray mb-2">
  84. {readOnly ? (readOnlyNote ?? t('printModal.variants.help')) : t('printModal.variants.help')}
  85. </p>
  86. <div className="space-y-2">
  87. {candidates.map((candidate, index) => {
  88. const plates = platesByFile[candidate.id];
  89. // Shown exactly as the 3MF declares it. The backend normalizes when it
  90. // resolves the candidate; echoing its own words here avoids a second,
  91. // possibly disagreeing, normalizer in the browser.
  92. const model = candidate.sliced_for_model;
  93. return (
  94. <div
  95. key={candidate.id}
  96. className="flex flex-wrap items-center gap-2 rounded border border-bambu-dark-tertiary p-2"
  97. >
  98. <span className="text-xs font-mono text-bambu-gray w-5 shrink-0">{index + 1}.</span>
  99. <span className="px-2 py-0.5 rounded-full bg-bambu-green/10 text-bambu-green text-xs shrink-0">
  100. {model || t('printModal.variants.unknownModel')}
  101. </span>
  102. <span className="text-sm text-white truncate min-w-0 flex-1" title={candidate.filename}>
  103. {candidate.filename}
  104. </span>
  105. {plates && (
  106. <label className="flex items-center gap-1 text-xs text-bambu-gray shrink-0">
  107. <Layers className="w-3 h-3" />
  108. <select
  109. className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-1 py-0.5 text-xs text-white"
  110. value={plateByFile[candidate.id] ?? plates[0].index}
  111. onChange={(e) => onPlateChange(candidate.id, Number(e.target.value))}
  112. aria-label={t('printModal.variants.plateFor', { filename: candidate.filename })}
  113. >
  114. {plates.map((p) => (
  115. <option key={p.index} value={p.index}>
  116. {p.name || t('printModal.plateN', { n: p.index })}
  117. </option>
  118. ))}
  119. </select>
  120. </label>
  121. )}
  122. {!readOnly && (
  123. <div className="flex items-center gap-1 shrink-0">
  124. <button
  125. type="button"
  126. onClick={() => move(index, index - 1)}
  127. disabled={index === 0}
  128. className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
  129. aria-label={t('printModal.variants.moveUp')}
  130. >
  131. <ArrowUp className="w-3.5 h-3.5" />
  132. </button>
  133. <button
  134. type="button"
  135. onClick={() => move(index, index + 1)}
  136. disabled={index === candidates.length - 1}
  137. className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
  138. aria-label={t('printModal.variants.moveDown')}
  139. >
  140. <ArrowDown className="w-3.5 h-3.5" />
  141. </button>
  142. </div>
  143. )}
  144. </div>
  145. );
  146. })}
  147. </div>
  148. </div>
  149. );
  150. }