Просмотр исходного кода

Leave archived projects out of the pickers that file work (issue #2888)

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.
maziggy 2 недель назад
Родитель
Сommit
e4aab7b44b

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by @gyrene2083)** — Reading back a slot's pressure-advance value meant hovering it: the K factor lived in the filament popover alone, so checking whether a calibration had actually taken across four slots was four hovers, and comparing two of them side by side was not possible at all. Every slot card now carries the value under the material name, the way Bambu Studio shows it per slot — on regular AMS units, on AMS-HT, and on the external spool of a dual-nozzle machine. Only a value the printer actually reported is shown: a loaded but never-calibrated slot stays blank rather than inheriting the 0.020 that fills the popover's own field, and a slot the firmware reports as exactly 0 counts as uncalibrated the same way the stored K-profiles do. The label is shortened to **K** with the full localized name on hover, because "K Factor", "K-Faktor" and "Facteur K" ate the value itself — the whole point of the line — on cards under about 350px, and the figure is set in tabular numerals so it measures the same in Safari as in Chromium. Where one slot of a unit is calibrated and its neighbours are not, the neighbours hold the same row open so the fill bars stay level across the card.
 
 ### Fixed
+- **Archived projects crowded out the live ones in every project picker (#2888, reported by @e77)** — The reporter files each job under its own project and archives it when the job is done, so five active projects sat behind thirty-odd finished ones in the Project dropdown of the Edit Archive dialog — an unscrolled list of everything ever created, with no way to tell which entries were still live. That dropdown, the one on the pending-uploads panel, the bulk "Add to Project" dialog and the File Manager's folder link now leave archived projects out. Completed projects stay: a project marked completed says the work is done, not that it should be hidden, and filing a reprint under one is ordinary. The Archives right-click submenu had gone the other way and offered active projects only, so a completed project was reachable from the Edit dialog and not from the menu beside it; all five surfaces now apply the same rule. Whatever an archive is already filed under stays on its own list whatever its status — a `<select>` holding a value none of its options match is reset by the browser to the first one, which here reads "No project", so an archive in an archived project would have stated that it was filed nowhere. The one picker deliberately left alone is the parent-project picker, where a finished or archived project is still a legal parent. Fixed alongside it: the status tabs on the Projects page counted only the projects the selected filter had already let through, so every tab but the current one counted zero and lost its badge entirely — the reporter's own screenshot shows "Active 5" beside a bare Completed and a bare Archived, with thirty projects behind them. They are counted from the whole fleet now. Covered by frontend tests.
 - **The full-page G-code preview grew without limit and never drew anything (#2887, reported by @ojimpo)** — Opening a 3D Preview from Archives left an empty white pane with the legend and layer slider floating over it, while the page's scrollbar shrank for as long as it stayed open — about 190px of page height per second, without stopping. Two faults compounded. The viewer appends its canvas into the very element it measures and watches for resizes, and three.js writes each new size onto the canvas as inline style, leaving it `display: inline` so the line box adds descender space on top; on a page where that element takes its height from its contents, the canvas was sizing the box that sizes the canvas, gaining a fixed 33px every round. And the page never gave it a height to take instead: the viewer pane is `flex-1 min-h-0`, which divides nothing unless the column above it is a definite height, and `h-full` is a percentage that resolves against a main area whose own height comes from a `min-height` — a floor, not a size — so it fell through to the content. Nothing was ever drawn because each observer callback reallocated and cleared the frame buffer before an antialiased render of what had grown to roughly 18 megapixels could finish. The canvas is now positioned out of flow, so it cannot contribute to the height of the element that measures it on any page, present or future, and that element takes a definite height from the pane around it rather than a percentage; the page itself is sized from the viewport the same way the File Manager page already was. The same viewer inside the File Manager dialog was never affected — a dialog gives it a fixed height, so neither fault could arise there. Nothing was wrong with the data path at any point: the legend and the layer slider were built from the parsed toolpath throughout, so the fetch, the parse and the layer split had all succeeded.
 - **A print could not start on the nozzle sitting in the H2C's rack (#2885, reported by @apizz)** — A job sliced for a 0.2mm nozzle failed in the queue with "install the matching nozzle before printing", even though a 0.2mm nozzle was in the tool-changer rack and the printer would have fetched it. Only picking the nozzle up by hand on the printer's own screen first let the print run, and because the item failed rather than waited, the rest of the queue went with it. The reporter noticed the giveaway: going *to* 0.4mm always worked, going *to* 0.2mm never did. The nozzle-diameter guard that catches a genuinely wrong slice before upload was measuring the wrong thing — it compared the sliced diameter against the two mounted hotends only, so on a machine whose hotends both read 0.4mm nothing but 0.4mm could ever pass, and the rack picker that would have fetched the right one runs further down the same dispatch and never got the chance. The rack now counts as reachable: a diameter parked in any dock satisfies the guard the same way a mounted one does. This was never only about 0.2mm — a 0.6mm slice was blocked identically. A diameter that is in neither a hotend nor a dock still stops the print before it uploads, and the message now lists both sets so it is clear what the machine actually has. Also fixed alongside it: an empty carriage keeps reporting the diameter of the nozzle it last held, so a hotend that had parked its nozzle back in the rack was counted as a mounted 0.4mm that was not there — presence is now read from the nozzle's own temperature rating and serial number, and a hotend is only discarded when both agree it is empty. Printers that report no rack at all are unaffected.
 - **An AMS slot could name the wrong white** — A hex is not one colour in Bambu's range: `#FFFFFF` is Jade White in PLA Basic, Ivory White in PLA Matte and plain White in six other materials, and `#000000` is Charcoal in PLA Matte where it is Black everywhere else. The slot popover looked the colour up by hex alone, and the lookup table can only keep one name per hex — so an ivory Matte spool was titled "Jade White" even while the profile line beside it correctly read Matte Ivory. The colour map now also carries the names that collapsing loses, keyed by material, and every slot resolves its colour with the material the printer reports for it (`tray_sub_brands`). Slots with a spool assigned from Inventory are titled with that spool's own colour name, which is the roll the user actually put in. Only a name the same brand's own range lost is carried — one manufacturer's name must not displace another's — so the added map is 11 entries against the 608 in the shipped catalog.

+ 77 - 0
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -387,4 +387,81 @@ describe('EditArchiveModal', () => {
       });
     });
   });
