ProjectsPage.tsx 60 KB

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