ProjectsPage.tsx 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. import { useState, useRef } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useNavigate } from 'react-router-dom';
  4. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  5. import {
  6. FolderKanban,
  7. Loader2,
  8. Plus,
  9. Trash2,
  10. Edit3,
  11. Archive,
  12. ListTodo,
  13. Package,
  14. Layers,
  15. Clock,
  16. CheckCircle2,
  17. AlertTriangle,
  18. ChevronRight,
  19. MoreVertical,
  20. Download,
  21. Upload,
  22. } from 'lucide-react';
  23. import { api } from '../api/client';
  24. import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
  25. import { Button } from '../components/Button';
  26. import { ConfirmModal } from '../components/ConfirmModal';
  27. import { useToast } from '../contexts/ToastContext';
  28. import { useAuth } from '../contexts/AuthContext';
  29. import { getCurrencySymbol } from '../utils/currency';
  30. const PROJECT_COLORS = [
  31. '#ef4444', // red
  32. '#f97316', // orange
  33. '#eab308', // yellow
  34. '#22c55e', // green
  35. '#06b6d4', // cyan
  36. '#3b82f6', // blue
  37. '#8b5cf6', // violet
  38. '#ec4899', // pink
  39. '#6b7280', // gray
  40. ];
  41. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  42. interface ProjectModalProps {
  43. project?: ProjectListItem;
  44. onClose: () => void;
  45. onSave: (data: ProjectCreate | ProjectUpdate) => void;
  46. isLoading: boolean;
  47. currencySymbol: string;
  48. t: TFunction;
  49. }
  50. export function ProjectModal({ project, onClose, onSave, isLoading, currencySymbol, t }: ProjectModalProps) {
  51. const [name, setName] = useState(project?.name || '');
  52. const [description, setDescription] = useState(project?.description || '');
  53. const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
  54. const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
  55. const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
  56. const [status, setStatus] = useState(project?.status || 'active');
  57. const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
  58. const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
  59. const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
  60. const [budget, setBudget] = useState(project?.budget?.toString() || '');
  61. const handleSubmit = (e: React.FormEvent) => {
  62. e.preventDefault();
  63. onSave({
  64. name: name.trim(),
  65. description: description.trim() || undefined,
  66. color,
  67. target_count: targetCount ? parseInt(targetCount, 10) : undefined,
  68. target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
  69. tags: tags.trim() || undefined,
  70. due_date: dueDate || undefined,
  71. priority,
  72. budget: budget.trim() ? parseFloat(budget) : null,
  73. ...(project && { status }),
  74. });
  75. };
  76. return (
  77. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  78. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
  79. <div className="p-4 border-b border-bambu-dark-tertiary">
  80. <h2 className="text-lg font-semibold text-white">
  81. {project ? t('projects.editProject') : t('projects.newProject')}
  82. </h2>
  83. </div>
  84. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  85. <div>
  86. <label className="block text-sm font-medium text-white mb-1">
  87. {t('common.name')}
  88. </label>
  89. <input
  90. type="text"
  91. value={name}
  92. onChange={(e) => setName(e.target.value)}
  93. 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"
  94. placeholder={t('projects.namePlaceholder')}
  95. required
  96. />
  97. </div>
  98. <div>
  99. <label className="block text-sm font-medium text-white mb-1">
  100. {t('common.description')}
  101. </label>
  102. <textarea
  103. value={description}
  104. onChange={(e) => setDescription(e.target.value)}
  105. 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"
  106. placeholder={t('projects.descriptionPlaceholder')}
  107. rows={2}
  108. />
  109. </div>
  110. <div>
  111. <label className="block text-sm font-medium text-white mb-1">
  112. {t('projects.color')}
  113. </label>
  114. <div className="flex gap-2 flex-wrap">
  115. {PROJECT_COLORS.map((c) => (
  116. <button
  117. key={c}
  118. type="button"
  119. onClick={() => setColor(c)}
  120. className={`w-8 h-8 rounded-full transition-transform ${
  121. color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : ''
  122. }`}
  123. style={{ backgroundColor: c }}
  124. />
  125. ))}
  126. </div>
  127. </div>
  128. {/* Target Counts - Plates and Parts side by side */}
  129. <div className="grid grid-cols-2 gap-4">
  130. <div>
  131. <label className="block text-sm font-medium text-white mb-1">
  132. {t('projects.targetPlates')}
  133. </label>
  134. <input
  135. type="number"
  136. value={targetCount}
  137. onChange={(e) => setTargetCount(e.target.value)}
  138. 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"
  139. placeholder={t('projects.targetPlatesPlaceholder')}
  140. min="1"
  141. />
  142. <p className="text-xs text-bambu-gray mt-1">{t('projects.targetPlatesHelp')}</p>
  143. </div>
  144. <div>
  145. <label className="block text-sm font-medium text-white mb-1">
  146. {t('projects.targetParts')}
  147. </label>
  148. <input
  149. type="number"
  150. value={targetPartsCount}
  151. onChange={(e) => setTargetPartsCount(e.target.value)}
  152. 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"
  153. placeholder={t('projects.targetPartsPlaceholder')}
  154. min="1"
  155. />
  156. <p className="text-xs text-bambu-gray mt-1">{t('projects.targetPartsHelp')}</p>
  157. </div>
  158. </div>
  159. {/* Tags */}
  160. <div>
  161. <label className="block text-sm font-medium text-white mb-1">
  162. {t('projects.tagsLabel')}
  163. </label>
  164. <input
  165. type="text"
  166. value={tags}
  167. onChange={(e) => setTags(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.tagsPlaceholder')}
  170. />
  171. </div>
  172. {/* Due Date and Priority in a row */}
  173. <div className="grid grid-cols-2 gap-4">
  174. <div>
  175. <label className="block text-sm font-medium text-white mb-1">
  176. {t('projects.dueDate')}
  177. </label>
  178. <input
  179. type="date"
  180. value={dueDate}
  181. onChange={(e) => setDueDate(e.target.value)}
  182. 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"
  183. />
  184. </div>
  185. <div>
  186. <label className="block text-sm font-medium text-white mb-1">
  187. {t('projects.priority')}
  188. </label>
  189. <select
  190. value={priority}
  191. onChange={(e) => setPriority(e.target.value)}
  192. 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"
  193. >
  194. <option value="low">{t('projects.priorityLow')}</option>
  195. <option value="normal">{t('projects.priorityNormal')}</option>
  196. <option value="high">{t('projects.priorityHigh')}</option>
  197. <option value="urgent">{t('projects.priorityUrgent')}</option>
  198. </select>
  199. </div>
  200. </div>
  201. <div>
  202. <label className="block text-sm font-medium text-white mb-1">
  203. {t('projectDetail.cost.budget')}
  204. </label>
  205. <div className="relative">
  206. <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray pointer-events-none">
  207. {currencySymbol}
  208. </span>
  209. <input
  210. type="number"
  211. step="0.01"
  212. min="0"
  213. value={budget}
  214. onChange={(e) => setBudget(e.target.value)}
  215. 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"
  216. placeholder="0.00"
  217. />
  218. </div>
  219. </div>
  220. {project && (
  221. <div>
  222. <label className="block text-sm font-medium text-white mb-1">
  223. {t('common.status')}
  224. </label>
  225. <select
  226. value={status}
  227. onChange={(e) => setStatus(e.target.value)}
  228. 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"
  229. >
  230. <option value="active">{t('projects.statusActive')}</option>
  231. <option value="completed">{t('projects.statusCompleted')}</option>
  232. <option value="archived">{t('projects.statusArchived')}</option>
  233. </select>
  234. </div>
  235. )}
  236. <div className="flex justify-end gap-2 pt-2">
  237. <Button type="button" variant="secondary" onClick={onClose}>
  238. {t('common.cancel')}
  239. </Button>
  240. <Button type="submit" disabled={!name.trim() || isLoading}>
  241. {isLoading ? (
  242. <Loader2 className="w-4 h-4 animate-spin" />
  243. ) : project ? (
  244. t('common.save')
  245. ) : (
  246. t('projects.create')
  247. )}
  248. </Button>
  249. </div>
  250. </form>
  251. </div>
  252. </div>
  253. );
  254. }
  255. interface ProjectCardProps {
  256. project: ProjectListItem;
  257. onClick: () => void;
  258. onEdit: () => void;
  259. onDelete: () => void;
  260. hasPermission: (permission: Permission) => boolean;
  261. t: TFunction;
  262. }
  263. function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
  264. // Plates progress: archive_count / target_count
  265. const platesProgressPercent = project.target_count
  266. ? Math.round((project.archive_count / project.target_count) * 100)
  267. : 0;
  268. // Parts progress: completed_count / target_parts_count
  269. const partsProgressPercent = project.target_parts_count
  270. ? Math.round((project.completed_count / project.target_parts_count) * 100)
  271. : 0;
  272. const isCompleted = project.status === 'completed';
  273. const isArchived = project.status === 'archived';
  274. const [showActions, setShowActions] = useState(false);
  275. // Status icon and color
  276. const getStatusConfig = () => {
  277. if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };
  278. if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
  279. if (project.queue_count > 0) return { icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' };
  280. return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
  281. };
  282. const statusConfig = getStatusConfig();
  283. return (
  284. <div
  285. 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"
  286. onClick={onClick}
  287. >
  288. {/* Color accent bar with glow */}
  289. <div
  290. className="absolute top-0 left-0 w-1.5 h-full"
  291. style={{
  292. backgroundColor: project.color || '#6b7280',
  293. boxShadow: `0 0 12px ${project.color || '#6b7280'}40`
  294. }}
  295. />
  296. <div className="p-5 pl-6">
  297. {/* Header */}
  298. <div className="flex items-start justify-between mb-4">
  299. <div className="flex items-center gap-3 min-w-0 flex-1">
  300. <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
  301. <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
  302. </div>
  303. <div className="min-w-0 flex-1">
  304. <div className="flex items-center gap-2 flex-wrap">
  305. <h3 className="font-semibold text-white truncate">{project.name}</h3>
  306. {project.target_parts_count ? (
  307. <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
  308. partsProgressPercent >= 100
  309. ? 'bg-bambu-green/20 text-bambu-green'
  310. : 'bg-bambu-dark text-bambu-gray'
  311. }`}>
  312. {project.completed_count}/{project.target_parts_count} {t('projects.parts')}
  313. </span>
  314. ) : project.target_count ? (
  315. <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
  316. platesProgressPercent >= 100
  317. ? 'bg-bambu-green/20 text-bambu-green'
  318. : 'bg-bambu-dark text-bambu-gray'
  319. }`}>
  320. {project.archive_count}/{project.target_count} {t('projects.plates')}
  321. </span>
  322. ) : project.completed_count > 0 ? (
  323. <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
  324. {project.completed_count} {t('projects.parts')}
  325. </span>
  326. ) : null}
  327. {isCompleted && (
  328. <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
  329. {t('projects.done')}
  330. </span>
  331. )}
  332. {isArchived && (
  333. <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
  334. {t('projects.statusArchived')}
  335. </span>
  336. )}
  337. </div>
  338. {project.description && (
  339. <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
  340. {project.description}
  341. </p>
  342. )}
  343. {/* Filament materials/colors */}
  344. {project.archives && project.archives.length > 0 && (() => {
  345. // Flatten comma-separated materials and deduplicate
  346. const allMaterials = project.archives
  347. .map(a => a.filament_type)
  348. .filter(Boolean)
  349. .flatMap(m => (m as string).split(',').map(s => s.trim()))
  350. .filter(Boolean);
  351. const materials = [...new Set(allMaterials)];
  352. // Flatten comma-separated colors and deduplicate
  353. const allColors = project.archives
  354. .map(a => a.filament_color)
  355. .filter(Boolean)
  356. .flatMap(c => (c as string).split(',').map(s => s.trim()))
  357. .filter(c => c.startsWith('#') || /^[0-9A-Fa-f]{6}$/.test(c));
  358. const colors = [...new Set(allColors)];
  359. if (materials.length === 0 && colors.length === 0) return null;
  360. return (
  361. <div className="flex items-center gap-2 mt-1.5">
  362. {/* Material types as text badges */}
  363. {materials.slice(0, 3).map((mat) => (
  364. <span key={mat} className="text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded">
  365. {mat}
  366. </span>
  367. ))}
  368. {/* Colors as swatches */}
  369. {colors.length > 0 && (
  370. <div className="flex items-center gap-0.5">
  371. {colors.slice(0, 5).map((col) => (
  372. <div
  373. key={col}
  374. className="w-3 h-3 rounded-full border border-black/20"
  375. style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}
  376. title={col}
  377. />
  378. ))}
  379. {colors.length > 5 && (
  380. <span className="text-[10px] text-bambu-gray ml-0.5">+{colors.length - 5}</span>
  381. )}
  382. </div>
  383. )}
  384. </div>
  385. );
  386. })()}
  387. </div>
  388. </div>
  389. {/* Actions menu */}
  390. <div className="relative" onClick={(e) => e.stopPropagation()}>
  391. <button
  392. className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
  393. onClick={() => setShowActions(!showActions)}
  394. >
  395. <MoreVertical className="w-4 h-4" />
  396. </button>
  397. {showActions && (
  398. <>
  399. <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
  400. <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]">
  401. <button
  402. className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
  403. hasPermission('projects:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  404. }`}
  405. onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
  406. disabled={!hasPermission('projects:update')}
  407. title={!hasPermission('projects:update') ? t('projects.noEditPermission') : undefined}
  408. >
  409. <Edit3 className="w-4 h-4" />
  410. {t('common.edit')}
  411. </button>
  412. <button
  413. className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
  414. hasPermission('projects:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  415. }`}
  416. onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
  417. disabled={!hasPermission('projects:delete')}
  418. title={!hasPermission('projects:delete') ? t('projects.noDeletePermission') : undefined}
  419. >
  420. <Trash2 className="w-4 h-4" />
  421. {t('common.delete')}
  422. </button>
  423. </div>
  424. </>
  425. )}
  426. </div>
  427. </div>
  428. {/* Progress section - show for all projects */}
  429. <div className="mb-4">
  430. {(project.target_count || project.target_parts_count) ? (
  431. <div className="space-y-3">
  432. {/* Plates progress */}
  433. {project.target_count && (
  434. <div>
  435. <div className="flex items-center justify-between text-xs mb-1">
  436. <span className="text-bambu-gray">{t('projects.plates')}</span>
  437. <span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
  438. {project.archive_count} / {project.target_count}
  439. </span>
  440. </div>
  441. <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
  442. <div
  443. className="h-full transition-all duration-500 ease-out rounded-full relative"
  444. style={{
  445. width: `${Math.min(platesProgressPercent, 100)}%`,
  446. background: platesProgressPercent >= 100
  447. ? 'linear-gradient(90deg, #22c55e, #4ade80)'
  448. : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
  449. boxShadow: `0 0 8px ${platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
  450. }}
  451. />
  452. </div>
  453. </div>
  454. )}
  455. {/* Parts progress */}
  456. {project.target_parts_count && (
  457. <div>
  458. <div className="flex items-center justify-between text-xs mb-1">
  459. <span className="text-bambu-gray">{t('projects.parts')}</span>
  460. <span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
  461. {project.completed_count} / {project.target_parts_count}
  462. </span>
  463. </div>
  464. <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
  465. <div
  466. className="h-full transition-all duration-500 ease-out rounded-full relative"
  467. style={{
  468. width: `${Math.min(partsProgressPercent, 100)}%`,
  469. background: partsProgressPercent >= 100
  470. ? 'linear-gradient(90deg, #22c55e, #4ade80)'
  471. : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
  472. boxShadow: `0 0 8px ${partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
  473. }}
  474. />
  475. </div>
  476. </div>
  477. )}
  478. {/* Failed count */}
  479. {project.failed_count > 0 && (
  480. <div className="text-xs text-red-400">
  481. {project.failed_count} {t('projects.failed')}
  482. </div>
  483. )}
  484. </div>
  485. ) : project.completed_count > 0 || project.failed_count > 0 ? (
  486. <div className="flex items-center gap-4 text-xs">
  487. {project.completed_count > 0 && (
  488. <div className="flex items-center gap-1.5 text-bambu-gray">
  489. <Archive className="w-3.5 h-3.5" />
  490. <span>{project.completed_count} {t('projects.completed')}</span>
  491. </div>
  492. )}
  493. {project.failed_count > 0 && (
  494. <div className="flex items-center gap-1.5 text-red-400">
  495. <AlertTriangle className="w-3.5 h-3.5" />
  496. <span>{project.failed_count} {t('projects.failed')}</span>
  497. </div>
  498. )}
  499. {project.queue_count > 0 && (
  500. <div className="flex items-center gap-1.5 text-blue-400">
  501. <Clock className="w-3.5 h-3.5" />
  502. <span>{project.queue_count} {t('projects.inQueue')}</span>
  503. </div>
  504. )}
  505. </div>
  506. ) : (
  507. <div className="text-xs text-bambu-gray/60 italic">
  508. {t('projects.noPrintsYet')}
  509. </div>
  510. )}
  511. </div>
  512. {/* Archive thumbnails - compact 4-column grid */}
  513. {project.archives && project.archives.length > 0 && (
  514. <div className="mb-4">
  515. <div className="grid grid-cols-4 gap-1.5">
  516. {project.archives.slice(0, 4).map((archive) => (
  517. <div
  518. key={archive.id}
  519. className="relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary"
  520. title={archive.print_name || 'Unknown'}
  521. >
  522. {archive.thumbnail_path ? (
  523. <img
  524. src={api.getArchiveThumbnail(archive.id)}
  525. alt={archive.print_name || ''}
  526. className="w-full h-full object-cover"
  527. />
  528. ) : (
  529. <div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
  530. <Package className="w-6 h-6" />
  531. </div>
  532. )}
  533. {archive.status === 'failed' && (
  534. <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
  535. <AlertTriangle className="w-4 h-4 text-white" />
  536. </div>
  537. )}
  538. </div>
  539. ))}
  540. </div>
  541. {project.archive_count > 4 && (
  542. <p className="text-xs text-bambu-gray mt-1.5 text-center">
  543. {t('common.more', { count: project.archive_count - 4 })}
  544. </p>
  545. )}
  546. </div>
  547. )}
  548. {/* Stats footer */}
  549. <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
  550. <div className="flex items-center gap-4 text-xs text-bambu-gray">
  551. <div className="flex items-center gap-1.5" title={t('projects.printJobs')}>
  552. <Layers className="w-3.5 h-3.5 text-blue-400" />
  553. <span>{project.archive_count} {t('projects.plates')}</span>
  554. </div>
  555. <div className="flex items-center gap-1.5" title={t('projects.partsPrinted')}>
  556. <Package className="w-3.5 h-3.5 text-bambu-green" />
  557. <span>{project.completed_count} {t('projects.parts')}</span>
  558. </div>
  559. {project.failed_count > 0 && (
  560. <div className="flex items-center gap-1.5 text-red-400" title={t('projects.failedParts')}>
  561. <AlertTriangle className="w-3.5 h-3.5" />
  562. <span>{project.failed_count}</span>
  563. </div>
  564. )}
  565. {project.queue_count > 0 && (
  566. <div className="flex items-center gap-1.5 text-yellow-400" title={t('projects.inQueue')}>
  567. <ListTodo className="w-3.5 h-3.5" />
  568. <span>{project.queue_count}</span>
  569. </div>
  570. )}
  571. </div>
  572. <ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
  573. </div>
  574. </div>
  575. </div>
  576. );
  577. }
  578. export function ProjectsPage() {
  579. const { t } = useTranslation();
  580. const navigate = useNavigate();
  581. const queryClient = useQueryClient();
  582. const { showToast } = useToast();
  583. const { hasPermission } = useAuth();
  584. const [showModal, setShowModal] = useState(false);
  585. const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
  586. const [statusFilter, setStatusFilter] = useState<string>('active');
  587. const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
  588. const { data: settings } = useQuery({
  589. queryKey: ['settings'],
  590. queryFn: api.getSettings,
  591. });
  592. const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
  593. const { data: projects, isLoading } = useQuery({
  594. queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
  595. queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
  596. });
  597. const createMutation = useMutation({
  598. mutationFn: (data: ProjectCreate) => api.createProject(data),
  599. onSuccess: () => {
  600. queryClient.invalidateQueries({ queryKey: ['projects'] });
  601. setShowModal(false);
  602. showToast(t('projects.toast.created'), 'success');
  603. },
  604. onError: (error: Error) => {
  605. showToast(error.message, 'error');
  606. },
  607. });
  608. const updateMutation = useMutation({
  609. mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>
  610. api.updateProject(id, data),
  611. onSuccess: () => {
  612. queryClient.invalidateQueries({ queryKey: ['projects'] });
  613. setShowModal(false);
  614. setEditingProject(undefined);
  615. showToast(t('projects.toast.updated'), 'success');
  616. },
  617. onError: (error: Error) => {
  618. showToast(error.message, 'error');
  619. },
  620. });
  621. const deleteMutation = useMutation({
  622. mutationFn: (id: number) => api.deleteProject(id),
  623. onSuccess: () => {
  624. setDeleteConfirm(null);
  625. showToast(t('projects.toast.deleted'), 'success');
  626. // Reload to refresh the list (React Query cache invalidation not working reliably)
  627. setTimeout(() => window.location.reload(), 100);
  628. },
  629. onError: (error: Error) => {
  630. setDeleteConfirm(null);
  631. showToast(error.message, 'error');
  632. },
  633. });
  634. const importMutation = useMutation({
  635. mutationFn: (data: ProjectImport) => api.importProject(data),
  636. onSuccess: () => {
  637. queryClient.invalidateQueries({ queryKey: ['projects'] });
  638. showToast(t('projects.toast.imported'), 'success');
  639. },
  640. onError: (error: Error) => {
  641. showToast(error.message, 'error');
  642. },
  643. });
  644. const fileInputRef = useRef<HTMLInputElement>(null);
  645. const handleExportAll = async () => {
  646. try {
  647. // Export all projects as JSON (metadata only, no files)
  648. const allProjects = await api.getProjects();
  649. const exports = await Promise.all(
  650. allProjects.map(async (p) => {
  651. const exported = await api.exportProjectJson(p.id);
  652. return exported;
  653. })
  654. );
  655. const blob = new Blob([JSON.stringify(exports, null, 2)], { type: 'application/json' });
  656. const url = URL.createObjectURL(blob);
  657. const a = document.createElement('a');
  658. a.href = url;
  659. a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
  660. a.click();
  661. URL.revokeObjectURL(url);
  662. showToast(t('projects.toast.exported'), 'success');
  663. } catch (error) {
  664. showToast((error as Error).message, 'error');
  665. }
  666. };
  667. const handleImportClick = () => {
  668. fileInputRef.current?.click();
  669. };
  670. const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
  671. const file = e.target.files?.[0];
  672. if (!file) return;
  673. try {
  674. const filename = file.name.toLowerCase();
  675. if (filename.endsWith('.zip')) {
  676. // ZIP file: upload via file endpoint
  677. await api.importProjectFile(file);
  678. queryClient.invalidateQueries({ queryKey: ['projects'] });
  679. showToast(t('projects.toast.imported'), 'success');
  680. } else {
  681. // JSON file: parse and handle bulk or single import
  682. const text = await file.text();
  683. const data = JSON.parse(text);
  684. // Handle both single project and array of projects
  685. const projectsToImport = Array.isArray(data) ? data : [data];
  686. for (const project of projectsToImport) {
  687. await importMutation.mutateAsync(project);
  688. }
  689. if (projectsToImport.length > 1) {
  690. showToast(t('projects.toast.multipleImported', { count: projectsToImport.length }), 'success');
  691. }
  692. }
  693. } catch (error) {
  694. showToast(`${t('projects.toast.importFailed')}: ${(error as Error).message}`, 'error');
  695. }
  696. // Reset file input
  697. e.target.value = '';
  698. };
  699. const handleSave = (data: ProjectCreate | ProjectUpdate) => {
  700. if (editingProject) {
  701. updateMutation.mutate({ id: editingProject.id, data });
  702. } else {
  703. createMutation.mutate(data as ProjectCreate);
  704. }
  705. };
  706. const handleEdit = (project: ProjectListItem) => {
  707. setEditingProject(project);
  708. setShowModal(true);
  709. };
  710. const handleClick = (project: ProjectListItem) => {
  711. // Navigate to project detail page
  712. navigate(`/projects/${project.id}`);
  713. };
  714. const handleDeleteClick = (id: number) => {
  715. setDeleteConfirm(id);
  716. };
  717. const handleDeleteConfirm = () => {
  718. if (deleteConfirm !== null) {
  719. deleteMutation.mutate(deleteConfirm);
  720. }
  721. };
  722. // Count projects by status for filter badges
  723. const projectCounts = projects?.reduce((acc, p) => {
  724. acc[p.status] = (acc[p.status] || 0) + 1;
  725. acc.all = (acc.all || 0) + 1;
  726. return acc;
  727. }, {} as Record<string, number>) || {};
  728. return (
  729. <div className="p-4 md:p-8 space-y-8">
  730. {/* Hidden file input for import */}
  731. <input
  732. ref={fileInputRef}
  733. type="file"
  734. accept=".json,.zip"
  735. onChange={handleFileChange}
  736. className="hidden"
  737. />
  738. {/* Header */}
  739. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
  740. <div>
  741. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  742. <div className="p-2.5 bg-bambu-green/10 rounded-xl">
  743. <FolderKanban className="w-6 h-6 text-bambu-green" />
  744. </div>
  745. {t('projects.title')}
  746. </h1>
  747. <p className="text-sm text-bambu-gray mt-2 ml-14">
  748. {t('projects.subtitle')}
  749. </p>
  750. </div>
  751. <div className="flex gap-2">
  752. <Button
  753. variant="secondary"
  754. onClick={handleImportClick}
  755. disabled={!hasPermission('projects:create')}
  756. title={!hasPermission('projects:create') ? t('projects.noImportPermission') : t('projects.importProject')}
  757. >
  758. <Upload className="w-4 h-4 mr-2" />
  759. {t('projects.import')}
  760. </Button>
  761. <Button
  762. variant="secondary"
  763. onClick={handleExportAll}
  764. disabled={!hasPermission('projects:read')}
  765. title={!hasPermission('projects:read') ? t('projects.noExportPermission') : t('projects.exportAll')}
  766. >
  767. <Download className="w-4 h-4 mr-2" />
  768. {t('projects.export')}
  769. </Button>
  770. <Button
  771. onClick={() => setShowModal(true)}
  772. className="sm:w-auto w-full"
  773. disabled={!hasPermission('projects:create')}
  774. title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
  775. >
  776. <Plus className="w-4 h-4 mr-2" />
  777. {t('projects.newProject')}
  778. </Button>
  779. </div>
  780. </div>
  781. {/* Filter tabs */}
  782. <div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
  783. {[
  784. { key: 'active', label: t('projects.statusActive'), icon: Clock },
  785. { key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
  786. { key: 'archived', label: t('projects.statusArchived'), icon: Archive },
  787. { key: 'all', label: t('common.all'), icon: FolderKanban },
  788. ].map(({ key, label, icon: Icon }) => (
  789. <button
  790. key={key}
  791. onClick={() => setStatusFilter(key)}
  792. className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
  793. statusFilter === key
  794. ? 'bg-bambu-card text-white shadow-sm'
  795. : 'text-bambu-gray hover:text-white'
  796. }`}
  797. >
  798. <Icon className="w-4 h-4" />
  799. <span>{label}</span>
  800. {projectCounts[key] > 0 && (
  801. <span className={`text-xs px-1.5 py-0.5 rounded-full ${
  802. statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
  803. }`}>
  804. {projectCounts[key]}
  805. </span>
  806. )}
  807. </button>
  808. ))}
  809. </div>
  810. {/* Content */}
  811. {isLoading ? (
  812. <div className="flex items-center justify-center py-20">
  813. <div className="flex flex-col items-center gap-3">
  814. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  815. <p className="text-sm text-bambu-gray">{t('projects.loading')}</p>
  816. </div>
  817. </div>
  818. ) : projects?.length === 0 ? (
  819. <div className="flex flex-col items-center justify-center py-20 px-4">
  820. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  821. <FolderKanban className="w-12 h-12 text-bambu-gray/50" />
  822. </div>
  823. <h3 className="text-lg font-medium text-white mb-2">
  824. {statusFilter === 'all' ? t('projects.noProjects') : t('projects.noProjectsFiltered', { status: statusFilter })}
  825. </h3>
  826. <p className="text-bambu-gray text-center max-w-md mb-6">
  827. {statusFilter === 'all'
  828. ? t('projects.createFirst')
  829. : t('projects.noProjectsFilteredHelp', { status: statusFilter })
  830. }
  831. </p>
  832. {statusFilter === 'all' && (
  833. <Button
  834. onClick={() => setShowModal(true)}
  835. disabled={!hasPermission('projects:create')}
  836. title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
  837. >
  838. <Plus className="w-4 h-4 mr-2" />
  839. {t('projects.createFirstButton')}
  840. </Button>
  841. )}
  842. </div>
  843. ) : (
  844. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
  845. {projects?.map((project) => (
  846. <ProjectCard
  847. key={project.id}
  848. project={project}
  849. onClick={() => handleClick(project)}
  850. onEdit={() => handleEdit(project)}
  851. onDelete={() => handleDeleteClick(project.id)}
  852. hasPermission={hasPermission}
  853. t={t}
  854. />
  855. ))}
  856. </div>
  857. )}
  858. {/* Delete Confirmation Modal */}
  859. {deleteConfirm !== null && (
  860. <ConfirmModal
  861. title={t('projects.deleteProject')}
  862. message={t('projects.deleteConfirm')}
  863. confirmText={t('projects.deleteProject')}
  864. variant="danger"
  865. onConfirm={handleDeleteConfirm}
  866. onCancel={() => setDeleteConfirm(null)}
  867. />
  868. )}
  869. {/* Modal */}
  870. {showModal && (
  871. <ProjectModal
  872. project={editingProject}
  873. onClose={() => {
  874. setShowModal(false);
  875. setEditingProject(undefined);
  876. }}
  877. onSave={handleSave}
  878. isLoading={createMutation.isPending || updateMutation.isPending}
  879. currencySymbol={currencySymbol}
  880. t={t}
  881. />
  882. )}
  883. </div>
  884. );
  885. }