+  describe('project picker (#2888)', () => {
+    // Statuses matter here, so this describe brings its own list rather than
+    // the bare one the rest of the file shares.
+    const withStatuses = (rows: Array<Record<string, unknown>>) =>
+      server.use(http.get('/api/v1/projects/', () => HttpResponse.json(rows)));
+
+    function savedBody() {
+      const seen: { body?: Record<string, unknown> } = {};
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          seen.body = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockArchive, ...seen.body });
+        }),
+      );
+      return seen;
+    }
+
+    it('leaves archived projects out of the list', async () => {
+      withStatuses([
+        { id: 1, name: 'Live Work', color: '#00ae42', status: 'active' },
+        { id: 2, name: 'Last Year', color: '#888888', status: 'archived' },
+      ]);
+
+      render(<EditArchiveModal archive={mockArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+
+      await screen.findByRole('option', { name: 'Live Work' });
+      expect(screen.queryByRole('option', { name: 'Last Year' })).not.toBeInTheDocument();
+    });
+
+    it('keeps completed projects, which are still worth filing a reprint under', async () => {
+      withStatuses([
+        { id: 1, name: 'Live Work', color: '#00ae42', status: 'active' },
+        { id: 3, name: 'Shipped', color: '#888888', status: 'completed' },
+      ]);
+
+      render(<EditArchiveModal archive={mockArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+
+      expect(await screen.findByRole('option', { name: 'Shipped' })).toBeInTheDocument();
+    });
+
+    it('still offers the archived project this archive is already in', async () => {
+      // Filtered out, the select holds a value no option matches, and the
+      // browser resets it to the first option -- "No project". The archive
+      // would say it is filed nowhere while sitting in a project.
+      withStatuses([
+        { id: 1, name: 'Live Work', color: '#00ae42', status: 'active' },
+        { id: 2, name: 'Last Year', color: '#888888', status: 'archived' },
+      ]);
+      const filed = { ...mockArchive, project_id: 2 };
+
+      render(<EditArchiveModal archive={filed} onClose={mockOnClose} onSave={mockOnSave} />);
+
+      const option = await screen.findByRole('option', { name: 'Last Year' });
+      expect((option as HTMLOptionElement).selected).toBe(true);
+    });
+
+    it('saves the project it was already in when nothing else is touched', async () => {
+      const user = userEvent.setup();
+      const seen = savedBody();
+      withStatuses([{ id: 2, name: 'Last Year', color: '#888888', status: 'archived' }]);
+
+      render(
+        <EditArchiveModal
+          archive={{ ...mockArchive, project_id: 2 }}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />,
+      );
+      await screen.findByRole('option', { name: 'Last Year' });
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      // The stored id survives the round trip untouched: showing the archived
+      // project is what makes the field honest, and it must not also change
+      // what an untouched save writes.
+      await waitFor(() => expect(seen.body?.project_id).toBe(2));
+    });
+  });
 });

