ProfileDefaultsSettings.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import { useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { ChevronRight, Loader2, Search, Sparkles, Trash2, Wand2 } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { ProfileDefault } from '../api/client';
  7. import { useToast } from '../contexts/ToastContext';
  8. import { Card, CardContent, CardHeader } from './Card';
  9. import { Button } from './Button';
  10. import { ConfirmModal } from './ConfirmModal';
  11. /**
  12. * Settings > Filament > Profile defaults (#1326): what new spools of each
  13. * product start with. A default is saved from a spool's Printers tab ("Save as
  14. * default"), which is also how one is changed: this card lists them, applies
  15. * one to the spools already in stock, and deletes one. There can be one per
  16. * product and colour, so each is a single line and its profiles fold out.
  17. */
  18. export function ProfileDefaultsSettings() {
  19. const { t } = useTranslation();
  20. const { showToast } = useToast();
  21. const queryClient = useQueryClient();
  22. const [deleting, setDeleting] = useState<ProfileDefault | null>(null);
  23. const [search, setSearch] = useState('');
  24. const [expanded, setExpanded] = useState<Set<number>>(new Set());
  25. const { data: defaults = [], isLoading } = useQuery({
  26. queryKey: ['profile-defaults'],
  27. queryFn: api.getProfileDefaults,
  28. });
  29. const { data: printers = [] } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters });
  30. // Applying writes to the spools of the inventory in use.
  31. const { data: spoolmanSettings } = useQuery({
  32. queryKey: ['spoolman-settings'],
  33. queryFn: api.getSpoolmanSettings,
  34. staleTime: 5 * 60 * 1000,
  35. });
  36. const spoolmanMode = spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
  37. const printerName = (id: number) => printers.find((p) => p.id === id)?.name ?? `#${id}`;
  38. const applyMutation = useMutation({
  39. mutationFn: (row: ProfileDefault) => api.applyProfileDefault(row.id, spoolmanMode),
  40. onSuccess: async ({ updated }) => {
  41. showToast(t('inventory.profileDefaults.applied', { count: updated }), 'success');
  42. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  43. await queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
  44. },
  45. onError: () => showToast(t('inventory.profileDefaults.applyFailed'), 'error'),
  46. });
  47. // One request per default, one after the other. The order does not matter: each
  48. // spool resolves its own colour's values, and nothing a spool has is overwritten.
  49. const applyAllMutation = useMutation({
  50. mutationFn: async (rows: ProfileDefault[]) => {
  51. let updated = 0;
  52. let failed = 0;
  53. for (const row of rows) {
  54. try {
  55. updated += (await api.applyProfileDefault(row.id, spoolmanMode)).updated;
  56. } catch {
  57. failed += 1;
  58. }
  59. }
  60. return { updated, failed };
  61. },
  62. onSuccess: async ({ updated, failed }) => {
  63. if (failed > 0) showToast(t('inventory.profileDefaults.applyAllFailed', { failed, updated }), 'error');
  64. else showToast(t('inventory.profileDefaults.applied', { count: updated }), 'success');
  65. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  66. await queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
  67. },
  68. });
  69. const applying = applyMutation.isPending || applyAllMutation.isPending;
  70. const deleteMutation = useMutation({
  71. mutationFn: (row: ProfileDefault) => api.deleteProfileDefault(row.id),
  72. onSuccess: async () => {
  73. setDeleting(null);
  74. await queryClient.invalidateQueries({ queryKey: ['profile-defaults'] });
  75. },
  76. onError: () => {
  77. setDeleting(null);
  78. showToast(t('inventory.profileDefaults.deleteFailed'), 'error');
  79. },
  80. });
  81. const productOf = (row: ProfileDefault) =>
  82. [row.brand, row.material, row.subtype].filter((part) => part.trim()).join(' ');
  83. const colourOf = (row: ProfileDefault) => row.color_name || t('inventory.profileDefaults.allColours');
  84. // A list that can hold one row per product and colour in stock: sorted, searchable,
  85. // one line each with the details folded away.
  86. const query = search.trim().toLowerCase();
  87. const rows = defaults
  88. .map((row) => ({ row, product: productOf(row), colour: colourOf(row) }))
  89. .filter(
  90. ({ row, product, colour }) =>
  91. !query ||
  92. `${product} ${colour} ${row.slicer_filament_name || row.slicer_filament || ''}`.toLowerCase().includes(query),
  93. )
  94. .sort((a, b) => a.product.localeCompare(b.product) || a.colour.localeCompare(b.colour));
  95. const toggle = (id: number) =>
  96. setExpanded((prev) => {
  97. const next = new Set(prev);
  98. if (next.has(id)) next.delete(id);
  99. else next.add(id);
  100. return next;
  101. });
  102. return (
  103. <Card id="card-profiledefaults">
  104. <CardHeader>
  105. <div className="flex items-center justify-between gap-2">
  106. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  107. <Sparkles className="w-5 h-5 text-bambu-green" />
  108. {t('inventory.profileDefaults.title')}
  109. {defaults.length > 0 && <span className="text-sm font-normal text-bambu-gray">({defaults.length})</span>}
  110. </h2>
  111. {defaults.length > 0 && (
  112. <Button
  113. size="sm"
  114. variant="secondary"
  115. onClick={() => applyAllMutation.mutate(defaults)}
  116. // Not before the inventory mode is known: it picks the spools written to.
  117. disabled={applying || spoolmanSettings === undefined}
  118. title={t('inventory.profileDefaults.applyAllHelp')}
  119. >
  120. {applyAllMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
  121. {t('inventory.profileDefaults.applyAll')}
  122. </Button>
  123. )}
  124. </div>
  125. </CardHeader>
  126. <CardContent className="space-y-3">
  127. <p className="text-sm text-bambu-gray">{t('inventory.profileDefaults.description')}</p>
  128. {isLoading ? (
  129. <div className="flex justify-center py-4">
  130. <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
  131. </div>
  132. ) : defaults.length === 0 ? (
  133. <p className="text-sm text-bambu-gray italic">{t('inventory.profileDefaults.empty')}</p>
  134. ) : (
  135. <>
  136. <div className="relative">
  137. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  138. <input
  139. type="text"
  140. className="w-full pl-10 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  141. placeholder={t('inventory.profileDefaults.search')}
  142. aria-label={t('common.search')}
  143. value={search}
  144. onChange={(e) => setSearch(e.target.value)}
  145. />
  146. </div>
  147. <ul className="max-h-[480px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg divide-y divide-bambu-dark-tertiary">
  148. {rows.length === 0 ? (
  149. <li className="px-3 py-4 text-center text-sm text-bambu-gray">{t('inventory.profileDefaults.noMatch')}</li>
  150. ) : (
  151. rows.map(({ row, product, colour }) => {
  152. const open = expanded.has(row.id);
  153. const preset = row.slicer_filament_name || row.slicer_filament;
  154. return (
  155. <li key={row.id} className="text-sm">
  156. <div className="flex items-center gap-2 px-2 py-1.5 hover:bg-bambu-dark">
  157. <button
  158. type="button"
  159. onClick={() => toggle(row.id)}
  160. aria-expanded={open}
  161. aria-label={open ? t('common.collapse') : t('common.expand')}
  162. className="flex min-w-0 flex-1 items-center gap-2 text-left"
  163. >
  164. <ChevronRight
  165. className={`w-4 h-4 shrink-0 text-bambu-gray transition-transform ${open ? 'rotate-90' : ''}`}
  166. />
  167. <span className="truncate text-white">{product}</span>
  168. <span className="shrink-0 text-xs text-bambu-gray">{colour}</span>
  169. {preset && (
  170. <span className="hidden min-w-0 truncate text-xs text-bambu-gray md:inline">· {preset}</span>
  171. )}
  172. <span className="ml-auto hidden shrink-0 gap-1 text-xs text-bambu-gray sm:flex">
  173. {row.presets.length > 0 && (
  174. <span className="rounded bg-bambu-dark-tertiary px-1.5">
  175. {t('inventory.profileDefaults.presetsCount', { count: row.presets.length })}
  176. </span>
  177. )}
  178. {row.k_profiles.length > 0 && (
  179. <span className="rounded bg-bambu-dark-tertiary px-1.5">
  180. {t('inventory.profileDefaults.kProfilesCount', { count: row.k_profiles.length })}
  181. </span>
  182. )}
  183. </span>
  184. </button>
  185. <button
  186. type="button"
  187. onClick={() => applyMutation.mutate(row)}
  188. // Not before the inventory mode is known: it picks the spools written to.
  189. disabled={applying || spoolmanSettings === undefined}
  190. title={`${t('inventory.profileDefaults.apply')}: ${t('inventory.profileDefaults.applyHelp')}`}
  191. aria-label={t('inventory.profileDefaults.apply')}
  192. className="shrink-0 rounded p-1.5 text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-bambu-green disabled:opacity-50"
  193. >
  194. {applyMutation.isPending && applyMutation.variables?.id === row.id ? (
  195. <Loader2 className="w-4 h-4 animate-spin" />
  196. ) : (
  197. <Wand2 className="w-4 h-4" />
  198. )}
  199. </button>
  200. <button
  201. type="button"
  202. onClick={() => setDeleting(row)}
  203. title={t('common.delete')}
  204. aria-label={t('common.delete')}
  205. className="shrink-0 rounded p-1.5 text-bambu-gray hover:bg-red-500/20 hover:text-red-400"
  206. >
  207. <Trash2 className="w-4 h-4" />
  208. </button>
  209. </div>
  210. {open && (
  211. <ul className="space-y-0.5 pb-2 pl-8 pr-3 text-xs text-bambu-gray">
  212. {preset && (
  213. <li>
  214. {t('inventory.filamentPreset')}: {preset}
  215. </li>
  216. )}
  217. {row.presets.map((p) => (
  218. <li key={`${p.printer_model}:${p.nozzle_diameter}`}>
  219. {p.printer_model}
  220. {p.nozzle_diameter ? ` ${p.nozzle_diameter} mm` : ''}: {p.slicer_filament_name || p.slicer_filament}
  221. </li>
  222. ))}
  223. {row.k_profiles.map((k) => (
  224. <li key={`${k.printer_id}:${k.extruder}:${k.nozzle_diameter}`}>
  225. {`${printerName(k.printer_id)} ${k.nozzle_diameter ?? '0.4'} mm: ${k.name || '-'} K=${k.k_value.toFixed(3)}`}
  226. {(k.extruder ?? 0) > 0 ? ` (${t('inventory.leftNozzle')})` : ''}
  227. </li>
  228. ))}
  229. </ul>
  230. )}
  231. </li>
  232. );
  233. })
  234. )}
  235. </ul>
  236. </>
  237. )}
  238. </CardContent>
  239. {deleting && (
  240. <ConfirmModal
  241. title={t('inventory.profileDefaults.deleteTitle')}
  242. message={t('inventory.profileDefaults.deleteMessage', {
  243. product: `${productOf(deleting)} ${colourOf(deleting)}`,
  244. })}
  245. confirmText={t('common.delete')}
  246. variant="danger"
  247. isLoading={deleteMutation.isPending}
  248. onConfirm={() => deleteMutation.mutate(deleting)}
  249. onCancel={() => setDeleting(null)}
  250. />
  251. )}
  252. </Card>
  253. );
  254. }