BatchProjectModal.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { useEffect } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { X, FolderKanban, Loader2, XCircle } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import { Card, CardContent } from './Card';
  6. import { Button } from './Button';
  7. import { useToast } from '../contexts/ToastContext';
  8. interface BatchProjectModalProps {
  9. selectedIds: number[];
  10. onClose: () => void;
  11. }
  12. export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {
  13. const queryClient = useQueryClient();
  14. const { showToast } = useToast();
  15. const { data: projects, isLoading } = useQuery({
  16. queryKey: ['projects'],
  17. queryFn: () => api.getProjects(),
  18. });
  19. // Close on Escape key
  20. useEffect(() => {
  21. const handleKeyDown = (e: KeyboardEvent) => {
  22. if (e.key === 'Escape') onClose();
  23. };
  24. window.addEventListener('keydown', handleKeyDown);
  25. return () => window.removeEventListener('keydown', handleKeyDown);
  26. }, [onClose]);
  27. // Helper to invalidate all project-related queries
  28. const invalidateProjectQueries = () => {
  29. queryClient.invalidateQueries({ queryKey: ['archives'] });
  30. queryClient.invalidateQueries({ queryKey: ['projects'] });
  31. // Invalidate project detail pages (partial match catches all project IDs)
  32. queryClient.invalidateQueries({ queryKey: ['project'] });
  33. queryClient.invalidateQueries({ queryKey: ['project-archives'] });
  34. };
  35. // Assign to project mutation (uses bulk API)
  36. const assignMutation = useMutation({
  37. mutationFn: async (projectId: number) => {
  38. await api.addArchivesToProject(projectId, selectedIds);
  39. return projectId;
  40. },
  41. onSuccess: (projectId) => {
  42. const project = projects?.find(p => p.id === projectId);
  43. invalidateProjectQueries();
  44. showToast(`Added ${selectedIds.length} archive${selectedIds.length !== 1 ? 's' : ''} to "${project?.name}"`);
  45. onClose();
  46. },
  47. onError: () => {
  48. showToast('Failed to assign project', 'error');
  49. },
  50. });
  51. // Remove from project mutation (updates each archive individually)
  52. const removeMutation = useMutation({
  53. mutationFn: async () => {
  54. for (const id of selectedIds) {
  55. await api.updateArchive(id, { project_id: null });
  56. }
  57. return selectedIds.length;
  58. },
  59. onSuccess: (count) => {
  60. invalidateProjectQueries();
  61. showToast(`Removed ${count} archive${count !== 1 ? 's' : ''} from project`);
  62. onClose();
  63. },
  64. onError: () => {
  65. showToast('Failed to remove from project', 'error');
  66. },
  67. });
  68. const isPending = assignMutation.isPending || removeMutation.isPending;
  69. return (
  70. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  71. <Card className="w-full max-w-md max-h-[80vh] flex flex-col">
  72. <CardContent className="p-0 flex flex-col min-h-0">
  73. {/* Header */}
  74. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
  75. <div className="flex items-center gap-2">
  76. <FolderKanban className="w-5 h-5 text-bambu-green" />
  77. <h2 className="text-xl font-semibold text-white">
  78. Assign to Project
  79. </h2>
  80. </div>
  81. <button
  82. onClick={onClose}
  83. className="text-bambu-gray hover:text-white transition-colors"
  84. disabled={isPending}
  85. >
  86. <X className="w-5 h-5" />
  87. </button>
  88. </div>
  89. {/* Content */}
  90. <div className="p-4 space-y-3 overflow-y-auto min-h-0">
  91. <p className="text-sm text-bambu-gray">
  92. Assign {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} to a project
  93. </p>
  94. {isLoading ? (
  95. <div className="flex items-center justify-center py-8">
  96. <Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
  97. </div>
  98. ) : (
  99. <div className="space-y-2">
  100. {/* Remove from project option */}
  101. <button
  102. onClick={() => removeMutation.mutate()}
  103. disabled={isPending}
  104. 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"
  105. >
  106. <div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0">
  107. <XCircle className="w-4 h-4 text-red-400" />
  108. </div>
  109. <div className="min-w-0 flex-1">
  110. <p className="text-white font-medium">Remove from project</p>
  111. <p className="text-sm text-bambu-gray truncate">Clear project assignment</p>
  112. </div>
  113. {removeMutation.isPending && (
  114. <Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
  115. )}
  116. </button>
  117. {/* Divider */}
  118. {projects && projects.length > 0 && (
  119. <div className="flex items-center gap-2 py-2">
  120. <div className="flex-1 h-px bg-bambu-dark-tertiary" />
  121. <span className="text-xs text-bambu-gray">or assign to</span>
  122. <div className="flex-1 h-px bg-bambu-dark-tertiary" />
  123. </div>
  124. )}
  125. {/* Project list */}
  126. {projects?.map((project) => (
  127. <button
  128. key={project.id}
  129. onClick={() => assignMutation.mutate(project.id)}
  130. disabled={isPending}
  131. 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"
  132. >
  133. <div
  134. className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  135. style={{ backgroundColor: project.color ? `${project.color}20` : 'rgb(var(--bambu-green) / 0.2)' }}
  136. >
  137. <FolderKanban
  138. className="w-4 h-4"
  139. style={{ color: project.color || 'rgb(var(--bambu-green))' }}
  140. />
  141. </div>
  142. <div className="min-w-0 flex-1">
  143. <p className="text-white font-medium truncate">{project.name}</p>
  144. <p className="text-sm text-bambu-gray truncate">
  145. {project.archive_count} archive{project.archive_count !== 1 ? 's' : ''}
  146. {project.status && ` • ${project.status}`}
  147. </p>
  148. </div>
  149. {assignMutation.isPending && assignMutation.variables === project.id && (
  150. <Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
  151. )}
  152. </button>
  153. ))}
  154. {(!projects || projects.length === 0) && (
  155. <p className="text-center text-bambu-gray py-4">
  156. No projects yet. Create one from the Projects page.
  157. </p>
  158. )}
  159. </div>
  160. )}
  161. </div>
  162. {/* Footer */}
  163. <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary shrink-0">
  164. <Button variant="secondary" onClick={onClose} className="flex-1" disabled={isPending}>
  165. Cancel
  166. </Button>
  167. </div>
  168. </CardContent>
  169. </Card>
  170. </div>
  171. );
  172. }