+ 40 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -375,6 +375,46 @@ describe('ArchivesPage', () => {
 
   // #1153 — Sylvain wanted to differentiate VP-uploaded archives (status='archived',
   // never sent to a printer) from those that have been printed at least once.
+  describe('add-to-project submenu (#2888)', () => {
+    // The submenu used to offer active projects only, which left a completed
+    // project unreachable from here while the Edit dialog still offered it.
+    // Both now hide archived projects and nothing else.
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            { id: 1, name: 'Functional Parts', color: '#00ae42', status: 'active' },
+            { id: 2, name: 'Shipped Last Month', color: '#0088ff', status: 'completed' },
+            { id: 3, name: 'Season 2025', color: '#888888', status: 'archived' },
+          ]),
+        ),
+      );
+    });
+
+    const openSubmenu = async () => {
+      const card = await screen.findByText('Benchy');
+      fireEvent.contextMenu(card);
+      fireEvent.click(await screen.findByText('Add to Project'));
+    };
+
+    it('offers a completed project', async () => {
+      render(<ArchivesPage />);
+      await openSubmenu();
+
+      expect(await screen.findByText('Shipped Last Month')).toBeInTheDocument();
+    });
+
+    it('leaves archived projects out', async () => {
+      render(<ArchivesPage />);
+      await openSubmenu();
+
+      // Anchored on the completed one rather than the active one: the active
+      // project's name is also drawn on an archive card behind the menu.
+      await screen.findByText('Shipped Last Month');
+      expect(screen.queryByText('Season 2025')).not.toBeInTheDocument();
+    });
+  });
+
   describe('Not Printed / Printed collections', () => {
     const mixedStatusArchives = [
       { ...mockArchives[0], id: 100, print_name: 'NeverPrinted', status: 'archived', started_at: null, completed_at: null },

+ 50 - 0
frontend/src/__tests__/pages/ProjectsPage.test.tsx

@@ -467,6 +467,56 @@ describe('ProjectsPage', () => {
       expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null }));
     });
   });
