ScheduleOptions.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { useState, useEffect, useRef } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Calendar, Clock, Hand, Power, Layers, Code, ListOrdered } from 'lucide-react';
  4. import type { ScheduleOptionsProps, ScheduleType } from './types';
  5. import {
  6. formatDateInput,
  7. formatTimeInput,
  8. parseDateInput,
  9. parseTimeInput,
  10. getDatePlaceholder,
  11. getTimePlaceholder,
  12. toDateTimeLocalValue,
  13. type DateFormat,
  14. type TimeFormat,
  15. } from '../../utils/date';
  16. /**
  17. * Schedule options component for queue items.
  18. * Includes schedule type (ASAP/Queue/Schedule), datetime picker,
  19. * and options for require previous success and auto power off.
  20. */
  21. export function ScheduleOptionsPanel({
  22. options,
  23. onChange,
  24. dateFormat = 'system',
  25. timeFormat = 'system',
  26. canControlPrinter = true,
  27. showStagger = false,
  28. printerCount = 0,
  29. hasGcodeSnippets = false,
  30. }: ScheduleOptionsProps) {
  31. const { t } = useTranslation();
  32. const [dateValue, setDateValue] = useState('');
  33. const [timeValue, setTimeValue] = useState('');
  34. const [isDateValid, setIsDateValid] = useState(true);
  35. const [isTimeValid, setIsTimeValid] = useState(true);
  36. const hiddenInputRef = useRef<HTMLInputElement>(null);
  37. const isInitializedRef = useRef(false);
  38. // Initialize or sync from options.scheduledTime
  39. useEffect(() => {
  40. if (options.scheduleType !== 'scheduled') {
  41. isInitializedRef.current = false;
  42. return;
  43. }
  44. // Initialize with default time (now + 1 hour) or from existing value
  45. if (!isInitializedRef.current) {
  46. isInitializedRef.current = true;
  47. let date: Date;
  48. if (options.scheduledTime) {
  49. date = new Date(options.scheduledTime);
  50. if (isNaN(date.getTime())) {
  51. date = new Date();
  52. date.setHours(date.getHours() + 1, 0, 0, 0);
  53. }
  54. } else {
  55. date = new Date();
  56. date.setHours(date.getHours() + 1, 0, 0, 0);
  57. // Set initial value
  58. onChange({ ...options, scheduledTime: toDateTimeLocalValue(date) });
  59. }
  60. setDateValue(formatDateInput(date, dateFormat as DateFormat));
  61. setTimeValue(formatTimeInput(date, timeFormat as TimeFormat));
  62. setIsDateValid(true);
  63. setIsTimeValid(true);
  64. }
  65. }, [options.scheduleType, options.scheduledTime, dateFormat, timeFormat, onChange, options]);
  66. const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
  67. onChange({
  68. ...options,
  69. scheduleType,
  70. requireManualStart: scheduleType === 'queue' ? options.requireManualStart : false,
  71. });
  72. };
  73. const updateScheduledTime = (newDateValue: string, newTimeValue: string) => {
  74. const parsedDate = parseDateInput(newDateValue, dateFormat as DateFormat);
  75. const parsedTime = parseTimeInput(newTimeValue);
  76. setIsDateValid(!!parsedDate);
  77. setIsTimeValid(!!parsedTime);
  78. if (parsedDate && parsedTime) {
  79. parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0);
  80. const now = new Date();
  81. if (parsedDate > now) {
  82. onChange({ ...options, scheduledTime: toDateTimeLocalValue(parsedDate) });
  83. }
  84. }
  85. };
  86. const handleDateChange = (value: string) => {
  87. setDateValue(value);
  88. updateScheduledTime(value, timeValue);
  89. };
  90. const handleTimeChange = (value: string) => {
  91. setTimeValue(value);
  92. updateScheduledTime(dateValue, value);
  93. };
  94. // Handle calendar picker selection
  95. const handleCalendarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  96. const value = e.target.value;
  97. if (value) {
  98. const date = new Date(value);
  99. if (!isNaN(date.getTime())) {
  100. setDateValue(formatDateInput(date, dateFormat as DateFormat));
  101. setTimeValue(formatTimeInput(date, timeFormat as TimeFormat));
  102. setIsDateValid(true);
  103. setIsTimeValid(true);
  104. onChange({ ...options, scheduledTime: value });
  105. }
  106. }
  107. };
  108. const openCalendar = () => {
  109. hiddenInputRef.current?.showPicker();
  110. };
  111. return (
  112. <div className="space-y-4">
  113. {/* Schedule type */}
  114. <div>
  115. <label className="block text-sm text-bambu-gray mb-2">{t('printModal.whenToPrint')}</label>
  116. <div className="flex gap-2">
  117. <button
  118. type="button"
  119. className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
  120. options.scheduleType === 'asap'
  121. ? 'bg-bambu-green border-bambu-green text-white'
  122. : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  123. }`}
  124. onClick={() => handleScheduleTypeChange('asap')}
  125. >
  126. <Clock className="w-4 h-4" />
  127. {t('printModal.asap')}
  128. </button>
  129. <button
  130. type="button"
  131. className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
  132. options.scheduleType === 'queue'
  133. ? 'bg-bambu-green border-bambu-green text-white'
  134. : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  135. }`}
  136. onClick={() => handleScheduleTypeChange('queue')}
  137. >
  138. <ListOrdered className="w-4 h-4" />
  139. {t('printModal.queue')}
  140. </button>
  141. <button
  142. type="button"
  143. className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
  144. options.scheduleType === 'scheduled'
  145. ? 'bg-bambu-green border-bambu-green text-white'
  146. : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  147. }`}
  148. onClick={() => handleScheduleTypeChange('scheduled')}
  149. >
  150. <Calendar className="w-4 h-4" />
  151. {t('printModal.schedule')}
  152. </button>
  153. </div>
  154. </div>
  155. {/* Scheduled time input */}
  156. {options.scheduleType === 'scheduled' && (
  157. <div>
  158. <label className="block text-sm text-bambu-gray mb-1">{t('printModal.dateTime')}</label>
  159. <div className="flex gap-2">
  160. {/* Date input */}
  161. <div className="flex-1 relative">
  162. <input
  163. type="text"
  164. className={`w-full px-3 py-2 pr-10 bg-bambu-dark border rounded-lg text-white focus:outline-none ${
  165. isDateValid
  166. ? 'border-bambu-dark-tertiary focus:border-bambu-green'
  167. : 'border-red-500'
  168. }`}
  169. value={dateValue}
  170. onChange={(e) => handleDateChange(e.target.value)}
  171. placeholder={getDatePlaceholder(dateFormat as DateFormat)}
  172. />
  173. <button
  174. type="button"
  175. onClick={openCalendar}
  176. className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  177. title={t('printModal.openCalendar')}
  178. >
  179. <Calendar className="w-4 h-4" />
  180. </button>
  181. {/* Hidden datetime-local anchored here so the native picker opens near the date field */}
  182. <input
  183. ref={hiddenInputRef}
  184. type="datetime-local"
  185. className="absolute top-0 left-0 w-0 h-0 opacity-0 pointer-events-none"
  186. value={options.scheduledTime}
  187. onChange={handleCalendarChange}
  188. tabIndex={-1}
  189. />
  190. </div>
  191. {/* Time input */}
  192. <div className="w-32">
  193. <input
  194. type="text"
  195. className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:outline-none ${
  196. isTimeValid
  197. ? 'border-bambu-dark-tertiary focus:border-bambu-green'
  198. : 'border-red-500'
  199. }`}
  200. value={timeValue}
  201. onChange={(e) => handleTimeChange(e.target.value)}
  202. placeholder={getTimePlaceholder(timeFormat as TimeFormat)}
  203. />
  204. </div>
  205. </div>
  206. {(!isDateValid || !isTimeValid) && (
  207. <p className="mt-1 text-xs text-red-700 dark:text-red-400">
  208. {t('printModal.invalidDateTime')}
  209. </p>
  210. )}
  211. </div>
  212. )}
  213. {/* Manual start */}
  214. {options.scheduleType === 'queue' && (
  215. <div className="flex items-center gap-2">
  216. <input
  217. type="checkbox"
  218. id="requireManualStart"
  219. checked={options.requireManualStart}
  220. onChange={(e) => onChange({ ...options, requireManualStart: e.target.checked })}
  221. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  222. />
  223. <label htmlFor="requireManualStart" className="text-sm flex items-center gap-1 text-bambu-gray">
  224. <Hand className="w-3.5 h-3.5" />
  225. {t('printModal.requireManualStart')}
  226. </label>
  227. </div>
  228. )}
  229. {/* Require previous success */}
  230. <div className="flex items-center gap-2">
  231. <input
  232. type="checkbox"
  233. id="requirePrevious"
  234. checked={options.requirePreviousSuccess}
  235. onChange={(e) => onChange({ ...options, requirePreviousSuccess: e.target.checked })}
  236. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  237. />
  238. <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
  239. {t('printModal.requirePreviousSuccess')}
  240. </label>
  241. </div>
  242. {/* Auto power off */}
  243. <div className="flex items-center gap-2">
  244. <input
  245. type="checkbox"
  246. id="autoOffAfter"
  247. checked={options.autoOffAfter}
  248. onChange={(e) => onChange({ ...options, autoOffAfter: e.target.checked })}
  249. disabled={!canControlPrinter}
  250. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green disabled:opacity-50"
  251. />
  252. <label htmlFor="autoOffAfter" className={`text-sm flex items-center gap-1 ${canControlPrinter ? 'text-bambu-gray' : 'text-bambu-gray/50'}`}>
  253. <Power className="w-3.5 h-3.5" />
  254. {t('printModal.autoOffAfter')}
  255. </label>
  256. </div>
  257. {/* G-code injection */}
  258. {hasGcodeSnippets && (
  259. <div className="flex items-center gap-2">
  260. <input
  261. type="checkbox"
  262. id="gcodeInjection"
  263. checked={options.gcodeInjection}
  264. onChange={(e) => onChange({ ...options, gcodeInjection: e.target.checked })}
  265. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  266. />
  267. <label htmlFor="gcodeInjection" className="text-sm flex items-center gap-1 text-bambu-gray">
  268. <Code className="w-3.5 h-3.5" />
  269. {t('printModal.gcodeInjection', 'Inject auto-print G-code')}
  270. </label>
  271. </div>
  272. )}
  273. {/* Stagger start */}
  274. {showStagger && options.scheduleType !== 'queue' && (
  275. <div className="space-y-3">
  276. <div className="flex items-center gap-2">
  277. <input
  278. type="checkbox"
  279. id="staggerEnabled"
  280. checked={options.staggerEnabled}
  281. onChange={(e) => onChange({ ...options, staggerEnabled: e.target.checked })}
  282. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  283. />
  284. <label htmlFor="staggerEnabled" className="text-sm flex items-center gap-1 text-bambu-gray">
  285. <Layers className="w-3.5 h-3.5" />
  286. {t('printModal.staggerPrinterStarts', 'Stagger printer starts')}
  287. </label>
  288. </div>
  289. {options.staggerEnabled && (
  290. <div className="ml-6 space-y-3">
  291. <div className="flex gap-3">
  292. <div className="flex-1">
  293. <label className="block text-xs text-bambu-gray mb-1">{t('printModal.staggerGroupSize', 'Group size')}</label>
  294. <input
  295. type="number"
  296. min={1}
  297. max={printerCount}
  298. value={options.staggerGroupSize}
  299. onChange={(e) => onChange({ ...options, staggerGroupSize: Math.max(1, parseInt(e.target.value) || 1) })}
  300. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  301. />
  302. </div>
  303. <div className="flex-1">
  304. <label className="block text-xs text-bambu-gray mb-1">{t('printModal.staggerInterval', 'Interval (min)')}</label>
  305. <input
  306. type="number"
  307. min={1}
  308. max={60}
  309. value={options.staggerIntervalMinutes}
  310. onChange={(e) => onChange({ ...options, staggerIntervalMinutes: Math.max(1, parseInt(e.target.value) || 1) })}
  311. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  312. />
  313. </div>
  314. </div>
  315. {printerCount > 0 && (() => {
  316. const groupCount = Math.ceil(printerCount / options.staggerGroupSize);
  317. const lastGroupSize = printerCount % options.staggerGroupSize;
  318. const totalMinutes = (groupCount - 1) * options.staggerIntervalMinutes;
  319. return (
  320. <p className="text-xs text-bambu-gray">
  321. {t('printModal.staggerPreview', '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min', {
  322. printers: printerCount,
  323. groups: groupCount,
  324. size: options.staggerGroupSize,
  325. interval: options.staggerIntervalMinutes,
  326. })}
  327. {lastGroupSize !== 0 && options.staggerGroupSize < printerCount
  328. ? ` (${t('printModal.staggerLastGroup', 'last group: {{count}}', { count: lastGroupSize })})`
  329. : ''}
  330. {groupCount > 1
  331. ? ` (${t('printModal.staggerTotal', 'total: {{minutes}} min', { minutes: totalMinutes })})`
  332. : ''}
  333. </p>
  334. );
  335. })()}
  336. </div>
  337. )}
  338. </div>
  339. )}
  340. {/* Help text */}
  341. <p className="text-xs text-bambu-gray">
  342. {options.scheduleType === 'asap'
  343. ? t('printModal.helpAsap')
  344. : options.scheduleType === 'scheduled'
  345. ? t('printModal.helpSchedule')
  346. : t('printModal.helpQueue')}
  347. </p>
  348. </div>
  349. );
  350. }