Преглед изворни кода

fix(finance): a personal cost center's budget can be saved (issue #3358)

- Saving the Edit cost center dialog always sent the name first, and the
  backend refuses any rename of a private cost center, so the request
  failed and the budget request after it was never sent. For a private
  center the dialog now sends only the budget, and its name field is
  disabled.
maziggy пре 6 часа
родитељ
комит
3f0feddd75

+ 1 - 0
CHANGELOG.md

@@ -175,6 +175,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A personal cost center's budget can be changed (#3358, reported by @airforceixi)** — Saving the **Edit cost center** dialog on a user's personal cost center failed with "Private cost centers cannot be deactivated or renamed; set their budget to 0 to prevent printing", even when only the budget was changed, so a budget (or 0, to stop that user printing) could not be set. The dialog always sent the name as well, which a personal cost center refuses, and the budget was never saved. It now saves only the budget for a personal cost center, and its name field is greyed out, as the name is the user's.
 - **Slicing a painted multi-colour model keeps its colours (#3355, reported by @cartman75)** — On a model coloured with the paint tool, slicing in Bambuddy could turn several filaments into one colour, for example black, white, green and green all coming out green. Before slicing, Bambuddy works out which filaments the plate doesn't use and swaps them for one it does, so the slicer's material checks pass. It misread the painted areas, so painted filaments counted as unused and got another slot's profile and colour, and the print dialog then mapped them to that slot's spool. Painted areas are now read correctly, including small ones. A filament that really is unused keeps its own colour when it is swapped.
 - **An AMS Lite shows its serial number and firmware version (#3348, reported by @Spegeli)** — The printer reports both in its version answer, where the AMS Lite calls itself `ams_f1`. Bambuddy only knew the names of the other AMS models, so it skipped the AMS Lite's entry. The AMS popup showed "—" for both, the status API returned them empty, and the log warned on every connection that they were missing. They now come through on the A1, A1 Mini and A2L, including the A2L's AMS Lite, which the printer numbers differently. A name given to an AMS Lite before this, which was kept by its position because it had no serial number, still shows on the printer card and in the inventory's location column. Saving it again moves it to the serial number, so it follows the unit, and **Clear** now removes it; before, the old name came back after clearing.
 - **The queue's "View in File Manager" icon opens the file (#3336, reported by @Magical-Penguin)** — On a job added from the File Manager, the link icon next to the name went to `/library`, which is no page, so the screen stayed empty. It now opens the File Manager in the file's folder, with the file marked and scrolled into view, in the grid, list and column views. The folder is looked up when the link is opened, so a file moved since it was queued is still found. A deleted file, or one you can't see, shows a warning. The archive icon on the same cards opened the Archives page without pointing at the archive; it now marks the archive too, switches from the log or calendar view to the grid, and warns when the filters hide it. An archive tile on a project's page did the same and now opens its archive marked as well. Both look in a freshly loaded list, so an archive created since the page was last open is found. Wiki updated.

+ 33 - 0
frontend/src/__tests__/pages/FinancePageWiring.test.tsx

@@ -352,6 +352,39 @@ describe('FinancePage wiring: cost centres', () => {
     ]);
   });
 
+  it('editing a private centre saves its budget without renaming it (#3358)', async () => {
+    // As the backend does: a private centre can't be renamed or deactivated.
+    server.use(
+      http.patch('*/api/v1/finance/cost-centers/22', async ({ request }) => {
+        await capture(request);
+        return HttpResponse.json(
+          { detail: 'Private cost centers cannot be deactivated or renamed; set their budget to 0 to prevent printing' },
+          { status: 400 },
+        );
+      }),
+    );
+    const user = userEvent.setup();
+    await openAdmin(user);
+
+    await user.click(within(await centreRow('Alice private')).getByTitle('Edit'));
+    const dlg = await modal('Edit cost center');
+    await waitFor(() => expect(within(dlg).getByPlaceholderText('Name')).toHaveValue('Alice private'));
+    expect(within(dlg).getByPlaceholderText('Name')).toBeDisabled();
+    const budget = within(dlg).getByRole('spinbutton');
+    await user.clear(budget);
+    await user.type(budget, '0');
+    await user.click(within(dlg).getByRole('button', { name: 'Save' }));
+
+    await waitFor(() => expect(screen.getByText('Cost center updated')).toBeInTheDocument());
+    expect(mutations()).toEqual([
+      {
+        method: 'PATCH',
+        path: '/api/v1/finance/cost-centers/22/budgets',
+        body: { total_budget: null, monthly_budget: 0 },
+      },
+    ]);
+  });
+
   it('delete -> confirm sends DELETE for that centre; private centres offer no delete', async () => {
     const user = userEvent.setup();
     await openAdmin(user);

+ 8 - 2
frontend/src/pages/FinancePage.tsx

@@ -408,6 +408,12 @@ export function FinancePage() {
     createCostCenterMutation.mutate();
   };
 
+  // A private center is named after its owner and can't be renamed; the backend
+  // refuses the rename, which used to stop the budget from being saved (#3358).
+  const selectedEditCenterIsPrivate = (costCenters || []).some(
+    (c) => c.id === selectedEditCenterId && c.is_private,
+  );
+
   const handleSaveEditedCenter = async () => {
     if (selectedEditCenterId == null) {
       showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error');
@@ -421,7 +427,7 @@ export function FinancePage() {
     }
 
     try {
-      if (canUpdateCostCenters) {
+      if (canUpdateCostCenters && !selectedEditCenterIsPrivate) {
         await updateCostCenterMutation.mutateAsync({ costCenterId: selectedEditCenterId, name });
       }
       if (canUpdateBudgets) {
@@ -1008,7 +1014,7 @@ export function FinancePage() {
                 onChange={(e) => setEditCenterName(e.target.value)}
                 placeholder={t('finance.costCenterName', 'Name')}
                 className={fieldClass}
-                disabled={!canUpdateCostCenters}
+                disabled={!canUpdateCostCenters || selectedEditCenterIsPrivate}
               />
             </div>