ProjectsPage.tsx 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184
  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. Clock,
  17. CheckCircle2,
  18. AlertTriangle,
  19. ChevronRight,
  20. MoreVertical,
  21. Download,
  22. Upload,
  23. ExternalLink,
  24. Image as ImageIcon,
  25. X,
  26. } from 'lucide-react';
  27. import { api } from '../api/client';
  28. import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
  29. import { Button } from '../components/Button';
  30. import { ConfirmModal } from '../components/ConfirmModal';
  31. import { useToast } from '../contexts/ToastContext';
  32. import { useAuth } from '../contexts/AuthContext';
  33. import { getCurrencySymbol } from '../utils/currency';
  34. const PROJECT_COLORS = [
  35. '#ef4444', // red
  36. '#f97316', // orange
  37. '#eab308', // yellow
  38. '#22c55e', // green
  39. '#06b6d4', // cyan
  40. '#3b82f6', // blue
  41. '#8b5cf6', // violet
  42. '#ec4899', // pink
  43. '#6b7280', // gray
  44. ];
  45. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  46. interface ProjectModalProps {
  47. project?: ProjectListItem;
  48. onClose: () => void;
  49. onSave: (data: ProjectCreate | ProjectUpdate) => void;
  50. isLoading: boolean;
  51. currencySymbol: string;
  52. t: TFunction;
  53. }
  54. export function ProjectModal({ project, onClose, onSave, isLoading, currencySymbol, t }: ProjectModalProps) {
  55. const [name, setName] = useState(project?.name || '');
  56. const [description, setDescription] = useState(project?.description || '');
  57. const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
  58. const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
  59. const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
  60. const [status, setStatus] = useState(project?.status || 'active');
  61. const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
  62. const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
  63. const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
  64. const [budget, setBudget] = useState(project?.budget?.toString() || '');
  65. const [url, setUrl] = useState(project?.url || '');
  66. const [urlError, setUrlError] = useState<string | null>(null);
  67. const queryClient = useQueryClient();
  68. const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null);
  69. const coverFileInputRef = useRef<HTMLInputElement>(null);
  70. const [coverUploading, setCoverUploading] = useState(false);
  71. // Cache-bust the cover image URL when it changes mid-edit so the preview
  72. // refreshes after upload/remove.
  73. const [coverCacheKey, setCoverCacheKey] = useState(0);
  74. const handleCoverFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
  75. const file = e.target.files?.[0];
  76. if (!file || !project) return;
  77. setCoverUploading(true);
  78. try {
  79. const result = await api.uploadProjectCoverImage(project.id, file);
  80. setCoverImageFilename(result.filename);
  81. setCoverCacheKey((k) => k + 1);
  82. queryClient.invalidateQueries({ queryKey: ['projects'] });
  83. } catch {
  84. // Upload failed — leave existing cover image in place.
  85. } finally {
  86. setCoverUploading(false);
  87. if (coverFileInputRef.current) coverFileInputRef.current.value = '';
  88. }
  89. };
  90. const handleRemoveCover = async () => {
  91. if (!project) return;
  92. setCoverUploading(true);
  93. try {
  94. await api.deleteProjectCoverImage(project.id);
  95. setCoverImageFilename(null);
  96. setCoverCacheKey((k) => k + 1);
  97. queryClient.invalidateQueries({ queryKey: ['projects'] });
  98. } finally {
  99. setCoverUploading(false);
  100. }
  101. };
  102. const handleSubmit = (e: React.FormEvent) => {
  103. e.preventDefault();
  104. const trimmedUrl = url.trim();
  105. if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) {
  106. setUrlError(t('projects.urlInvalid'));
  107. return;
  108. }
  109. setUrlError(null);
  110. onSave({
  111. name: name.trim(),
  112. description: description.trim() || undefined,
  113. color,
  114. target_count: targetCount ? parseInt(targetCount, 10) : undefined,
  115. target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
  116. tags: tags.trim() || undefined,
  117. due_date: dueDate || undefined,
  118. priority,
  119. budget: budget.trim() ? parseFloat(budget) : null,
  120. // Pydantic accepts null to clear the URL; an empty string would fail the
  121. // http(s) prefix validator. Use undefined for create (omit) and null for
  122. // edit-with-cleared-value.
  123. url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
  124. ...(project && { status }),
  125. });
  126. };
  127. return (
  128. // max-h + flex column on the card + overflow on the fields wrapper so the
  129. // modal stays inside the viewport on short screens (#1642). Outer p-4 is
  130. // 1rem each side, hence the 2rem subtraction below.
  131. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  132. <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)]">
  133. <div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  134. <h2 className="text-lg font-semibold text-white">
  135. {project ? t('projects.editProject') : t('projects.newProject')}
  136. </h2>
  137. </div>
  138. <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
  139. <div className="p-4 space-y-4 overflow-y-auto flex-1">
  140. <div>
  141. <label className="block text-sm font-medium text-white mb-1">
  142. {t('common.name')}
  143. </label>
  144. <input
  145. type="text"
  146. value={name}
  147. onChange={(e) => setName(e.target.value)}
  148. 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"
  149. placeholder={t('projects.namePlaceholder')}
  150. required
  151. />
  152. </div>
  153. <div>
  154. <label className="block text-sm font-medium text-white mb-1">
  155. {t('common.description')}
  156. </label>
  157. <textarea
  158. value={description}
  159. onChange={(e) => setDescription(e.target.value)}
  160. 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"
  161. placeholder={t('projects.descriptionPlaceholder')}
  162. rows={2}
  163. />
  164. </div>
  165. {/* #1155: External URL */}
  166. <div>
  167. <label className="block text-sm font-medium text-white mb-1">
  168. {t('projects.urlLabel')}
  169. </label>
  170. <input
  171. type="url"
  172. value={url}
  173. onChange={(e) => { setUrl(e.target.value); if (urlError) setUrlError(null); }}
  174. className={`w-full bg-bambu-dark border rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none ${
  175. urlError ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'
  176. }`}
  177. placeholder={t('projects.urlPlaceholder')}
  178. maxLength={2048}
  179. />
  180. {urlError && <p className="text-xs text-red-600 dark:text-red-400 mt-1">{urlError}</p>}
  181. </div>
  182. {/* #1155: Cover image — only available when editing an existing project,
  183. since uploading needs a project_id. New projects can add it after save. */}
  184. {project && (
  185. <div>
  186. <label className="block text-sm font-medium text-white mb-1">
  187. {t('projects.coverImageLabel')}
  188. </label>
  189. <div className="flex items-center gap-3">
  190. <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">
  191. {coverImageFilename ? (
  192. <img
  193. src={`${api.getProjectCoverImageUrl(project.id)}?v=${coverCacheKey}`}
  194. alt={t('projects.coverImageAlt')}
  195. className="w-full h-full object-cover"
  196. />
  197. ) : (
  198. <ImageIcon className="w-6 h-6 text-bambu-gray" />
  199. )}
  200. </div>
  201. <div className="flex flex-col gap-2">
  202. <input
  203. ref={coverFileInputRef}
  204. type="file"
  205. accept="image/jpeg,image/png,image/gif,image/webp"
  206. onChange={handleCoverFileChange}
  207. className="hidden"
  208. />
  209. <Button
  210. type="button"
  211. variant="secondary"
  212. onClick={() => coverFileInputRef.current?.click()}
  213. disabled={coverUploading}
  214. >
  215. {coverUploading ? (
  216. <Loader2 className="w-4 h-4 animate-spin" />
  217. ) : (
  218. <Upload className="w-4 h-4 mr-1" />
  219. )}
  220. {coverImageFilename ? t('projects.coverImageReplace') : t('projects.coverImageUpload')}
  221. </Button>
  222. {coverImageFilename && (
  223. <Button
  224. type="button"
  225. variant="secondary"
  226. onClick={handleRemoveCover}
  227. disabled={coverUploading}
  228. >
  229. <X className="w-4 h-4 mr-1" />
  230. {t('projects.coverImageRemove')}
  231. </Button>
  232. )}
  233. </div>
  234. </div>
  235. </div>
  236. )}
  237. <div>
  238. <label className="block text-sm font-medium text-white mb-1">
  239. {t('projects.color')}
  240. </label>
  241. <div className="flex gap-2 flex-wrap">
  242. {PROJECT_COLORS.map((c) => (
  243. <button
  244. key={c}
  245. type="button"
  246. onClick={() => setColor(c)}
  247. className={`w-8 h-8 rounded-full transition-transform ${
  248. color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : ''
  249. }`}
  250. style={{ backgroundColor: c }}
  251. />
  252. ))}
  253. </div>
  254. </div>
  255. {/* Target Counts - Plates and Parts side by side */}
  256. <div className="grid grid-cols-2 gap-4">
  257. <div>
  258. <label className="block text-sm font-medium text-white mb-1">
  259. {t('projects.targetPlates')}
  260. </label>
  261. <input
  262. type="number"
  263. value={targetCount}
  264. onChange={(e) => setTargetCount(e.target.value)}
  265. 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"
  266. placeholder={t('projects.targetPlatesPlaceholder')}
  267. min="1"
  268. />
  269. <p className="text-xs text-bambu-gray mt-1">{t('projects.targetPlatesHelp')}</p>
  270. </div>
  271. <div>
  272. <label className="block text-sm font-medium text-white mb-1">
  273. {t('projects.targetParts')}
  274. </label>
  275. <input
  276. type="number"
  277. value={targetPartsCount}
  278. onChange={(e) => setTargetPartsCount(e.target.value)}
  279. 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"
  280. placeholder={t('projects.targetPartsPlaceholder')}
  281. min="1"
  282. />
  283. <p className="text-xs text-bambu-gray mt-1">{t('projects.targetPartsHelp')}</p>
  284. </div>
  285. </div>
  286. {/* Tags */}
  287. <div>
  288. <label className="block text-sm font-medium text-white mb-1">
  289. {t('projects.tagsLabel')}
  290. </label>
  291. <input
  292. type="text"
  293. value={tags}
  294. onChange={(e) => setTags(e.target.value)}
  295. 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"
  296. placeholder={t('projects.tagsPlaceholder')}
  297. />
  298. </div>
  299. {/* Due Date and Priority in a row */}
  300. <div className="grid grid-cols-2 gap-4">
  301. <div>
  302. <label className="block text-sm font-medium text-white mb-1">
  303. {t('projects.dueDate')}
  304. </label>
  305. <input
  306. type="date"
  307. value={dueDate}
  308. onChange={(e) => setDueDate(e.target.value)}
  309. 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"
  310. />
  311. </div>
  312. <div>
  313. <label className="block text-sm font-medium text-white mb-1">
  314. {t('projects.priority')}
  315. </label>
  316. <select
  317. value={priority}
  318. onChange={(e) => setPriority(e.target.value)}
  319. 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"
  320. >
  321. <option value="low">{t('projects.priorityLow')}</option>
  322. <option value="normal">{t('projects.priorityNormal')}</option>
  323. <option value="high">{t('projects.priorityHigh')}</option>
  324. <option value="urgent">{t('projects.priorityUrgent')}</option>
  325. </select>
  326. </div>
  327. </div>
  328. <div>
  329. <label className="block text-sm font-medium text-white mb-1">
  330. {t('projectDetail.cost.budget')}
  331. </label>
  332. <div className="relative">
  333. <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray pointer-events-none">
  334. {currencySymbol}
  335. </span>
  336. <input
  337. type="number"
  338. step="0.01"
  339. min="0"
  340. value={budget}
  341. onChange={(e) => setBudget(e.target.value)}
  342. 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"
  343. placeholder="0.00"
  344. />
  345. </div>
  346. </div>
  347. {project && (
  348. <div>
  349. <label className="block text-sm font-medium text-white mb-1">
  350. {t('common.status')}
  351. </label>
  352. <select
  353. value={status}
  354. onChange={(e) => setStatus(e.target.value)}
  355. 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"
  356. >
  357. <option value="active">{t('projects.statusActive')}</option>
  358. <option value="completed">{t('projects.statusCompleted')}</option>
  359. <option value="archived">{t('projects.statusArchived')}</option>
  360. </select>
  361. </div>
  362. )}
  363. </div>
  364. {/* Sticky action footer — stays visible regardless of scroll
  365. position so Save/Cancel are always reachable on short screens
  366. (#1642). Buttons stay inside <form> for type="submit". */}
  367. <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  368. <Button type="button" variant="secondary" onClick={onClose}>
  369. {t('common.cancel')}
  370. </Button>
  371. <Button type="submit" disabled={!name.trim() || isLoading}>
  372. {isLoading ? (
  373. <Loader2 className="w-4 h-4 animate-spin" />
  374. ) : project ? (
  375. t('common.save')
  376. ) : (
  377. t('projects.create')
  378. )}
  379. </Button>
  380. </div>
  381. </form>
  382. </div>
  383. </div>
  384. );
  385. }
  386. /**
  387. * Cover thumbnail with portal-rendered hover preview (#1155 follow-up).
  388. *
  389. * Why a portal: the parent ``ProjectCard`` carries ``overflow-hidden`` for
  390. * its rounded-corner clipping and color accent bar; an in-tree popover
  391. * gets clipped by that and only the part that overlaps the card is
  392. * visible. Rendering the preview via ``createPortal`` to ``document.body``
  393. * escapes every ancestor clipping context, and ``position: fixed`` with
  394. * ``getBoundingClientRect()`` keeps it pinned next to the thumbnail
  395. * regardless of where the card sits in the grid.
  396. */
  397. function ProjectCoverThumbnail({
  398. projectId,
  399. altText,
  400. }: {
  401. projectId: number;
  402. altText: string;
  403. }) {
  404. const thumbRef = useRef<HTMLDivElement>(null);
  405. const [hovered, setHovered] = useState(false);
  406. const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
  407. const handleEnter = () => {
  408. if (!thumbRef.current) return;
  409. const rect = thumbRef.current.getBoundingClientRect();
  410. // Anchor the 384px preview just to the right of the thumbnail (8px gap).
  411. // Clamp ``top`` so the preview never overflows the viewport vertically;
  412. // similar story for ``left`` if the card is near the right edge — flip
  413. // to the LEFT side of the thumbnail in that case.
  414. const PREVIEW = 384;
  415. const GAP = 8;
  416. const vw = window.innerWidth;
  417. const vh = window.innerHeight;
  418. let left = rect.right + GAP;
  419. if (left + PREVIEW > vw - 8) {
  420. left = rect.left - PREVIEW - GAP;
  421. }
  422. let top = rect.top;
  423. if (top + PREVIEW > vh - 8) {
  424. top = vh - PREVIEW - 8;
  425. }
  426. if (top < 8) top = 8;
  427. setPos({ left, top });
  428. setHovered(true);
  429. };
  430. const handleLeave = () => setHovered(false);
  431. return (
  432. <div
  433. ref={thumbRef}
  434. className="relative flex-shrink-0"
  435. onMouseEnter={handleEnter}
  436. onMouseLeave={handleLeave}
  437. onClick={(e) => e.stopPropagation()}
  438. >
  439. <div className="w-10 h-10 rounded-lg overflow-hidden bg-bambu-dark border border-bambu-dark-tertiary">
  440. <img
  441. src={api.getProjectCoverImageUrl(projectId)}
  442. alt={altText}
  443. className="w-full h-full object-cover"
  444. loading="lazy"
  445. />
  446. </div>
  447. {hovered && pos &&
  448. createPortal(
  449. <div
  450. 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"
  451. style={{ left: pos.left, top: pos.top }}
  452. aria-hidden="true"
  453. >
  454. <img
  455. src={api.getProjectCoverImageUrl(projectId)}
  456. alt=""
  457. className="w-full h-full object-contain"
  458. loading="lazy"
  459. />
  460. </div>,
  461. document.body,
  462. )}
  463. </div>
  464. );
  465. }
  466. interface ProjectCardProps {
  467. project: ProjectListItem;
  468. onClick: () => void;
  469. onEdit: () => void;
  470. onDelete: () => void;
  471. hasPermission: (permission: Permission) => boolean;
  472. t: TFunction;
  473. }
  474. function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
  475. // Plates progress: archive_count / target_count
  476. const platesProgressPercent = project.target_count
  477. ? Math.round((project.archive_count / project.target_count) * 100)
  478. : 0;
  479. // Parts progress: completed_count / target_parts_count
  480. const partsProgressPercent = project.target_parts_count
  481. ? Math.round((project.completed_count / project.target_parts_count) * 100)
  482. : 0;
  483. const isCompleted = project.status === 'completed';
  484. const isArchived = project.status === 'archived';
  485. const [showActions, setShowActions] = useState(false);
  486. // Status icon and color
  487. const getStatusConfig = () => {
  488. if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };
  489. if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
  490. 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' };
  491. return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
  492. };
  493. const statusConfig = getStatusConfig();
  494. return (
  495. <div
  496. 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"
  497. onClick={onClick}
  498. >
  499. {/* Color accent bar with glow */}
  500. <div
  501. className="absolute top-0 left-0 w-1.5 h-full"
  502. style={{
  503. backgroundColor: project.color || '#6b7280',
  504. boxShadow: `0 0 12px ${project.color || '#6b7280'}40`
  505. }}
  506. />
  507. <div className="p-5 pl-6">
  508. {/* Header */}
  509. <div className="flex items-start justify-between mb-4">
  510. <div className="flex items-center gap-3 min-w-0 flex-1">
  511. {project.cover_image_filename ? (
  512. // #1155: cover photo replaces the status-icon box. The thumbnail
  513. // itself stays small so the card layout doesn't shift; on hover
  514. // a portal-rendered 384×384 preview pops out beside the card
  515. // so the user can identify the print without navigating into
  516. // the project view. The portal is needed because ProjectCard's
  517. // own ``overflow-hidden`` (for rounded corners) clips any
  518. // in-tree popover before it can extend outside the card.
  519. <ProjectCoverThumbnail
  520. projectId={project.id}
  521. altText={t('projects.coverImageAlt')}
  522. />
  523. ) : (
  524. <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
  525. <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
  526. </div>
  527. )}
  528. <div className="min-w-0 flex-1">
  529. <div className="flex items-center gap-2 flex-wrap">
  530. <h3 className="font-semibold text-white truncate">{project.name}</h3>
  531. {project.url && (
  532. <a
  533. href={project.url}
  534. target="_blank"
  535. rel="noopener noreferrer"
  536. onClick={(e) => e.stopPropagation()}
  537. title={project.url}
  538. aria-label={t('projects.openExternalUrl')}
  539. 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"
  540. >
  541. <ExternalLink className="w-3.5 h-3.5" />
  542. </a>
  543. )}
  544. {project.target_parts_count ? (
  545. <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
  546. partsProgressPercent >= 100
  547. ? 'bg-bambu-green/20 text-bambu-green'
  548. : 'bg-bambu-dark text-bambu-gray'
  549. }`}>
  550. {project.completed_count}/{project.target_parts_count} {t('projects.parts')}
  551. </span>
  552. ) : project.target_count ? (
  553. <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
  554. platesProgressPercent >= 100
  555. ? 'bg-bambu-green/20 text-bambu-green'
  556. : 'bg-bambu-dark text-bambu-gray'
  557. }`}>
  558. {project.archive_count}/{project.target_count} {t('projects.plates')}
  559. </span>
  560. ) : project.completed_count > 0 ? (
  561. <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
  562. {project.completed_count} {t('projects.parts')}
  563. </span>
  564. ) : null}
  565. {isCompleted && (
  566. <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
  567. {t('projects.done')}
  568. </span>
  569. )}
  570. {isArchived && (
  571. <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
  572. {t('projects.statusArchived')}
  573. </span>
  574. )}
  575. </div>
  576. {project.description && (
  577. <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
  578. {project.description}
  579. </p>
  580. )}
  581. {/* Filament materials/colors */}
  582. {project.archives && project.archives.length > 0 && (() => {
  583. // Flatten comma-separated materials and deduplicate
  584. const allMaterials = project.archives
  585. .map(a => a.filament_type)
  586. .filter(Boolean)
  587. .flatMap(m => (m as string).split(',').map(s => s.trim()))
  588. .filter(Boolean);
  589. const materials = [...new Set(allMaterials)];
  590. // Flatten comma-separated colors and deduplicate
  591. const allColors = project.archives
  592. .map(a => a.filament_color)
  593. .filter(Boolean)
  594. .flatMap(c => (c as string).split(',').map(s => s.trim()))
  595. .filter(c => c.startsWith('#') || /^[0-9A-Fa-f]{6}$/.test(c));
  596. const colors = [...new Set(allColors)];
  597. if (materials.length === 0 && colors.length === 0) return null;
  598. return (
  599. <div className="flex items-center gap-2 mt-1.5">
  600. {/* Material types as text badges */}
  601. {materials.slice(0, 3).map((mat) => (
  602. <span key={mat} className="text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded">
  603. {mat}
  604. </span>
  605. ))}
  606. {/* Colors as swatches */}
  607. {colors.length > 0 && (
  608. <div className="flex items-center gap-0.5">
  609. {colors.slice(0, 5).map((col) => (
  610. <div
  611. key={col}
  612. className="w-3 h-3 rounded-full border border-black/20"
  613. style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}
  614. title={col}
  615. />
  616. ))}
  617. {colors.length > 5 && (
  618. <span className="text-[10px] text-bambu-gray ml-0.5">+{colors.length - 5}</span>
  619. )}
  620. </div>
  621. )}
  622. </div>
  623. );
  624. })()}
  625. </div>
  626. </div>
  627. {/* Actions menu */}
  628. <div className="relative" onClick={(e) => e.stopPropagation()}>
  629. <button
  630. className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
  631. onClick={() => setShowActions(!showActions)}
  632. >
  633. <MoreVertical className="w-4 h-4" />
  634. </button>
  635. {showActions && (
  636. <>
  637. <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
  638. <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]">
  639. <button
  640. className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
  641. hasPermission('projects:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  642. }`}
  643. onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
  644. disabled={!hasPermission('projects:update')}
  645. title={!hasPermission('projects:update') ? t('projects.noEditPermission') : undefined}
  646. >
  647. <Edit3 className="w-4 h-4" />
  648. {t('common.edit')}
  649. </button>
  650. <button
  651. className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
  652. hasPermission('projects:delete') ? 'text-red-600 dark:text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  653. }`}
  654. onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
  655. disabled={!hasPermission('projects:delete')}
  656. title={!hasPermission('projects:delete') ? t('projects.noDeletePermission') : undefined}
  657. >
  658. <Trash2 className="w-4 h-4" />
  659. {t('common.delete')}
  660. </button>
  661. </div>
  662. </>
  663. )}
  664. </div>
  665. </div>
  666. {/* Progress section - show for all projects */}
  667. <div className="mb-4">
  668. {(project.target_count || project.target_parts_count) ? (
  669. <div className="space-y-3">
  670. {/* Plates progress */}
  671. {project.target_count && (
  672. <div>
  673. <div className="flex items-center justify-between text-xs mb-1">
  674. <span className="text-bambu-gray">{t('projects.plates')}</span>
  675. <span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
  676. {project.archive_count} / {project.target_count}
  677. </span>
  678. </div>
  679. <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
  680. <div
  681. className="h-full transition-all duration-500 ease-out rounded-full relative"
  682. style={{
  683. width: `${Math.min(platesProgressPercent, 100)}%`,
  684. background: platesProgressPercent >= 100
  685. ? 'linear-gradient(90deg, #22c55e, #4ade80)'
  686. : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
  687. boxShadow: `0 0 8px ${platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
  688. }}
  689. />
  690. </div>
  691. </div>
  692. )}
  693. {/* Parts progress */}
  694. {project.target_parts_count && (
  695. <div>
  696. <div className="flex items-center justify-between text-xs mb-1">
  697. <span className="text-bambu-gray">{t('projects.parts')}</span>
  698. <span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
  699. {project.completed_count} / {project.target_parts_count}
  700. </span>
  701. </div>
  702. <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
  703. <div
  704. className="h-full transition-all duration-500 ease-out rounded-full relative"
  705. style={{
  706. width: `${Math.min(partsProgressPercent, 100)}%`,
  707. background: partsProgressPercent >= 100
  708. ? 'linear-gradient(90deg, #22c55e, #4ade80)'
  709. : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
  710. boxShadow: `0 0 8px ${partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
  711. }}
  712. />
  713. </div>
  714. </div>
  715. )}
  716. {/* Failed count */}
  717. {project.failed_count > 0 && (
  718. <div className="text-xs text-red-600 dark:text-red-400">
  719. {project.failed_count} {t('projects.failed')}
  720. </div>
  721. )}
  722. </div>
  723. ) : project.completed_count > 0 || project.failed_count > 0 ? (
  724. <div className="flex items-center gap-4 text-xs">
  725. {project.completed_count > 0 && (
  726. <div className="flex items-center gap-1.5 text-bambu-gray">
  727. <Archive className="w-3.5 h-3.5" />
  728. <span>{project.completed_count} {t('projects.completed')}</span>
  729. </div>
  730. )}
  731. {project.failed_count > 0 && (
  732. <div className="flex items-center gap-1.5 text-red-600 dark:text-red-400">
  733. <AlertTriangle className="w-3.5 h-3.5" />
  734. <span>{project.failed_count} {t('projects.failed')}</span>
  735. </div>
  736. )}
  737. {project.queue_count > 0 && (
  738. <div className="flex items-center gap-1.5 text-blue-600 dark:text-blue-400">
  739. <Clock className="w-3.5 h-3.5" />
  740. <span>{project.queue_count} {t('projects.inQueue')}</span>
  741. </div>
  742. )}
  743. </div>
  744. ) : (
  745. <div className="text-xs text-bambu-gray/60 italic">
  746. {t('projects.noPrintsYet')}
  747. </div>
  748. )}
  749. </div>
  750. {/* Archive thumbnails - compact 4-column grid */}
  751. {project.archives && project.archives.length > 0 && (
  752. <div className="mb-4">
  753. <div className="grid grid-cols-4 gap-1.5">
  754. {project.archives.slice(0, 4).map((archive) => (
  755. <div
  756. key={archive.id}
  757. className="relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary"
  758. title={archive.print_name || 'Unknown'}
  759. >
  760. {archive.thumbnail_path ? (
  761. <img
  762. src={api.getArchiveThumbnail(archive.id)}
  763. alt={archive.print_name || ''}
  764. className="w-full h-full object-cover"
  765. />
  766. ) : (
  767. <div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
  768. <Package className="w-6 h-6" />
  769. </div>
  770. )}
  771. {archive.status === 'failed' && (
  772. <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
  773. <AlertTriangle className="w-4 h-4 text-white" />
  774. </div>
  775. )}
  776. </div>
  777. ))}
  778. </div>
  779. {project.archive_count > 4 && (
  780. <p className="text-xs text-bambu-gray mt-1.5 text-center">
  781. {t('common.more', { count: project.archive_count - 4 })}
  782. </p>
  783. )}
  784. </div>
  785. )}
  786. {/* Stats footer */}
  787. <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
  788. <div className="flex items-center gap-4 text-xs text-bambu-gray">
  789. <div className="flex items-center gap-1.5" title={t('projects.printJobs')}>
  790. <Layers className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
  791. <span>{project.archive_count} {t('projects.plates')}</span>
  792. </div>
  793. <div className="flex items-center gap-1.5" title={t('projects.partsPrinted')}>
  794. <Package className="w-3.5 h-3.5 text-bambu-green" />
  795. <span>{project.completed_count} {t('projects.parts')}</span>
  796. </div>
  797. {project.failed_count > 0 && (
  798. <div className="flex items-center gap-1.5 text-red-600 dark:text-red-400" title={t('projects.failedParts')}>
  799. <AlertTriangle className="w-3.5 h-3.5" />
  800. <span>{project.failed_count}</span>
  801. </div>
  802. )}
  803. {project.queue_count > 0 && (
  804. <div className="flex items-center gap-1.5 text-yellow-600 dark:text-yellow-400" title={t('projects.inQueue')}>
  805. <ListTodo className="w-3.5 h-3.5" />
  806. <span>{project.queue_count}</span>
  807. </div>
  808. )}
  809. </div>
  810. <ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
  811. </div>
  812. </div>
  813. </div>
  814. );
  815. }
  816. export function ProjectsPage() {
  817. const { t } = useTranslation();
  818. const navigate = useNavigate();
  819. const queryClient = useQueryClient();
  820. const { showToast } = useToast();
  821. const { hasPermission } = useAuth();
  822. const [showModal, setShowModal] = useState(false);
  823. const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
  824. const [statusFilter, setStatusFilter] = useState<string>('active');
  825. const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
  826. const { data: settings } = useQuery({
  827. queryKey: ['settings'],
  828. queryFn: api.getSettings,
  829. });
  830. const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
  831. const { data: projects, isLoading } = useQuery({
  832. queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
  833. queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
  834. });
  835. const createMutation = useMutation({
  836. mutationFn: (data: ProjectCreate) => api.createProject(data),
  837. onSuccess: () => {
  838. queryClient.invalidateQueries({ queryKey: ['projects'] });
  839. setShowModal(false);
  840. showToast(t('projects.toast.created'), 'success');
  841. },
  842. onError: (error: Error) => {
  843. showToast(error.message, 'error');
  844. },
  845. });
  846. const updateMutation = useMutation({
  847. mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>
  848. api.updateProject(id, data),
  849. onSuccess: () => {
  850. queryClient.invalidateQueries({ queryKey: ['projects'] });
  851. setShowModal(false);
  852. setEditingProject(undefined);
  853. showToast(t('projects.toast.updated'), 'success');
  854. },
  855. onError: (error: Error) => {
  856. showToast(error.message, 'error');
  857. },
  858. });
  859. const deleteMutation = useMutation({
  860. mutationFn: (id: number) => api.deleteProject(id),
  861. onSuccess: () => {
  862. setDeleteConfirm(null);
  863. showToast(t('projects.toast.deleted'), 'success');
  864. // Reload to refresh the list (React Query cache invalidation not working reliably)
  865. setTimeout(() => window.location.reload(), 100);
  866. },
  867. onError: (error: Error) => {
  868. setDeleteConfirm(null);
  869. showToast(error.message, 'error');
  870. },
  871. });
  872. const importMutation = useMutation({
  873. mutationFn: (data: ProjectImport) => api.importProject(data),
  874. onSuccess: () => {
  875. queryClient.invalidateQueries({ queryKey: ['projects'] });
  876. showToast(t('projects.toast.imported'), 'success');
  877. },
  878. onError: (error: Error) => {
  879. showToast(error.message, 'error');
  880. },
  881. });
  882. const fileInputRef = useRef<HTMLInputElement>(null);
  883. const handleExportAll = async () => {
  884. try {
  885. // Export all projects as JSON (metadata only, no files)
  886. const allProjects = await api.getProjects();
  887. const exports = await Promise.all(
  888. allProjects.map(async (p) => {
  889. const exported = await api.exportProjectJson(p.id);
  890. return exported;
  891. })
  892. );
  893. const blob = new Blob([JSON.stringify(exports, null, 2)], { type: 'application/json' });
  894. const url = URL.createObjectURL(blob);
  895. const a = document.createElement('a');
  896. a.href = url;
  897. a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
  898. a.click();
  899. URL.revokeObjectURL(url);
  900. showToast(t('projects.toast.exported'), 'success');
  901. } catch (error) {
  902. showToast((error as Error).message, 'error');
  903. }
  904. };
  905. const handleImportClick = () => {
  906. fileInputRef.current?.click();
  907. };
  908. const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
  909. const file = e.target.files?.[0];
  910. if (!file) return;
  911. try {
  912. const filename = file.name.toLowerCase();
  913. if (filename.endsWith('.zip')) {
  914. // ZIP file: upload via file endpoint
  915. await api.importProjectFile(file);
  916. queryClient.invalidateQueries({ queryKey: ['projects'] });
  917. showToast(t('projects.toast.imported'), 'success');
  918. } else {
  919. // JSON file: parse and handle bulk or single import
  920. const text = await file.text();
  921. const data = JSON.parse(text);
  922. // Handle both single project and array of projects
  923. const projectsToImport = Array.isArray(data) ? data : [data];
  924. for (const project of projectsToImport) {
  925. await importMutation.mutateAsync(project);
  926. }
  927. if (projectsToImport.length > 1) {
  928. showToast(t('projects.toast.multipleImported', { count: projectsToImport.length }), 'success');
  929. }
  930. }
  931. } catch (error) {
  932. showToast(`${t('projects.toast.importFailed')}: ${(error as Error).message}`, 'error');
  933. }
  934. // Reset file input
  935. e.target.value = '';
  936. };
  937. const handleSave = (data: ProjectCreate | ProjectUpdate) => {
  938. if (editingProject) {
  939. updateMutation.mutate({ id: editingProject.id, data });
  940. } else {
  941. createMutation.mutate(data as ProjectCreate);
  942. }
  943. };
  944. const handleEdit = (project: ProjectListItem) => {
  945. setEditingProject(project);
  946. setShowModal(true);
  947. };
  948. const handleClick = (project: ProjectListItem) => {
  949. // Navigate to project detail page
  950. navigate(`/projects/${project.id}`);
  951. };
  952. const handleDeleteClick = (id: number) => {
  953. setDeleteConfirm(id);
  954. };
  955. const handleDeleteConfirm = () => {
  956. if (deleteConfirm !== null) {
  957. deleteMutation.mutate(deleteConfirm);
  958. }
  959. };
  960. // Count projects by status for filter badges
  961. const projectCounts = projects?.reduce((acc, p) => {
  962. acc[p.status] = (acc[p.status] || 0) + 1;
  963. acc.all = (acc.all || 0) + 1;
  964. return acc;
  965. }, {} as Record<string, number>) || {};
  966. return (
  967. <div className="p-4 md:p-8 space-y-8">
  968. {/* Hidden file input for import */}
  969. <input
  970. ref={fileInputRef}
  971. type="file"
  972. accept=".json,.zip"
  973. onChange={handleFileChange}
  974. className="hidden"
  975. />
  976. {/* Header */}
  977. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
  978. <div>
  979. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  980. <FolderKanban className="w-7 h-7 text-bambu-green" />
  981. {t('projects.title')}
  982. </h1>
  983. <p className="text-bambu-gray mt-1">
  984. {t('projects.subtitle')}
  985. </p>
  986. </div>
  987. <div className="flex gap-2">
  988. <Button
  989. variant="secondary"
  990. onClick={handleImportClick}
  991. disabled={!hasPermission('projects:create')}
  992. title={!hasPermission('projects:create') ? t('projects.noImportPermission') : t('projects.importProject')}
  993. >
  994. <Upload className="w-4 h-4 mr-2" />
  995. {t('projects.import')}
  996. </Button>
  997. <Button
  998. variant="secondary"
  999. onClick={handleExportAll}
  1000. disabled={!hasPermission('projects:read')}
  1001. title={!hasPermission('projects:read') ? t('projects.noExportPermission') : t('projects.exportAll')}
  1002. >
  1003. <Download className="w-4 h-4 mr-2" />
  1004. {t('projects.export')}
  1005. </Button>
  1006. <Button
  1007. onClick={() => setShowModal(true)}
  1008. className="sm:w-auto w-full"
  1009. disabled={!hasPermission('projects:create')}
  1010. title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
  1011. >
  1012. <Plus className="w-4 h-4 mr-2" />
  1013. {t('projects.newProject')}
  1014. </Button>
  1015. </div>
  1016. </div>
  1017. {/* Filter tabs */}
  1018. <div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
  1019. {[
  1020. { key: 'active', label: t('projects.statusActive'), icon: Clock },
  1021. { key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
  1022. { key: 'archived', label: t('projects.statusArchived'), icon: Archive },
  1023. { key: 'all', label: t('common.all'), icon: FolderKanban },
  1024. ].map(({ key, label, icon: Icon }) => (
  1025. <button
  1026. key={key}
  1027. onClick={() => setStatusFilter(key)}
  1028. className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
  1029. statusFilter === key
  1030. ? 'bg-bambu-card text-white shadow-sm'
  1031. : 'text-bambu-gray hover:text-white'
  1032. }`}
  1033. >
  1034. <Icon className="w-4 h-4" />
  1035. <span>{label}</span>
  1036. {projectCounts[key] > 0 && (
  1037. <span className={`text-xs px-1.5 py-0.5 rounded-full ${
  1038. statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
  1039. }`}>
  1040. {projectCounts[key]}
  1041. </span>
  1042. )}
  1043. </button>
  1044. ))}
  1045. </div>
  1046. {/* Content */}
  1047. {isLoading ? (
  1048. <div className="flex items-center justify-center py-20">
  1049. <div className="flex flex-col items-center gap-3">
  1050. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  1051. <p className="text-sm text-bambu-gray">{t('projects.loading')}</p>
  1052. </div>
  1053. </div>
  1054. ) : projects?.length === 0 ? (
  1055. <div className="flex flex-col items-center justify-center py-20 px-4">
  1056. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  1057. <FolderKanban className="w-12 h-12 text-bambu-gray/50" />
  1058. </div>
  1059. <h3 className="text-lg font-medium text-white mb-2">
  1060. {statusFilter === 'all' ? t('projects.noProjects') : t('projects.noProjectsFiltered', { status: statusFilter })}
  1061. </h3>
  1062. <p className="text-bambu-gray text-center max-w-md mb-6">
  1063. {statusFilter === 'all'
  1064. ? t('projects.createFirst')
  1065. : t('projects.noProjectsFilteredHelp', { status: statusFilter })
  1066. }
  1067. </p>
  1068. {statusFilter === 'all' && (
  1069. <Button
  1070. onClick={() => setShowModal(true)}
  1071. disabled={!hasPermission('projects:create')}
  1072. title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
  1073. >
  1074. <Plus className="w-4 h-4 mr-2" />
  1075. {t('projects.createFirstButton')}
  1076. </Button>
  1077. )}
  1078. </div>
  1079. ) : (
  1080. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
  1081. {projects?.map((project) => (
  1082. <ProjectCard
  1083. key={project.id}
  1084. project={project}
  1085. onClick={() => handleClick(project)}
  1086. onEdit={() => handleEdit(project)}
  1087. onDelete={() => handleDeleteClick(project.id)}
  1088. hasPermission={hasPermission}
  1089. t={t}
  1090. />
  1091. ))}
  1092. </div>
  1093. )}
  1094. {/* Delete Confirmation Modal */}
  1095. {deleteConfirm !== null && (
  1096. <ConfirmModal
  1097. title={t('projects.deleteProject')}
  1098. message={t('projects.deleteConfirm')}
  1099. confirmText={t('projects.deleteProject')}
  1100. variant="danger"
  1101. onConfirm={handleDeleteConfirm}
  1102. onCancel={() => setDeleteConfirm(null)}
  1103. />
  1104. )}
  1105. {/* Modal */}
  1106. {showModal && (
  1107. <ProjectModal
  1108. project={editingProject}
  1109. onClose={() => {
  1110. setShowModal(false);
  1111. setEditingProject(undefined);
  1112. }}
  1113. onSave={handleSave}
  1114. isLoading={createMutation.isPending || updateMutation.isPending}
  1115. currencySymbol={currencySymbol}
  1116. t={t}
  1117. />
  1118. )}
  1119. </div>
  1120. );
  1121. }