+  describe('status tab badges (#2888)', () => {
+    // Every tab is counted from the whole fleet, not from the page below it.
+    const fleet = [
+      { ...mockProjects[0], id: 1, status: 'active' },
+      { ...mockProjects[1], id: 2, status: 'completed' },
+      { ...mockProjects[1], id: 3, name: 'Last Year', status: 'archived' },
+      { ...mockProjects[1], id: 4, name: 'Year Before', status: 'archived' },
+    ];
+
+    beforeEach(() => {
+      // Stands in for the API's own status filter, so `projects` really does
+      // hold one status at a time the way it does in the app.
+      server.use(
+        http.get('/api/v1/projects/', ({ request }) => {
+          const status = new URL(request.url).searchParams.get('status');
+          return HttpResponse.json(status ? fleet.filter((p) => p.status === status) : fleet);
+        }),
+      );
+    });
+
+    const badgeFor = (label: string) =>
+      screen.getByRole('button', { name: new RegExp(`^${label}`) }).querySelector('span:last-child');
+
+    it('counts the tabs the filter is hiding', async () => {
+      // The page opens on Active, so before this every other tab counted zero
+      // and dropped its badge -- the reporter's fleet of thirty finished
+      // projects showed Completed bare next to "Active 5".
+      render(<ProjectsPage />);
+
+      await waitFor(() => expect(badgeFor('Active')).toHaveTextContent('1'));
+      expect(badgeFor('Completed')).toHaveTextContent('1');
+      expect(badgeFor('Archived')).toHaveTextContent('2');
+      expect(badgeFor('All')).toHaveTextContent('4');
+    });
+
+    it('keeps the same counts after switching tabs', async () => {
+      const user = userEvent.setup();
+      render(<ProjectsPage />);
+      await waitFor(() => expect(badgeFor('Archived')).toHaveTextContent('2'));
+
+      await user.click(screen.getByRole('button', { name: /^Archived/ }));
+
+      // Only the page below changes; the badges describe the fleet either way.
+      await waitFor(() => expect(screen.getByText('Last Year')).toBeInTheDocument());
+      expect(badgeFor('Active')).toHaveTextContent('1');
+      expect(badgeFor('Completed')).toHaveTextContent('1');
+      expect(badgeFor('All')).toHaveTextContent('4');
+    });
+  });
+
   describe('nesting projects under a master project (#1264)', () => {
     const listItem = (over: Record<string, unknown>) => ({
       id: 1,

+ 121 - 0
frontend/src/__tests__/utils/assignableProjects.test.ts

@@ -0,0 +1,121 @@
+/**
+ * Which projects a picker offers when filing something away (#2888).
+ *
+ * The reporter had five active projects sitting behind thirty-odd finished
+ * ones, and every dropdown that assigns an archive listed all of them. This is
+ * the rule those pickers now share: archived out, completed in, and whatever
+ * the thing being edited already belongs to stays regardless.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { assignableProjects } from '../../utils/projectTree';
+import type { ProjectListItem } from '../../api/client';
+
+const project = (over: Partial<ProjectListItem>): ProjectListItem => ({
+  id: 1,
+  name: 'Airframe',
+  description: null,
+  color: '#00ae42',
+  status: 'active',
+  target_count: null,
+  target_parts_count: null,
+  target_sets: null,
+  budget: null,
+  tags: null,
+  due_date: null,
+  priority: 'normal',
+  created_at: '2026-01-01T00:00:00Z',
+  archive_count: 0,
+  total_items: 0,
+  completed_count: 0,
+  failed_count: 0,
+  queue_count: 0,
+  progress_percent: null,
+  parent_id: null,
+  child_count: 0,
+  archives: [],
+  url: null,
+  cover_image_filename: null,
+  ...over,
+});
+
+const ids = (rows: ProjectListItem[]) => rows.map((p) => p.id);
+
+describe('assignableProjects', () => {
+  it('drops archived projects', () => {
+    const projects = [
+      project({ id: 1, status: 'active' }),
+      project({ id: 2, status: 'archived' }),
+    ];
+
+    expect(ids(assignableProjects(projects))).toEqual([1]);
+  });
+
+  it('keeps completed projects', () => {
+    // Filing a reprint against a finished project is ordinary work --
+    // "completed" says the job is done, not that it should be hidden.
+    const projects = [
+      project({ id: 1, status: 'active' }),
+      project({ id: 2, status: 'completed' }),
+      project({ id: 3, status: 'archived' }),
+    ];
+
+    expect(ids(assignableProjects(projects))).toEqual([1, 2]);
+  });
+
+  it('keeps the archived project the caller names', () => {
+    // A controlled <select> holding a value no option matches is reset by the
+    // browser to its first option -- "No project" -- so an archive filed in an
+    // archived project would claim to be filed nowhere.
+    const projects = [
+      project({ id: 1, status: 'active' }),
+      project({ id: 2, status: 'archived' }),
+    ];
+
+    expect(ids(assignableProjects(projects, 2))).toEqual([1, 2]);
+  });
+
+  it('keeps only the named archived project, not archived ones generally', () => {
+    const projects = [
+      project({ id: 2, status: 'archived' }),
+      project({ id: 3, status: 'archived' }),
+    ];
+
+    expect(ids(assignableProjects(projects, 3))).toEqual([3]);
+  });
+
+  it('treats no current project as naming nothing', () => {
+    // The pickers pass `archive.project_id` straight through, and an
+    // unassigned archive carries null there. Reading null as an id would be
+    // harmless today only because no project has that id -- but a picker with
+    // no argument at all must behave the same way.
+    const projects = [project({ id: 1, status: 'archived' })];
+
+    expect(assignableProjects(projects, null)).toEqual([]);
+    expect(assignableProjects(projects, undefined)).toEqual([]);
+    expect(assignableProjects(projects)).toEqual([]);
+  });
+
+  it('leaves the order it was given', () => {
+    // Every caller sorts by name either side of this, so reordering here
+    // would silently fight them.
+    const projects = [
+      project({ id: 3, name: 'Wing' }),
+      project({ id: 1, name: 'Airframe' }),
+      project({ id: 2, name: 'Spar' }),
+    ];
+
+    expect(ids(assignableProjects(projects))).toEqual([3, 1, 2]);
+  });
+
+  it('does not modify the array it is given', () => {
+    const projects = [
+      project({ id: 1, status: 'active' }),
+      project({ id: 2, status: 'archived' }),
+    ];
+
+    assignableProjects(projects);
+
+    expect(ids(projects)).toEqual([1, 2]);
+  });
+});

+ 4 - 1
frontend/src/components/BatchProjectModal.tsx

@@ -7,6 +7,7 @@ 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[];
@@ -24,8 +25,10 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
     queryFn: () => api.getProjects(),
   });
 
+  // Assigning in bulk, so nothing here has a project to preserve: archived
+  // ones drop off outright (#2888).
   const sortedProjects = useMemo(
-    () => (projects ? [...projects].sort((a, b) => a.name.localeCompare(b.name)) : undefined),
+    () => (projects ? assignableProjects([...projects].sort((a, b) => a.name.localeCompare(b.name))) : undefined),
     [projects],
   );
 

+ 12 - 2
frontend/src/components/EditArchiveModal.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useMemo, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link, Weight } from 'lucide-react';
@@ -7,6 +7,7 @@ import type { Archive } from '../api/client';
 import { Button } from './Button';
 import { PrintLogTable } from './PrintLogTable';
 import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
+import { assignableProjects } from '../utils/projectTree';
 
 // Keys for failure reasons - translated at render time.
 // Exported so the Print Log per-row classification editor (#1687 part 4)
@@ -98,6 +99,15 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
   });
 
+  // Archived projects drop off the list, except the one this archive is
+  // already filed under. That one has to stay: a select holding a value with
+  // no matching option resets to the first one, so the field would read
+  // "No project" for an archive that is in one (#2888).
+  const projectOptions = useMemo(
+    () => assignableProjects(projects ?? [], archive.project_id),
+    [projects, archive.project_id],
+  );
+
   // Fetch all tags using the dedicated API
   const { data: tagsData } = useQuery({
     queryKey: ['tags'],
@@ -317,7 +327,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             >
               <option value="">{t('editArchive.noProject')}</option>
-              {projects?.map((p) => (
+              {projectOptions.map((p) => (
                 <option key={p.id} value={p.id}>
                   {p.name}
                 </option>

+ 4 - 2
frontend/src/components/PendingUploadsPanel.tsx

@@ -9,6 +9,7 @@ import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { ConfirmModal } from './ConfirmModal';
 import { formatFileSize } from '../utils/file';
+import { assignableProjects } from '../utils/projectTree';
 
 function formatTimeAgo(dateStr: string): string {
   const date = new Date(dateStr);
@@ -183,11 +184,12 @@ export function PendingUploadsPanel() {
     refetchInterval: 10000, // Refresh every 10 seconds
   });
 
-  // Fetch projects for dropdown
+  // Fetch projects for dropdown. Nothing pending is filed anywhere yet, so
+  // there is no current project to hold on to -- archived ones just go (#2888).
   const { data: projects } = useQuery({
     queryKey: ['projects'],
     queryFn: () => api.getProjects(),
-    select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
+    select: (rows) => assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name))),
   });
 
   // Archive mutation

+ 19 - 10
frontend/src/pages/ArchivesPage.tsx

@@ -69,6 +69,7 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
 import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
+import { assignableProjects } from '../utils/projectTree';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
@@ -767,7 +768,7 @@ function ArchiveCard({
       onClick: () => {},
       disabled: !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
-      submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5
+      submenuSearchPlaceholder: assignableProjects(projects ?? [], archive.project_id).length > 5
         ? t('archives.menu.searchProjects')
         : undefined,
       submenu: (() => {
@@ -792,10 +793,14 @@ function ArchiveCard({
             disabled: true,
           });
         } else {
-          const activeProjects = projects
-            .filter(p => p.status === 'active')
+          // Archived projects are put away on purpose and are left out;
+          // completed ones stay, since a reprint filed against a finished
+          // project is ordinary (#2888). The archive's own project is kept
+          // whatever its status -- it is disabled below, and dropping it
+          // would leave the menu unable to say where the archive already is.
+          const assignable = assignableProjects(projects, archive.project_id)
             .sort((a, b) => a.name.localeCompare(b.name));
-          if (activeProjects.length === 0) {
+          if (assignable.length === 0) {
             items.push({
               label: t('archives.menu.noProjectsAvailable'),
               icon: <FolderKanban className="w-4 h-4 opacity-50" />,
@@ -803,7 +808,7 @@ function ArchiveCard({
               disabled: true,
             });
           } else {
-            activeProjects.forEach(p => {
+            assignable.forEach(p => {
               items.push({
                 label: p.name,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
@@ -2156,7 +2161,7 @@ function ArchiveListRow({
       label: t('archives.menu.addToProject'),
       icon: <FolderKanban className="w-4 h-4" />,
       onClick: () => {},
-      submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5
+      submenuSearchPlaceholder: assignableProjects(projects ?? [], archive.project_id).length > 5
         ? t('archives.menu.searchProjects')
         : undefined,
       submenu: (() => {
@@ -2176,10 +2181,14 @@ function ArchiveListRow({
             disabled: true,
           });
         } else {
-          const activeProjects = projects
-            .filter(p => p.status === 'active')
+          // Archived projects are put away on purpose and are left out;
+          // completed ones stay, since a reprint filed against a finished
+          // project is ordinary (#2888). The archive's own project is kept
+          // whatever its status -- it is disabled below, and dropping it
+          // would leave the menu unable to say where the archive already is.
+          const assignable = assignableProjects(projects, archive.project_id)
             .sort((a, b) => a.name.localeCompare(b.name));
-          if (activeProjects.length === 0) {
+          if (assignable.length === 0) {
             items.push({
               label: t('archives.menu.noProjectsAvailable'),
               icon: <FolderKanban className="w-4 h-4 opacity-50" />,
@@ -2187,7 +2196,7 @@ function ArchiveListRow({
               disabled: true,
             });
           } else {
-            activeProjects.forEach(p => {
+            assignable.forEach(p => {
               items.push({
                 label: p.name,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,

+ 6 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -74,6 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
+import { assignableProjects } from '../utils/projectTree';
 import { isApiSliceableFilename, isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
@@ -404,10 +405,14 @@ function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderMo
     if (folder.archive_id) setLinkType('archive');
   });
 
+  // Archived projects are left out, bar the one this folder is already linked
+  // to -- dropping that one would leave the folder looking unlinked while the
+  // link is still there (#2888).
   const { data: projects } = useQuery({
     queryKey: ['projects'],
     queryFn: () => api.getProjects(),
-    select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
+    select: (rows) =>
+      assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name)), folder.project_id),
   });
 
   const { data: archives } = useQuery({

+ 6 - 2
frontend/src/pages/ProjectsPage.tsx

@@ -1177,8 +1177,12 @@ export function ProjectsPage() {
     }
   };
 
-  // Count projects by status for filter badges
-  const projectCounts = projects?.reduce((acc, p) => {
+  // Count projects by status for filter badges. Counted from the unfiltered
+  // list, not the one on screen: `projects` holds only the selected status, so
+  // every other tab counted zero and lost its badge entirely -- a fleet of
+  // thirty finished projects showed "Completed" bare while Active read 5
+  // (#2888).
+  const projectCounts = allProjects?.reduce((acc, p) => {
     acc[p.status] = (acc[p.status] || 0) + 1;
     acc.all = (acc.all || 0) + 1;
     return acc;

+ 23 - 0
frontend/src/utils/projectTree.ts

@@ -28,3 +28,26 @@ export function eligibleParents(
   }
   return projects.filter((p) => !blocked.has(p.id));
 }
+
+/**
+ * Projects a picker should offer when filing something away (#2888).
+ *
+ * An archived project is one its owner has explicitly put out of the way, so
+ * leaving it in a picker only lengthens a list they then have to search --
+ * the reporter had five active projects behind thirty-odd finished ones.
+ * Completed projects stay: filing a reprint against a finished project is
+ * ordinary, and "completed" says the work is done, not that it should be
+ * hidden.
+ *
+ * `keepId` names one project that survives whatever its status -- the one the
+ * thing being edited already belongs to. Without it a controlled `<select>`
+ * holds a value no option matches, and the browser resets it to the first
+ * option, which here is "No project": an archive filed in an archived project
+ * would state, in as many words, that it is filed nowhere.
+ */
+export function assignableProjects(
+  projects: ProjectListItem[],
+  keepId?: number | null,
+): ProjectListItem[] {
+  return projects.filter((p) => p.status !== 'archived' || p.id === keepId);
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DGi_kjAn.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CVHHvKY8.js"></script>
+    <script type="module" crossorigin src="/assets/index-DGi_kjAn.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DynWy-72.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов