mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
The reporter opens a project per job and archives it when the job is done, so the Project dropdown in Edit Archive listed five live projects behind thirty-odd finished ones, in one unscrolled run with nothing to tell them apart. Archived is the state that means "put this away", so that is what the pickers now drop: the Edit Archive dropdown, the pending-uploads panel, the bulk Add to Project dialog and the File Manager's folder link. Completed stays. It says the work is finished, not that it should be hidden, and filing a reprint under a finished project is ordinary. The Archives right-click submenu had the opposite bug and offered active projects only, so a completed project was reachable from the edit dialog and not from the menu next to it. All five surfaces share one rule now. Whatever a thing is already filed under survives the filter whatever its status. A select holding a value that matches none of its options is reset by the browser to the first one, and here that reads "No project" -- an archive sitting in an archived project would have said in as many words that it was filed nowhere. The stored id does survive an untouched save; it is the field that lies. The parent-project picker is deliberately untouched. A finished or archived project is still a legal parent, and its own comment says so. Fixed alongside it, and reported separately: the status tabs counted only the projects the selected filter had already let through, so every tab but the current one counted zero and dropped its badge. Switching tabs moved the number rather than showing four of them. They are counted from the unfiltered list the page already fetches for the sub-project captions -- same query key, no extra request. The new rule is one function with its own unit tests, since five callers now depend on it reading the same way. Each half is pinned separately: dropping the filter, dropping the kept id, restoring active-only, and counting from the filtered list each fail their own tests and nothing else.
219 lines
9.2 KiB
TypeScript
219 lines
9.2 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { X, FolderKanban, Loader2, XCircle, Search } from 'lucide-react';
|
|
import { api } from '../api/client';
|
|
import { Card, CardContent } from './Card';
|
|
import { Button } from './Button';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
|
|
import { assignableProjects } from '../utils/projectTree';
|
|
|
|
interface BatchProjectModalProps {
|
|
selectedIds: number[];
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const { showToast } = useToast();
|
|
const [query, setQuery] = useState('');
|
|
|
|
const { data: projects, isLoading } = useQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: () => api.getProjects(),
|
|
});
|
|
|
|
// Assigning in bulk, so nothing here has a project to preserve: archived
|
|
// ones drop off outright (#2888).
|
|
const sortedProjects = useMemo(
|
|
() => (projects ? assignableProjects([...projects].sort((a, b) => a.name.localeCompare(b.name))) : undefined),
|
|
[projects],
|
|
);
|
|
|
|
const trimmed = query.trim().toLowerCase();
|
|
const visibleProjects = trimmed
|
|
? sortedProjects?.filter((p) => p.name.toLowerCase().includes(trimmed))
|
|
: sortedProjects;
|
|
const showSearch = (sortedProjects?.length ?? 0) > 5;
|
|
|
|
// Close on Escape key
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
// Helper to invalidate all project-related queries. The shared version also
|
|
// covers the timeline and file-progress views, which this list was missing.
|
|
const invalidateProjectQueries = () => invalidateArchiveAndProjectViews(queryClient);
|
|
|
|
// Assign to project mutation (uses bulk API)
|
|
const assignMutation = useMutation({
|
|
mutationFn: async (projectId: number) => {
|
|
await api.addArchivesToProject(projectId, selectedIds);
|
|
return projectId;
|
|
},
|
|
onSuccess: (projectId) => {
|
|
const project = projects?.find(p => p.id === projectId);
|
|
invalidateProjectQueries();
|
|
showToast(`Added ${selectedIds.length} archive${selectedIds.length !== 1 ? 's' : ''} to "${project?.name}"`);
|
|
onClose();
|
|
},
|
|
onError: () => {
|
|
showToast('Failed to assign project', 'error');
|
|
},
|
|
});
|
|
|
|
// Remove from project mutation (updates each archive individually)
|
|
const removeMutation = useMutation({
|
|
mutationFn: async () => {
|
|
for (const id of selectedIds) {
|
|
await api.updateArchive(id, { project_id: null });
|
|
}
|
|
return selectedIds.length;
|
|
},
|
|
onSuccess: (count) => {
|
|
invalidateProjectQueries();
|
|
showToast(`Removed ${count} archive${count !== 1 ? 's' : ''} from project`);
|
|
onClose();
|
|
},
|
|
onError: () => {
|
|
showToast('Failed to remove from project', 'error');
|
|
},
|
|
});
|
|
|
|
const isPending = assignMutation.isPending || removeMutation.isPending;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<Card className="w-full max-w-md max-h-[80vh] flex flex-col">
|
|
<CardContent className="p-0 flex flex-col min-h-0">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<FolderKanban className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-xl font-semibold text-white">
|
|
Assign to Project
|
|
</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-bambu-gray hover:text-white transition-colors"
|
|
disabled={isPending}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-4 space-y-3 overflow-y-auto min-h-0">
|
|
<p className="text-sm text-bambu-gray">
|
|
Assign {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} to a project
|
|
</p>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{/* Remove from project option */}
|
|
<button
|
|
onClick={() => removeMutation.mutate()}
|
|
disabled={isPending}
|
|
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"
|
|
>
|
|
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0">
|
|
<XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-white font-medium">Remove from project</p>
|
|
<p className="text-sm text-bambu-gray truncate">Clear project assignment</p>
|
|
</div>
|
|
{removeMutation.isPending && (
|
|
<Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Divider */}
|
|
{sortedProjects && sortedProjects.length > 0 && (
|
|
<div className="flex items-center gap-2 py-2">
|
|
<div className="flex-1 h-px bg-bambu-dark-tertiary" />
|
|
<span className="text-xs text-bambu-gray">or assign to</span>
|
|
<div className="flex-1 h-px bg-bambu-dark-tertiary" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Search input */}
|
|
{showSearch && (
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t('archives.menu.searchProjects')}
|
|
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"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Project list */}
|
|
{visibleProjects?.map((project) => (
|
|
<button
|
|
key={project.id}
|
|
onClick={() => assignMutation.mutate(project.id)}
|
|
disabled={isPending}
|
|
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"
|
|
>
|
|
<div
|
|
className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
|
|
style={{ backgroundColor: project.color ? `${project.color}20` : 'rgb(var(--bambu-green) / 0.2)' }}
|
|
>
|
|
<FolderKanban
|
|
className="w-4 h-4"
|
|
style={{ color: project.color || 'rgb(var(--bambu-green))' }}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-white font-medium truncate">{project.name}</p>
|
|
<p className="text-sm text-bambu-gray truncate">
|
|
{project.archive_count} archive{project.archive_count !== 1 ? 's' : ''}
|
|
{project.status && ` • ${project.status}`}
|
|
</p>
|
|
</div>
|
|
{assignMutation.isPending && assignMutation.variables === project.id && (
|
|
<Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
|
|
)}
|
|
</button>
|
|
))}
|
|
|
|
{(!sortedProjects || sortedProjects.length === 0) && (
|
|
<p className="text-center text-bambu-gray py-4">
|
|
No projects yet. Create one from the Projects page.
|
|
</p>
|
|
)}
|
|
|
|
{sortedProjects && sortedProjects.length > 0 && visibleProjects?.length === 0 && (
|
|
<p className="text-center text-bambu-gray text-sm py-4">—</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary shrink-0">
|
|
<Button variant="secondary" onClick={onClose} className="flex-1" disabled={isPending}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|