BatchProjectModal.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { X, FolderKanban, Loader2, XCircle, Search } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import { Card, CardContent } from './Card';
  7. import { Button } from './Button';
  8. import { useToast } from '../contexts/ToastContext';
  9. import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
  10. import { assignableProjects } from '../utils/projectTree';
  11. interface BatchProjectModalProps {
  12. selectedIds: number[];
  13. onClose: () => void;
  14. }
  15. export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {
  16. const { t } = useTranslation();
  17. const queryClient = useQueryClient();
  18. const { showToast } = useToast();
  19. const [query, setQuery] = useState('');
  20. const { data: projects, isLoading } = useQuery({
  21. queryKey: ['projects'],
  22. queryFn: () => api.getProjects(),
  23. });
  24. // Assigning in bulk, so nothing here has a project to preserve: archived
  25. // ones drop off outright (#2888).
  26. const sortedProjects = useMemo(
  27. () => (projects ? assignableProjects([...projects].sort((a, b) => a.name.localeCompare(b.name))) : undefined),
  28. [projects],
  29. );
  30. const trimmed = query.trim().toLowerCase();
  31. const visibleProjects = trimmed
  32. ? sortedProjects?.filter((p) => p.name.toLowerCase().includes(trimmed))
  33. : sortedProjects;
  34. const showSearch = (sortedProjects?.length ?? 0) > 5;
  35. // Close on Escape key
  36. useEffect(() => {
  37. const handleKeyDown = (e: KeyboardEvent) => {
  38. if (e.key === 'Escape') onClose();
  39. };
  40. window.addEventListener('keydown', handleKeyDown);
  41. return () => window.removeEventListener('keydown', handleKeyDown);
  42. }, [onClose]);
  43. // Helper to invalidate all project-related queries. The shared version also
  44. // covers the timeline and file-progress views, which this list was missing.
  45. const invalidateProjectQueries = () => invalidateArchiveAndProjectViews(queryClient);
  46. // Assign to project mutation (uses bulk API)
  47. const assignMutation = useMutation({
  48. mutationFn: async (projectId: number) => {
  49. await api.addArchivesToProject(projectId, selectedIds);
  50. return projectId;
  51. },
  52. onSuccess: (projectId) => {
  53. const project = projects?.find(p => p.id === projectId);
  54. invalidateProjectQueries();
  55. showToast(`Added ${selectedIds.length} archive${selectedIds.length !== 1 ? 's' : ''} to "${project?.name}"`);
  56. onClose();
  57. },
  58. onError: () => {
  59. showToast('Failed to assign project', 'error');
  60. },
  61. });
  62. // Remove from project mutation (updates each archive individually)
  63. const removeMutation = useMutation({
  64. mutationFn: async () => {
  65. for (const id of selectedIds) {
  66. await api.updateArchive(id, { project_id: null });
  67. }
  68. return selectedIds.length;
  69. },
  70. onSuccess: (count) => {
  71. invalidateProjectQueries();
  72. showToast(`Removed ${count} archive${count !== 1 ? 's' : ''} from project`);
  73. onClose();
  74. },
  75. onError: () => {
  76. showToast('Failed to remove from project', 'error');
  77. },
  78. });
  79. const isPending = assignMutation.isPending || removeMutation.isPending;
  80. return (
  81. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  82. <Card className="w-full max-w-md max-h-[80vh] flex flex-col">
  83. <CardContent className="p-0 flex flex-col min-h-0">
  84. {/* Header */}
  85. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
  86. <div className="flex items-center gap-2">
  87. <FolderKanban className="w-5 h-5 text-bambu-green" />
  88. <h2 className="text-xl font-semibold text-white">
  89. Assign to Project
  90. </h2>
  91. </div>
  92. <button
  93. onClick={onClose}
  94. className="text-bambu-gray hover:text-white transition-colors"
  95. disabled={isPending}
  96. >
  97. <X className="w-5 h-5" />
  98. </button>
  99. </div>
  100. {/* Content */}
  101. <div className="p-4 space-y-3 overflow-y-auto min-h-0">
  102. <p className="text-sm text-bambu-gray">
  103. Assign {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} to a project
  104. </p>
  105. {isLoading ? (
  106. <div className="flex items-center justify-center py-8">
  107. <Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
  108. </div>
  109. ) : (
  110. <div className="space-y-2">
  111. {/* Remove from project option */}
  112. <button
  113. onClick={() => removeMutation.mutate()}
  114. disabled={isPending}
  115. className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50"
  116. >
  117. <div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0">
  118. <XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
  119. </div>
  120. <div className="min-w-0 flex-1">
  121. <p className="text-white font-medium">Remove from project</p>
  122. <p className="text-sm text-bambu-gray truncate">Clear project assignment</p>
  123. </div>
  124. {removeMutation.isPending && (
  125. <Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
  126. )}
  127. </button>
  128. {/* Divider */}
  129. {sortedProjects && sortedProjects.length > 0 && (
  130. <div className="flex items-center gap-2 py-2">
  131. <div className="flex-1 h-px bg-bambu-dark-tertiary" />
  132. <span className="text-xs text-bambu-gray">or assign to</span>
  133. <div className="flex-1 h-px bg-bambu-dark-tertiary" />
  134. </div>
  135. )}
  136. {/* Search input */}
  137. {showSearch && (
  138. <div className="relative">
  139. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  140. <input
  141. type="text"
  142. value={query}
  143. onChange={(e) => setQuery(e.target.value)}
  144. placeholder={t('archives.menu.searchProjects')}
  145. className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-sm focus:border-bambu-green focus:outline-none"
  146. />
  147. </div>
  148. )}
  149. {/* Project list */}
  150. {visibleProjects?.map((project) => (
  151. <button
  152. key={project.id}
  153. onClick={() => assignMutation.mutate(project.id)}
  154. disabled={isPending}
  155. className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50"
  156. >
  157. <div
  158. className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  159. style={{ backgroundColor: project.color ? `${project.color}20` : 'rgb(var(--bambu-green) / 0.2)' }}
  160. >
  161. <FolderKanban
  162. className="w-4 h-4"
  163. style={{ color: project.color || 'rgb(var(--bambu-green))' }}
  164. />
  165. </div>
  166. <div className="min-w-0 flex-1">
  167. <p className="text-white font-medium truncate">{project.name}</p>
  168. <p className="text-sm text-bambu-gray truncate">
  169. {project.archive_count} archive{project.archive_count !== 1 ? 's' : ''}
  170. {project.status && ` • ${project.status}`}
  171. </p>
  172. </div>
  173. {assignMutation.isPending && assignMutation.variables === project.id && (
  174. <Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
  175. )}
  176. </button>
  177. ))}
  178. {(!sortedProjects || sortedProjects.length === 0) && (
  179. <p className="text-center text-bambu-gray py-4">
  180. No projects yet. Create one from the Projects page.
  181. </p>
  182. )}
  183. {sortedProjects && sortedProjects.length > 0 && visibleProjects?.length === 0 && (
  184. <p className="text-center text-bambu-gray text-sm py-4">—</p>
  185. )}
  186. </div>
  187. )}
  188. </div>
  189. {/* Footer */}
  190. <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary shrink-0">
  191. <Button variant="secondary" onClick={onClose} className="flex-1" disabled={isPending}>
  192. Cancel
  193. </Button>
  194. </div>
  195. </CardContent>
  196. </Card>
  197. </div>
  198. );
  199. }