ProjectsPage.tsx 47 KB

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