ProjectsPage.tsx 48 KB

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