ProjectsPage.tsx 60 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409
  1. import { useState, useRef } from 'react';
  2. import { createPortal } from 'react-dom';
  3. import { useTranslation } from 'react-i18next';
  4. import { useNavigate } from 'react-router-dom';
  5. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  6. import {
  7. FolderKanban,
  8. Loader2,
  9. Plus,
  10. Trash2,
  11. Edit3,
  12. Archive,
  13. ListTodo,
  14. Package,
  15. Layers,
  16. FolderTree,
  17. Clock,
  18. CheckCircle2,
  19. AlertTriangle,
  20. ChevronDown,
  21. ChevronRight,
  22. MoreVertical,
  23. Download,
  24. Upload,
  25. ExternalLink,
  26. Image as ImageIcon,
  27. X,
  28. } from 'lucide-react';
  29. import { api } from '../api/client';
  30. import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
  31. import { Button } from '../components/Button';
  32. import { ConfirmModal } from '../components/ConfirmModal';
  33. import { useToast } from '../contexts/ToastContext';
  34. import { useAuth } from '../contexts/AuthContext';
  35. import { getCurrencySymbol } from '../utils/currency';
  36. import { eligibleParents } from '../utils/projectTree';
  37. import { useUiSettings } from '../hooks/useUiSettings';
  38. const PROJECT_COLORS = [
  39. '#ef4444', // red
  40. '#f97316', // orange
  41. '#eab308', // yellow
  42. '#22c55e', // green
  43. '#06b6d4', // cyan
  44. '#3b82f6', // blue
  45. '#8b5cf6', // violet
  46. '#ec4899', // pink
  47. '#6b7280', // gray
  48. ];
  49. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  50. interface ProjectModalProps {
  51. project?: ProjectListItem;
  52. onClose: () => void;
  53. onSave: (data: ProjectCreate | ProjectUpdate) => void;
  54. isLoading: boolean;
  55. currencySymbol: string;
  56. t: TFunction;
  57. }
  58. export function ProjectModal({ project, onClose, onSave, isLoading, currencySymbol, t }: ProjectModalProps) {
  59. const [name, setName] = useState(project?.name || '');
  60. const [description, setDescription] = useState(project?.description || '');
  61. const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
  62. const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
  63. const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
  64. const [targetSets, setTargetSets] = useState(project?.target_sets?.toString() || '');
  65. const [status, setStatus] = useState(project?.status || 'active');
  66. const [tags, setTags] = useState(project?.tags || '');
  67. const [dueDate, setDueDate] = useState(project?.due_date?.split('T')[0] || '');
  68. const [priority, setPriority] = useState(project?.priority || 'normal');
  69. const [budget, setBudget] = useState(project?.budget?.toString() || '');
  70. const [url, setUrl] = useState(project?.url || '');
  71. const [urlError, setUrlError] = useState<string | null>(null);
  72. const [parentId, setParentId] = useState<number | null>(project?.parent_id ?? null);
  73. const queryClient = useQueryClient();
  74. // Unfiltered on purpose: a completed or archived project is still a legal
  75. // parent, and the picker offering fewer options than the list does would be
  76. // hard to explain.
  77. const { data: allProjects } = useQuery({
  78. queryKey: ['projects', undefined],
  79. queryFn: () => api.getProjects(),
  80. });
  81. const parentOptions = eligibleParents(allProjects || [], project?.id);
  82. const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null);
  83. const coverFileInputRef = useRef<HTMLInputElement>(null);
  84. const [coverUploading, setCoverUploading] = useState(false);
  85. // Cache-bust the cover image URL when it changes mid-edit so the preview
  86. // refreshes after upload/remove.
  87. const [coverCacheKey, setCoverCacheKey] = useState(0);
  88. const handleCoverFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
  89. const file = e.target.files?.[0];
  90. if (!file || !project) return;
  91. setCoverUploading(true);
  92. try {
  93. const result = await api.uploadProjectCoverImage(project.id, file);
  94. setCoverImageFilename(result.filename);
  95. setCoverCacheKey((k) => k + 1);
  96. queryClient.invalidateQueries({ queryKey: ['projects'] });
  97. } catch {
  98. // Upload failed — leave existing cover image in place.
  99. } finally {
  100. setCoverUploading(false);
  101. if (coverFileInputRef.current) coverFileInputRef.current.value = '';
  102. }
  103. };
  104. const handleRemoveCover = async () => {
  105. if (!project) return;
  106. setCoverUploading(true);
  107. try {
  108. await api.deleteProjectCoverImage(project.id);
  109. setCoverImageFilename(null);
  110. setCoverCacheKey((k) => k + 1);
  111. queryClient.invalidateQueries({ queryKey: ['projects'] });
  112. } finally {
  113. setCoverUploading(false);
  114. }
  115. };
  116. const handleSubmit = (e: React.FormEvent) => {
  117. e.preventDefault();
  118. const trimmedUrl = url.trim();
  119. if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) {
  120. setUrlError(t('projects.urlInvalid'));
  121. return;
  122. }
  123. setUrlError(null);
  124. onSave({
  125. name: name.trim(),
  126. description: description.trim() || undefined,
  127. color,
  128. target_count: targetCount ? parseInt(targetCount, 10) : undefined,
  129. target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
  130. // Null clears the copies-per-file target on edit (#1897); undefined omits on create.
  131. target_sets: project ? (targetSets ? parseInt(targetSets, 10) : null) : (targetSets ? parseInt(targetSets, 10) : undefined),
  132. // Null clears the stored value on edit; undefined omits the key on create.
  133. // Sending undefined on edit would make an emptied field un-clearable.
  134. tags: project ? (tags.trim() || null) : (tags.trim() || undefined),
  135. due_date: project ? (dueDate || null) : (dueDate || undefined),
  136. priority,
  137. budget: budget.trim() ? parseFloat(budget) : null,
  138. // Pydantic accepts null to clear the URL; an empty string would fail the
  139. // http(s) prefix validator.
  140. url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
  141. // The API reads 0 as "remove the parent" — null would be indistinguishable
  142. // from the field having been omitted (#1264).
  143. parent_id: project ? (parentId ?? 0) : (parentId ?? undefined),
  144. ...(project && { status }),
  145. });
  146. };
  147. return (
  148. // max-h + flex column on the card + overflow on the fields wrapper so the
  149. // modal stays inside the viewport on short screens (#1642). Outer p-4 is
  150. // 1rem each side, hence the 2rem subtraction below.
  151. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  152. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary flex flex-col max-h-[calc(100vh-2rem)]">
  153. <div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  154. <h2 className="text-lg font-semibold text-white">
  155. {project ? t('projects.editProject') : t('projects.newProject')}
  156. </h2>
  157. </div>
  158. <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
  159. <div className="p-4 space-y-4 overflow-y-auto flex-1">
  160. <div>
  161. <label className="block text-sm font-medium text-white mb-1">
  162. {t('common.name')}
  163. </label>
  164. <input
  165. type="text"
  166. value={name}
  167. onChange={(e) => setName(e.target.value)}
  168. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  169. placeholder={t('projects.namePlaceholder')}
  170. required
  171. />
  172. </div>
  173. <div>
  174. <label className="block text-sm font-medium text-white mb-1">
  175. {t('common.description')}
  176. </label>
  177. <textarea
  178. value={description}
  179. onChange={(e) => setDescription(e.target.value)}
  180. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none"
  181. placeholder={t('projects.descriptionPlaceholder')}
  182. rows={2}
  183. />
  184. </div>
  185. {/* #1155: External URL */}
  186. <div>
  187. <label className="block text-sm font-medium text-white mb-1">
  188. {t('projects.urlLabel')}
  189. </label>
  190. <input
  191. type="url"
  192. value={url}
  193. onChange={(e) => { setUrl(e.target.value); if (urlError) setUrlError(null); }}
  194. className={`w-full bg-bambu-dark border rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none ${
  195. urlError ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'
  196. }`}
  197. placeholder={t('projects.urlPlaceholder')}
  198. maxLength={2048}
  199. />
  200. {urlError && <p className="text-xs text-red-600 dark:text-red-400 mt-1">{urlError}</p>}
  201. </div>
  202. {/* #1264: Nest this project under another one */}
  203. <div>
  204. <label className="block text-sm font-medium text-white mb-1">
  205. {t('projects.parentLabel')}
  206. </label>
  207. <select
  208. value={parentId ?? ''}
  209. onChange={(e) => setParentId(e.target.value ? parseInt(e.target.value, 10) : null)}
  210. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
  211. >
  212. <option value="">{t('projects.parentNone')}</option>
  213. {parentOptions.map((option) => (
  214. <option key={option.id} value={option.id}>
  215. {option.name}
  216. </option>
  217. ))}
  218. </select>
  219. <p className="text-xs text-bambu-gray mt-1">{t('projects.parentHint')}</p>
  220. </div>
  221. {/* #1155: Cover image — only available when editing an existing project,
  222. since uploading needs a project_id. New projects can add it after save. */}
  223. {project && (
  224. <div>
  225. <label className="block text-sm font-medium text-white mb-1">
  226. {t('projects.coverImageLabel')}
  227. </label>
  228. <div className="flex items-center gap-3">
  229. <div className="w-20 h-20 rounded bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden flex items-center justify-center flex-shrink-0">
  230. {coverImageFilename ? (
  231. <img
  232. src={api.getProjectCoverImageUrl(project.id, coverCacheKey)}
  233. alt={t('projects.coverImageAlt')}
  234. className="w-full h-full object-cover"
  235. />
  236. ) : (
  237. <ImageIcon className="w-6 h-6 text-bambu-gray" />
  238. )}
  239. </div>
  240. <div className="flex flex-col gap-2">
  241. <input
  242. ref={coverFileInputRef}
  243. type="file"
  244. accept="image/jpeg,image/png,image/gif,image/webp"
  245. onChange={handleCoverFileChange}
  246. className="hidden"
  247. />
  248. <Button
  249. type="button"
  250. variant="secondary"
  251. onClick={() => coverFileInputRef.current?.click()}
  252. disabled={coverUploading}
  253. >
  254. {coverUploading ? (
  255. <Loader2 className="w-4 h-4 animate-spin" />
  256. ) : (
  257. <Upload className="w-4 h-4 mr-1" />
  258. )}
  259. {coverImageFilename ? t('projects.coverImageReplace') : t('projects.coverImageUpload')}
  260. </Button>
  261. {coverImageFilename && (
  262. <Button
  263. type="button"
  264. variant="secondary"
  265. onClick={handleRemoveCover}
  266. disabled={coverUploading}
  267. >
  268. <X className="w-4 h-4 mr-1" />
  269. {t('projects.coverImageRemove')}
  270. </Button>
  271. )}
  272. </div>
  273. </div>
  274. </div>
  275. )}
  276. <div>
  277. <label className="block text-sm font-medium text-white mb-1">
  278. {t('projects.color')}
  279. </label>
  280. <div className="flex gap-2 flex-wrap">
  281. {PROJECT_COLORS.map((c) => (
  282. <button
  283. key={c}
  284. type="button"
  285. onClick={() => setColor(c)}
  286. className={`w-8 h-8 rounded-full transition-transform ${
  287. color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : ''
  288. }`}
  289. style={{ backgroundColor: c }}
  290. />
  291. ))}
  292. </div>
  293. </div>
  294. {/* Target Counts - Plates and Parts side by side */}
  295. <div className="grid grid-cols-2 gap-4">
  296. <div>
  297. <label className="block text-sm font-medium text-white mb-1">
  298. {t('projects.targetPlates')}
  299. </label>
  300. <input
  301. type="number"
  302. value={targetCount}
  303. onChange={(e) => setTargetCount(e.target.value)}
  304. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  305. placeholder={t('projects.targetPlatesPlaceholder')}
  306. min="1"
  307. />
  308. <p className="text-xs text-bambu-gray mt-1">{t('projects.targetPlatesHelp')}</p>
  309. </div>
  310. <div>
  311. <label className="block text-sm font-medium text-white mb-1">
  312. {t('projects.targetParts')}
  313. </label>
  314. <input
  315. type="number"
  316. value={targetPartsCount}
  317. onChange={(e) => setTargetPartsCount(e.target.value)}
  318. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  319. placeholder={t('projects.targetPartsPlaceholder')}
  320. min="1"
  321. />
  322. <p className="text-xs text-bambu-gray mt-1">{t('projects.targetPartsHelp')}</p>
  323. </div>
  324. </div>
  325. {/* Copies-per-file target (#1897) */}
  326. <div>
  327. <label className="block text-sm font-medium text-white mb-1">
  328. {t('projects.targetSets')}
  329. </label>
  330. <input
  331. type="number"
  332. value={targetSets}
  333. onChange={(e) => setTargetSets(e.target.value)}
  334. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  335. placeholder={t('projects.targetSetsPlaceholder')}
  336. min="1"
  337. />
  338. <p className="text-xs text-bambu-gray mt-1">{t('projects.targetSetsHelp')}</p>
  339. </div>
  340. {/* Tags */}
  341. <div>
  342. <label className="block text-sm font-medium text-white mb-1">
  343. {t('projects.tagsLabel')}
  344. </label>
  345. <input
  346. type="text"
  347. value={tags}
  348. onChange={(e) => setTags(e.target.value)}
  349. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  350. placeholder={t('projects.tagsPlaceholder')}
  351. />
  352. </div>
  353. {/* Due Date and Priority in a row */}
  354. <div className="grid grid-cols-2 gap-4">
  355. <div>
  356. <label className="block text-sm font-medium text-white mb-1">
  357. {t('projects.dueDate')}
  358. </label>
  359. <input
  360. type="date"
  361. value={dueDate}
  362. onChange={(e) => setDueDate(e.target.value)}
  363. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
  364. />
  365. </div>
  366. <div>
  367. <label className="block text-sm font-medium text-white mb-1">
  368. {t('projects.priority')}
  369. </label>
  370. <select
  371. value={priority}
  372. onChange={(e) => setPriority(e.target.value)}
  373. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
  374. >
  375. <option value="low">{t('projects.priorityLow')}</option>
  376. <option value="normal">{t('projects.priorityNormal')}</option>
  377. <option value="high">{t('projects.priorityHigh')}</option>
  378. <option value="urgent">{t('projects.priorityUrgent')}</option>
  379. </select>
  380. </div>
  381. </div>
  382. <div>
  383. <label className="block text-sm font-medium text-white mb-1">
  384. {t('projectDetail.cost.budget')}
  385. </label>
  386. <div className="relative">
  387. <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray pointer-events-none">
  388. {currencySymbol}
  389. </span>
  390. <input
  391. type="number"
  392. step="0.01"
  393. min="0"
  394. value={budget}
  395. onChange={(e) => setBudget(e.target.value)}
  396. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded pl-8 pr-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  397. placeholder="0.00"
  398. />
  399. </div>
  400. </div>
  401. {project && (
  402. <div>
  403. <label className="block text-sm font-medium text-white mb-1">
  404. {t('common.status')}
  405. </label>
  406. <select
  407. value={status}
  408. onChange={(e) => setStatus(e.target.value)}
  409. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
  410. >
  411. <option value="active">{t('projects.statusActive')}</option>
  412. <option value="completed">{t('projects.statusCompleted')}</option>
  413. <option value="archived">{t('projects.statusArchived')}</option>
  414. </select>
  415. </div>
  416. )}
  417. </div>
  418. {/* Sticky action footer — stays visible regardless of scroll
  419. position so Save/Cancel are always reachable on short screens
  420. (#1642). Buttons stay inside <form> for type="submit". */}
  421. <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  422. <Button type="button" variant="secondary" onClick={onClose}>
  423. {t('common.cancel')}
  424. </Button>
  425. <Button type="submit" disabled={!name.trim() || isLoading}>
  426. {isLoading ? (
  427. <Loader2 className="w-4 h-4 animate-spin" />
  428. ) : project ? (
  429. t('common.save')
  430. ) : (
  431. t('projects.create')
  432. )}
  433. </Button>
  434. </div>
  435. </form>
  436. </div>
  437. </div>
  438. );
  439. }
  440. /**
  441. * Cover thumbnail with portal-rendered hover preview (#1155 follow-up).
  442. *
  443. * Why a portal: the parent ``ProjectCard`` carries ``overflow-hidden`` for
  444. * its rounded-corner clipping and color accent bar; an in-tree popover
  445. * gets clipped by that and only the part that overlaps the card is
  446. * visible. Rendering the preview via ``createPortal`` to ``document.body``
  447. * escapes every ancestor clipping context, and ``position: fixed`` with
  448. * ``getBoundingClientRect()`` keeps it pinned next to the thumbnail
  449. * regardless of where the card sits in the grid.
  450. */
  451. function ProjectCoverThumbnail({
  452. projectId,
  453. altText,
  454. }: {
  455. projectId: number;
  456. altText: string;
  457. }) {
  458. const thumbRef = useRef<HTMLDivElement>(null);
  459. const [hovered, setHovered] = useState(false);
  460. const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
  461. const handleEnter = () => {
  462. if (!thumbRef.current) return;
  463. const rect = thumbRef.current.getBoundingClientRect();
  464. // Anchor the 384px preview just to the right of the thumbnail (8px gap).
  465. // Clamp ``top`` so the preview never overflows the viewport vertically;
  466. // similar story for ``left`` if the card is near the right edge — flip
  467. // to the LEFT side of the thumbnail in that case.
  468. const PREVIEW = 384;
  469. const GAP = 8;
  470. const vw = window.innerWidth;
  471. const vh = window.innerHeight;
  472. let left = rect.right + GAP;
  473. if (left + PREVIEW > vw - 8) {
  474. left = rect.left - PREVIEW - GAP;
  475. }
  476. let top = rect.top;
  477. if (top + PREVIEW > vh - 8) {
  478. top = vh - PREVIEW - 8;
  479. }
  480. if (top < 8) top = 8;
  481. setPos({ left, top });
  482. setHovered(true);
  483. };
  484. const handleLeave = () => setHovered(false);
  485. return (
  486. <div
  487. ref={thumbRef}
  488. className="relative flex-shrink-0"
  489. onMouseEnter={handleEnter}
  490. onMouseLeave={handleLeave}
  491. onClick={(e) => e.stopPropagation()}
  492. >
  493. <div className="w-10 h-10 rounded-lg overflow-hidden bg-bambu-dark border border-bambu-dark-tertiary">
  494. <img
  495. src={api.getProjectCoverImageUrl(projectId)}
  496. alt={altText}
  497. className="w-full h-full object-cover"
  498. loading="lazy"
  499. />
  500. </div>
  501. {hovered && pos &&
  502. createPortal(
  503. <div
  504. className="fixed z-[100] w-96 h-96 rounded-lg overflow-hidden border border-bambu-dark-tertiary shadow-2xl bg-bambu-dark pointer-events-none"
  505. style={{ left: pos.left, top: pos.top }}
  506. aria-hidden="true"
  507. >
  508. <img
  509. src={api.getProjectCoverImageUrl(projectId)}
  510. alt=""
  511. className="w-full h-full object-contain"
  512. loading="lazy"
  513. />
  514. </div>,
  515. document.body,
  516. )}
  517. </div>
  518. );
  519. }
  520. interface ProjectCardProps {
  521. project: ProjectListItem;
  522. parentName?: string; // #1264 — resolved by the caller, which holds the whole list
  523. onClick: () => void;
  524. onEdit: () => void;
  525. onDelete: () => void;
  526. hasPermission: (permission: Permission) => boolean;
  527. t: TFunction;
  528. }
  529. function ProjectCard({ project, parentName, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
  530. // Plates progress: archive_count / target_count
  531. const platesProgressPercent = project.target_count
  532. ? Math.round((project.archive_count / project.target_count) * 100)
  533. : 0;
  534. // Parts progress: completed_count / target_parts_count
  535. const partsProgressPercent = project.target_parts_count
  536. ? Math.round((project.completed_count / project.target_parts_count) * 100)
  537. : 0;
  538. const isCompleted = project.status === 'completed';
  539. const isArchived = project.status === 'archived';
  540. const [showActions, setShowActions] = useState(false);
  541. // Status icon and color
  542. const getStatusConfig = () => {
  543. if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };
  544. if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
  545. if (project.queue_count > 0) return { icon: Clock, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-400/10' };
  546. return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
  547. };
  548. const statusConfig = getStatusConfig();
  549. return (
  550. <div
  551. className="group relative bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary hover:border-bambu-green/50 hover:shadow-lg hover:shadow-bambu-green/5 transition-all duration-300 cursor-pointer overflow-hidden"
  552. onClick={onClick}
  553. >
  554. {/* Color accent bar with glow */}
  555. <div
  556. className="absolute top-0 left-0 w-1.5 h-full"
  557. style={{
  558. backgroundColor: project.color || '#6b7280',
  559. boxShadow: `0 0 12px ${project.color || '#6b7280'}40`
  560. }}
  561. />
  562. <div className="p-5 pl-6">
  563. {/* Header */}
  564. <div className="flex items-start justify-between mb-4">
  565. <div className="flex items-center gap-3 min-w-0 flex-1">
  566. {project.cover_image_filename ? (
  567. // #1155: cover photo replaces the status-icon box. The thumbnail
  568. // itself stays small so the card layout doesn't shift; on hover
  569. // a portal-rendered 384×384 preview pops out beside the card
  570. // so the user can identify the print without navigating into
  571. // the project view. The portal is needed because ProjectCard's
  572. // own ``overflow-hidden`` (for rounded corners) clips any
  573. // in-tree popover before it can extend outside the card.
  574. <ProjectCoverThumbnail
  575. projectId={project.id}
  576. altText={t('projects.coverImageAlt')}
  577. />
  578. ) : (
  579. <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
  580. <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
  581. </div>
  582. )}
  583. <div className="min-w-0 flex-1">
  584. <div className="flex items-center gap-2 flex-wrap">
  585. <h3 className="font-semibold text-white truncate">{project.name}</h3>
  586. {project.url && (
  587. <a
  588. href={project.url}
  589. target="_blank"
  590. rel="noopener noreferrer"
  591. onClick={(e) => e.stopPropagation()}
  592. title={project.url}
  593. aria-label={t('projects.openExternalUrl')}
  594. className="inline-flex items-center justify-center w-6 h-6 rounded bg-bambu-dark border border-bambu-dark-tertiary text-bambu-green hover:bg-bambu-green/10 hover:border-bambu-green transition-colors flex-shrink-0"
  595. >
  596. <ExternalLink className="w-3.5 h-3.5" />
  597. </a>
  598. )}
  599. {project.target_parts_count ? (
  600. <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
  601. partsProgressPercent >= 100
  602. ? 'bg-bambu-green/20 text-bambu-green'
  603. : 'bg-bambu-dark text-bambu-gray'
  604. }`}>
  605. {project.completed_count}/{project.target_parts_count} {t('projects.parts')}
  606. </span>
  607. ) : project.target_count ? (
  608. <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
  609. platesProgressPercent >= 100
  610. ? 'bg-bambu-green/20 text-bambu-green'
  611. : 'bg-bambu-dark text-bambu-gray'
  612. }`}>
  613. {project.archive_count}/{project.target_count} {t('projects.plates')}
  614. </span>
  615. ) : project.completed_count > 0 ? (
  616. <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
  617. {project.completed_count} {t('projects.parts')}
  618. </span>
  619. ) : null}
  620. {isCompleted && (
  621. <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
  622. {t('projects.done')}
  623. </span>
  624. )}
  625. {isArchived && (
  626. <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
  627. {t('projects.statusArchived')}
  628. </span>
  629. )}
  630. {/* #1264: without these, eight sub-projects of one programme
  631. look like eight unrelated projects in the grid. */}
  632. {project.child_count > 0 && (
  633. <span
  634. className="text-xs bg-bambu-dark text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap inline-flex items-center gap-1"
  635. title={t('projects.subProjectCount', { count: project.child_count })}
  636. >
  637. <FolderTree className="w-3 h-3" />
  638. {project.child_count}
  639. </span>
  640. )}
  641. </div>
  642. {parentName && (
  643. <p className="text-xs text-bambu-gray mt-1 flex items-center gap-1">
  644. <Layers className="w-3 h-3 flex-shrink-0" />
  645. <span className="truncate">{t('projects.partOf', { name: parentName })}</span>
  646. </p>
  647. )}
  648. {project.description && (
  649. <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
  650. {project.description}
  651. </p>
  652. )}
  653. {/* Filament materials/colors */}
  654. {project.archives && project.archives.length > 0 && (() => {
  655. // Flatten comma-separated materials and deduplicate
  656. const allMaterials = project.archives
  657. .map(a => a.filament_type)
  658. .filter(Boolean)
  659. .flatMap(m => (m as string).split(',').map(s => s.trim()))
  660. .filter(Boolean);
  661. const materials = [...new Set(allMaterials)];
  662. // Flatten comma-separated colors and deduplicate
  663. const allColors = project.archives
  664. .map(a => a.filament_color)
  665. .filter(Boolean)
  666. .flatMap(c => (c as string).split(',').map(s => s.trim()))
  667. .filter(c => c.startsWith('#') || /^[0-9A-Fa-f]{6}$/.test(c));
  668. const colors = [...new Set(allColors)];
  669. if (materials.length === 0 && colors.length === 0) return null;
  670. return (
  671. <div className="flex items-center gap-2 mt-1.5">
  672. {/* Material types as text badges */}
  673. {materials.slice(0, 3).map((mat) => (
  674. <span key={mat} className="text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded">
  675. {mat}
  676. </span>
  677. ))}
  678. {/* Colors as swatches */}
  679. {colors.length > 0 && (
  680. <div className="flex items-center gap-0.5">
  681. {colors.slice(0, 5).map((col) => (
  682. <div
  683. key={col}
  684. className="w-3 h-3 rounded-full border border-black/20"
  685. style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}
  686. title={col}
  687. />
  688. ))}
  689. {colors.length > 5 && (
  690. <span className="text-[10px] text-bambu-gray ml-0.5">+{colors.length - 5}</span>
  691. )}
  692. </div>
  693. )}
  694. </div>
  695. );
  696. })()}
  697. </div>
  698. </div>
  699. {/* Actions menu */}
  700. <div className="relative" onClick={(e) => e.stopPropagation()}>
  701. <button
  702. className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
  703. onClick={() => setShowActions(!showActions)}
  704. >
  705. <MoreVertical className="w-4 h-4" />
  706. </button>
  707. {showActions && (
  708. <>
  709. <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
  710. <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
  711. <button
  712. className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
  713. hasPermission('projects:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  714. }`}
  715. onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
  716. disabled={!hasPermission('projects:update')}
  717. title={!hasPermission('projects:update') ? t('projects.noEditPermission') : undefined}
  718. >
  719. <Edit3 className="w-4 h-4" />
  720. {t('common.edit')}
  721. </button>
  722. <button
  723. className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
  724. hasPermission('projects:delete') ? 'text-red-600 dark:text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  725. }`}
  726. onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
  727. disabled={!hasPermission('projects:delete')}
  728. title={!hasPermission('projects:delete') ? t('projects.noDeletePermission') : undefined}
  729. >
  730. <Trash2 className="w-4 h-4" />
  731. {t('common.delete')}
  732. </button>
  733. </div>
  734. </>
  735. )}
  736. </div>
  737. </div>
  738. {/* Progress section - show for all projects */}
  739. <div className="mb-4">
  740. {(project.target_count || project.target_parts_count) ? (
  741. <div className="space-y-3">
  742. {/* Plates progress */}
  743. {project.target_count && (
  744. <div>
  745. <div className="flex items-center justify-between text-xs mb-1">
  746. <span className="text-bambu-gray">{t('projects.plates')}</span>
  747. <span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
  748. {project.archive_count} / {project.target_count}
  749. </span>
  750. </div>
  751. <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
  752. <div
  753. className="h-full transition-all duration-500 ease-out rounded-full relative"
  754. style={{
  755. width: `${Math.min(platesProgressPercent, 100)}%`,
  756. background: platesProgressPercent >= 100
  757. ? 'linear-gradient(90deg, #22c55e, #4ade80)'
  758. : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
  759. boxShadow: `0 0 8px ${platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
  760. }}
  761. />
  762. </div>
  763. </div>
  764. )}
  765. {/* Parts progress */}
  766. {project.target_parts_count && (
  767. <div>
  768. <div className="flex items-center justify-between text-xs mb-1">
  769. <span className="text-bambu-gray">{t('projects.parts')}</span>
  770. <span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
  771. {project.completed_count} / {project.target_parts_count}
  772. </span>
  773. </div>
  774. <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
  775. <div
  776. className="h-full transition-all duration-500 ease-out rounded-full relative"
  777. style={{
  778. width: `${Math.min(partsProgressPercent, 100)}%`,
  779. background: partsProgressPercent >= 100
  780. ? 'linear-gradient(90deg, #22c55e, #4ade80)'
  781. : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
  782. boxShadow: `0 0 8px ${partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
  783. }}
  784. />
  785. </div>
  786. </div>
  787. )}
  788. {/* Failed count */}
  789. {project.failed_count > 0 && (
  790. <div className="text-xs text-red-600 dark:text-red-400">
  791. {project.failed_count} {t('projects.failed')}
  792. </div>
  793. )}
  794. </div>
  795. ) : project.completed_count > 0 || project.failed_count > 0 ? (
  796. <div className="flex items-center gap-4 text-xs">
  797. {project.completed_count > 0 && (
  798. <div className="flex items-center gap-1.5 text-bambu-gray">
  799. <Archive className="w-3.5 h-3.5" />
  800. <span>{project.completed_count} {t('projects.completed')}</span>
  801. </div>
  802. )}
  803. {project.failed_count > 0 && (
  804. <div className="flex items-center gap-1.5 text-red-600 dark:text-red-400">
  805. <AlertTriangle className="w-3.5 h-3.5" />
  806. <span>{project.failed_count} {t('projects.failed')}</span>
  807. </div>
  808. )}
  809. {project.queue_count > 0 && (
  810. <div className="flex items-center gap-1.5 text-blue-600 dark:text-blue-400">
  811. <Clock className="w-3.5 h-3.5" />
  812. <span>{project.queue_count} {t('projects.inQueue')}</span>
  813. </div>
  814. )}
  815. </div>
  816. ) : (
  817. <div className="text-xs text-bambu-gray/60 italic">
  818. {t('projects.noPrintsYet')}
  819. </div>
  820. )}
  821. </div>
  822. {/* Archive thumbnails - compact 4-column grid */}
  823. {project.archives && project.archives.length > 0 && (
  824. <div className="mb-4">
  825. <div className="grid grid-cols-4 gap-1.5">
  826. {project.archives.slice(0, 4).map((archive) => (
  827. <div
  828. key={archive.id}
  829. className="relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary"
  830. title={archive.print_name || 'Unknown'}
  831. >
  832. {archive.thumbnail_path ? (
  833. <img
  834. src={api.getArchiveThumbnail(archive.id)}
  835. alt={archive.print_name || ''}
  836. className="w-full h-full object-cover"
  837. />
  838. ) : (
  839. <div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
  840. <Package className="w-6 h-6" />
  841. </div>
  842. )}
  843. {archive.status === 'failed' && (
  844. <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
  845. <AlertTriangle className="w-4 h-4 text-white" />
  846. </div>
  847. )}
  848. </div>
  849. ))}
  850. </div>
  851. {project.archive_count > 4 && (
  852. <p className="text-xs text-bambu-gray mt-1.5 text-center">
  853. {t('common.more', { count: project.archive_count - 4 })}
  854. </p>
  855. )}
  856. </div>
  857. )}
  858. {/* Stats footer */}
  859. <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
  860. <div className="flex items-center gap-4 text-xs text-bambu-gray">
  861. <div className="flex items-center gap-1.5" title={t('projects.printJobs')}>
  862. <Layers className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
  863. <span>{project.archive_count} {t('projects.plates')}</span>
  864. </div>
  865. <div className="flex items-center gap-1.5" title={t('projects.partsPrinted')}>
  866. <Package className="w-3.5 h-3.5 text-bambu-green" />
  867. <span>{project.completed_count} {t('projects.parts')}</span>
  868. </div>
  869. {project.failed_count > 0 && (
  870. <div className="flex items-center gap-1.5 text-red-600 dark:text-red-400" title={t('projects.failedParts')}>
  871. <AlertTriangle className="w-3.5 h-3.5" />
  872. <span>{project.failed_count}</span>
  873. </div>
  874. )}
  875. {project.queue_count > 0 && (
  876. <div className="flex items-center gap-1.5 text-yellow-600 dark:text-yellow-400" title={t('projects.inQueue')}>
  877. <ListTodo className="w-3.5 h-3.5" />
  878. <span>{project.queue_count}</span>
  879. </div>
  880. )}
  881. </div>
  882. <ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
  883. </div>
  884. </div>
  885. </div>
  886. );
  887. }
  888. export function ProjectsPage() {
  889. const { t } = useTranslation();
  890. const navigate = useNavigate();
  891. const queryClient = useQueryClient();
  892. const { showToast } = useToast();
  893. const { hasPermission } = useAuth();
  894. const [showModal, setShowModal] = useState(false);
  895. const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
  896. const [statusFilter, setStatusFilter] = useState<string>('active');
  897. const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
  898. // Sub-project groups fold away (#2991). Every sub-project at every depth was
  899. // drawn expanded with no way to shut one, so a three-level hierarchy over a
  900. // couple of hundred archives turned this page into a very long scroll. The
  901. // pill next to the filter tabs sets the default and is remembered; the
  902. // chevron on a group deviates from it for that one parent.
  903. const [collapseSubProjects, setCollapseSubProjects] = useState(
  904. () => localStorage.getItem('projects-collapse-subprojects') === 'true',
  905. );
  906. const [subProjectOverrides, setSubProjectOverrides] = useState<Record<number, boolean>>({});
  907. const toggleCollapseDefault = () => {
  908. const next = !collapseSubProjects;
  909. setCollapseSubProjects(next);
  910. // Every deviation was made against the old default, so keeping them would
  911. // leave groups sitting open right after the user asked for everything
  912. // shut. Same bargain the file manager's folder tree makes, where flipping
  913. // its Collapse toggle remounts the tree and drops each folder's own state.
  914. setSubProjectOverrides({});
  915. localStorage.setItem('projects-collapse-subprojects', String(next));
  916. };
  917. const { data: settings } = useUiSettings();
  918. const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
  919. const { data: projects, isLoading } = useQuery({
  920. queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
  921. queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
  922. });
  923. // Parent names come from the unfiltered list, so a sub-project still says
  924. // what it belongs to when the status filter has hidden its parent (#1264).
  925. // Same query key the parent picker uses — one request shared between them,
  926. // and the same one as above whenever the filter is 'all'.
  927. const { data: allProjects } = useQuery({
  928. queryKey: ['projects', undefined],
  929. queryFn: () => api.getProjects(),
  930. });
  931. const namesById = new Map((allProjects || []).map((p) => [p.id, p.name]));
  932. // A sub-project is drawn inside its parent's group rather than as another
  933. // card somewhere in the grid — two cards that belong together are not
  934. // something a caption can convey when they sit columns apart (#1264).
  935. const visible = projects || [];
  936. const visibleIds = new Set(visible.map((p) => p.id));
  937. const childrenByParent = new Map<number, ProjectListItem[]>();
  938. for (const project of visible) {
  939. // Parent hidden by the status filter: the child has nothing to nest under
  940. // here, so it stays a top-level card and keeps its "part of" caption.
  941. if (project.parent_id !== null && visibleIds.has(project.parent_id)) {
  942. const siblings = childrenByParent.get(project.parent_id) || [];
  943. siblings.push(project);
  944. childrenByParent.set(project.parent_id, siblings);
  945. }
  946. }
  947. // Everything drawn at the top of the grid, in order. Roots first, then any
  948. // project the roots cannot reach: a database written before the API refused
  949. // A -> B -> A can still hold a cycle, and a cycle has no root, so filtering
  950. // on "has no visible parent" alone would drop every project in it off the
  951. // page. Each entry point marks its whole branch as drawn, so nothing appears
  952. // twice either.
  953. const drawn = new Set<number>();
  954. const markDrawn = (id: number) => {
  955. if (drawn.has(id)) return;
  956. drawn.add(id);
  957. for (const child of childrenByParent.get(id) || []) markDrawn(child.id);
  958. };
  959. const topLevel: ProjectListItem[] = [];
  960. for (const project of visible) {
  961. if (project.parent_id !== null && visibleIds.has(project.parent_id)) continue;
  962. topLevel.push(project);
  963. markDrawn(project.id);
  964. }
  965. for (const project of visible) {
  966. if (drawn.has(project.id)) continue;
  967. topLevel.push(project);
  968. markDrawn(project.id);
  969. }
  970. const renderProjectTree = (project: ProjectListItem, depth: number, seen: Set<number>) => {
  971. const card = (
  972. <ProjectCard
  973. project={project}
  974. // Only when it is detached from its parent — inside the group the
  975. // nesting is already visible, and the caption would be noise.
  976. parentName={depth === 0 && project.parent_id !== null ? namesById.get(project.parent_id) : undefined}
  977. onClick={() => handleClick(project)}
  978. onEdit={() => handleEdit(project)}
  979. onDelete={() => handleDeleteClick(project.id)}
  980. hasPermission={hasPermission}
  981. t={t}
  982. />
  983. );
  984. // Drop any child that is already an ancestor on this path. A database
  985. // written before the API refused A -> B -> A can still hold a cycle, and
  986. // following one would draw the same project over and over.
  987. const descended = new Set(seen).add(project.id);
  988. const children = (childrenByParent.get(project.id) || []).filter((c) => !descended.has(c.id));
  989. if (children.length === 0) return <div key={project.id}>{card}</div>;
  990. const expanded = subProjectOverrides[project.id] ?? !collapseSubProjects;
  991. return (
  992. // A shut group is a card like any other, so it takes one grid cell
  993. // instead of a whole row of its own — folding a deep tree that still
  994. // spent a full-width row per parent would only halve the scrolling.
  995. <div key={project.id} className={expanded ? 'col-span-full space-y-4' : 'space-y-4'}>
  996. {card}
  997. <div
  998. className="ml-4 md:ml-8 pl-4 md:pl-6 border-l-2 rounded-l space-y-4"
  999. style={{ borderColor: project.color || '#6b7280' }}
  1000. >
  1001. {/* The caption is the toggle rather than the card above it: clicking
  1002. a card opens the project, and taking that over would surprise
  1003. every user who has no sub-projects to fold. */}
  1004. <button
  1005. type="button"
  1006. onClick={() => setSubProjectOverrides((prev) => ({ ...prev, [project.id]: !expanded }))}
  1007. aria-expanded={expanded}
  1008. title={expanded ? t('projects.collapseSubProjects') : t('projects.expandSubProjects')}
  1009. className="text-xs uppercase tracking-wide text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5 max-w-full min-w-0"
  1010. >
  1011. {expanded
  1012. ? <ChevronDown className="w-3.5 h-3.5 flex-shrink-0" />
  1013. : <ChevronRight className="w-3.5 h-3.5 flex-shrink-0" />}
  1014. <FolderTree className="w-3.5 h-3.5 flex-shrink-0" />
  1015. {/* Truncated because a shut group lives in one grid column, where
  1016. a long project name would otherwise run out of the card. */}
  1017. <span className="truncate">{t('projects.subProjectsOf', { name: project.name })}</span>
  1018. {/* Counted from what is actually nested here, not from the card's
  1019. own badge: the API counts sub-projects across every status on
  1020. purpose, so that badge can say 2 where only one card is behind
  1021. this chevron. A count that disagrees with what unfolds is
  1022. worse than no count. */}
  1023. <span className="px-1.5 py-0.5 rounded-full bg-bambu-dark normal-case tracking-normal flex-shrink-0">
  1024. {children.length}
  1025. </span>
  1026. </button>
  1027. {expanded && (
  1028. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1029. {children.map((child) => renderProjectTree(child, depth + 1, descended))}
  1030. </div>
  1031. )}
  1032. </div>
  1033. </div>
  1034. );
  1035. };
  1036. const createMutation = useMutation({
  1037. mutationFn: (data: ProjectCreate) => api.createProject(data),
  1038. onSuccess: () => {
  1039. queryClient.invalidateQueries({ queryKey: ['projects'] });
  1040. setShowModal(false);
  1041. showToast(t('projects.toast.created'), 'success');
  1042. },
  1043. onError: (error: Error) => {
  1044. showToast(error.message, 'error');
  1045. },
  1046. });
  1047. const updateMutation = useMutation({
  1048. mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>
  1049. api.updateProject(id, data),
  1050. onSuccess: () => {
  1051. queryClient.invalidateQueries({ queryKey: ['projects'] });
  1052. setShowModal(false);
  1053. setEditingProject(undefined);
  1054. showToast(t('projects.toast.updated'), 'success');
  1055. },
  1056. onError: (error: Error) => {
  1057. showToast(error.message, 'error');
  1058. },
  1059. });
  1060. const deleteMutation = useMutation({
  1061. mutationFn: (id: number) => api.deleteProject(id),
  1062. onSuccess: () => {
  1063. setDeleteConfirm(null);
  1064. showToast(t('projects.toast.deleted'), 'success');
  1065. // Reload to refresh the list (React Query cache invalidation not working reliably)
  1066. setTimeout(() => window.location.reload(), 100);
  1067. },
  1068. onError: (error: Error) => {
  1069. setDeleteConfirm(null);
  1070. showToast(error.message, 'error');
  1071. },
  1072. });
  1073. const importMutation = useMutation({
  1074. mutationFn: (data: ProjectImport) => api.importProject(data),
  1075. onSuccess: () => {
  1076. queryClient.invalidateQueries({ queryKey: ['projects'] });
  1077. showToast(t('projects.toast.imported'), 'success');
  1078. },
  1079. onError: (error: Error) => {
  1080. showToast(error.message, 'error');
  1081. },
  1082. });
  1083. const fileInputRef = useRef<HTMLInputElement>(null);
  1084. const handleExportAll = async () => {
  1085. try {
  1086. // Export all projects as JSON (metadata only, no files)
  1087. const allProjects = await api.getProjects();
  1088. const exports = await Promise.all(
  1089. allProjects.map(async (p) => {
  1090. const exported = await api.exportProjectJson(p.id);
  1091. return exported;
  1092. })
  1093. );
  1094. const blob = new Blob([JSON.stringify(exports, null, 2)], { type: 'application/json' });
  1095. const url = URL.createObjectURL(blob);
  1096. const a = document.createElement('a');
  1097. a.href = url;
  1098. a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
  1099. a.click();
  1100. URL.revokeObjectURL(url);
  1101. showToast(t('projects.toast.exported'), 'success');
  1102. } catch (error) {
  1103. showToast((error as Error).message, 'error');
  1104. }
  1105. };
  1106. const handleImportClick = () => {
  1107. fileInputRef.current?.click();
  1108. };
  1109. const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
  1110. const file = e.target.files?.[0];
  1111. if (!file) return;
  1112. try {
  1113. const filename = file.name.toLowerCase();
  1114. if (filename.endsWith('.zip')) {
  1115. // ZIP file: upload via file endpoint
  1116. await api.importProjectFile(file);
  1117. queryClient.invalidateQueries({ queryKey: ['projects'] });
  1118. showToast(t('projects.toast.imported'), 'success');
  1119. } else {
  1120. // JSON file: parse and handle bulk or single import
  1121. const text = await file.text();
  1122. const data = JSON.parse(text);
  1123. // Handle both single project and array of projects
  1124. const projectsToImport = Array.isArray(data) ? data : [data];
  1125. for (const project of projectsToImport) {
  1126. await importMutation.mutateAsync(project);
  1127. }
  1128. if (projectsToImport.length > 1) {
  1129. showToast(t('projects.toast.multipleImported', { count: projectsToImport.length }), 'success');
  1130. }
  1131. }
  1132. } catch (error) {
  1133. showToast(`${t('projects.toast.importFailed')}: ${(error as Error).message}`, 'error');
  1134. }
  1135. // Reset file input
  1136. e.target.value = '';
  1137. };
  1138. const handleSave = (data: ProjectCreate | ProjectUpdate) => {
  1139. if (editingProject) {
  1140. updateMutation.mutate({ id: editingProject.id, data });
  1141. } else {
  1142. createMutation.mutate(data as ProjectCreate);
  1143. }
  1144. };
  1145. const handleEdit = (project: ProjectListItem) => {
  1146. setEditingProject(project);
  1147. setShowModal(true);
  1148. };
  1149. const handleClick = (project: ProjectListItem) => {
  1150. // Navigate to project detail page
  1151. navigate(`/projects/${project.id}`);
  1152. };
  1153. const handleDeleteClick = (id: number) => {
  1154. setDeleteConfirm(id);
  1155. };
  1156. const handleDeleteConfirm = () => {
  1157. if (deleteConfirm !== null) {
  1158. deleteMutation.mutate(deleteConfirm);
  1159. }
  1160. };
  1161. // Count projects by status for filter badges. Counted from the unfiltered
  1162. // list, not the one on screen: `projects` holds only the selected status, so
  1163. // every other tab counted zero and lost its badge entirely -- a fleet of
  1164. // thirty finished projects showed "Completed" bare while Active read 5
  1165. // (#2888).
  1166. const projectCounts = allProjects?.reduce((acc, p) => {
  1167. acc[p.status] = (acc[p.status] || 0) + 1;
  1168. acc.all = (acc.all || 0) + 1;
  1169. return acc;
  1170. }, {} as Record<string, number>) || {};
  1171. return (
  1172. <div className="p-4 md:p-8 space-y-8">
  1173. {/* Hidden file input for import */}
  1174. <input
  1175. ref={fileInputRef}
  1176. type="file"
  1177. accept=".json,.zip"
  1178. onChange={handleFileChange}
  1179. className="hidden"
  1180. />
  1181. {/* Header */}
  1182. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
  1183. <div>
  1184. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  1185. <FolderKanban className="w-7 h-7 text-bambu-green" />
  1186. {t('projects.title')}
  1187. </h1>
  1188. <p className="text-bambu-gray mt-1">
  1189. {t('projects.subtitle')}
  1190. </p>
  1191. </div>
  1192. <div className="flex gap-2">
  1193. <Button
  1194. variant="secondary"
  1195. onClick={handleImportClick}
  1196. disabled={!hasPermission('projects:create')}
  1197. title={!hasPermission('projects:create') ? t('projects.noImportPermission') : t('projects.importProject')}
  1198. >
  1199. <Upload className="w-4 h-4 mr-2" />
  1200. {t('projects.import')}
  1201. </Button>
  1202. <Button
  1203. variant="secondary"
  1204. onClick={handleExportAll}
  1205. disabled={!hasPermission('projects:read')}
  1206. title={!hasPermission('projects:read') ? t('projects.noExportPermission') : t('projects.exportAll')}
  1207. >
  1208. <Download className="w-4 h-4 mr-2" />
  1209. {t('projects.export')}
  1210. </Button>
  1211. <Button
  1212. onClick={() => setShowModal(true)}
  1213. className="sm:w-auto w-full"
  1214. disabled={!hasPermission('projects:create')}
  1215. title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
  1216. >
  1217. <Plus className="w-4 h-4 mr-2" />
  1218. {t('projects.newProject')}
  1219. </Button>
  1220. </div>
  1221. </div>
  1222. {/* Filter tabs */}
  1223. <div className="flex flex-wrap items-center gap-3">
  1224. <div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
  1225. {[
  1226. { key: 'active', label: t('projects.statusActive'), icon: Clock },
  1227. { key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
  1228. { key: 'archived', label: t('projects.statusArchived'), icon: Archive },
  1229. { key: 'all', label: t('common.all'), icon: FolderKanban },
  1230. ].map(({ key, label, icon: Icon }) => (
  1231. <button
  1232. key={key}
  1233. onClick={() => setStatusFilter(key)}
  1234. className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
  1235. statusFilter === key
  1236. ? 'bg-bambu-card text-white shadow-sm'
  1237. : 'text-bambu-gray hover:text-white'
  1238. }`}
  1239. >
  1240. <Icon className="w-4 h-4" />
  1241. <span>{label}</span>
  1242. {projectCounts[key] > 0 && (
  1243. <span className={`text-xs px-1.5 py-0.5 rounded-full ${
  1244. statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
  1245. }`}>
  1246. {projectCounts[key]}
  1247. </span>
  1248. )}
  1249. </button>
  1250. ))}
  1251. </div>
  1252. {/* Only where something can actually be folded -- with no nesting under
  1253. the current filter this would be a control that does nothing. */}
  1254. {childrenByParent.size > 0 && (
  1255. <button
  1256. type="button"
  1257. onClick={toggleCollapseDefault}
  1258. aria-pressed={collapseSubProjects}
  1259. title={collapseSubProjects ? t('projects.expandSubProjects') : t('projects.collapseSubProjects')}
  1260. className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors ${
  1261. collapseSubProjects
  1262. ? 'bg-bambu-green/20 text-bambu-green'
  1263. : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
  1264. }`}
  1265. >
  1266. <FolderTree className="w-4 h-4" />
  1267. {t('common.collapse')}
  1268. </button>
  1269. )}
  1270. </div>
  1271. {/* Content */}
  1272. {isLoading ? (
  1273. <div className="flex items-center justify-center py-20">
  1274. <div className="flex flex-col items-center gap-3">
  1275. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  1276. <p className="text-sm text-bambu-gray">{t('projects.loading')}</p>
  1277. </div>
  1278. </div>
  1279. ) : projects?.length === 0 ? (
  1280. <div className="flex flex-col items-center justify-center py-20 px-4">
  1281. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  1282. <FolderKanban className="w-12 h-12 text-bambu-gray/50" />
  1283. </div>
  1284. <h3 className="text-lg font-medium text-white mb-2">
  1285. {statusFilter === 'all' ? t('projects.noProjects') : t('projects.noProjectsFiltered', { status: statusFilter })}
  1286. </h3>
  1287. <p className="text-bambu-gray text-center max-w-md mb-6">
  1288. {statusFilter === 'all'
  1289. ? t('projects.createFirst')
  1290. : t('projects.noProjectsFilteredHelp', { status: statusFilter })
  1291. }
  1292. </p>
  1293. {statusFilter === 'all' && (
  1294. <Button
  1295. onClick={() => setShowModal(true)}
  1296. disabled={!hasPermission('projects:create')}
  1297. title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
  1298. >
  1299. <Plus className="w-4 h-4 mr-2" />
  1300. {t('projects.createFirstButton')}
  1301. </Button>
  1302. )}
  1303. </div>
  1304. ) : (
  1305. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
  1306. {topLevel.map((project) => renderProjectTree(project, 0, new Set()))}
  1307. </div>
  1308. )}
  1309. {/* Delete Confirmation Modal */}
  1310. {deleteConfirm !== null && (
  1311. <ConfirmModal
  1312. title={t('projects.deleteProject')}
  1313. message={t('projects.deleteConfirm')}
  1314. confirmText={t('projects.deleteProject')}
  1315. variant="danger"
  1316. onConfirm={handleDeleteConfirm}
  1317. onCancel={() => setDeleteConfirm(null)}
  1318. />
  1319. )}
  1320. {/* Modal */}
  1321. {showModal && (
  1322. <ProjectModal
  1323. project={editingProject}
  1324. onClose={() => {
  1325. setShowModal(false);
  1326. setEditingProject(undefined);
  1327. }}
  1328. onSave={handleSave}
  1329. isLoading={createMutation.isPending || updateMutation.isPending}
  1330. currencySymbol={currencySymbol}
  1331. t={t}
  1332. />
  1333. )}
  1334. </div>
  1335. );
  1336. }