feat(settings): Frontend changes to support billing

Settings:
- Introduced billing enabled toggle with printer kill switch functionality in the settings.
- Added finance monthly budget reset day and timezone settings.

Other:
- Added cost center fields to PrintModal (if enabled)
- Switched sequence in PrintModal (cost center after estimated cost, and before print options)
- Implemented Finance Page
This commit is contained in:
behrinml
2026-06-24 11:00:43 +02:00
parent 010687eafd
commit 38c5bbb62e
9 changed files with 1960 additions and 27 deletions
+2
View File
@@ -7,6 +7,7 @@ import { ArchivesPage } from './pages/ArchivesPage';
import { QueuePage } from './pages/QueuePage';
import { StatsPage } from './pages/StatsPage';
import { SettingsPage } from './pages/SettingsPage';
import { FinancePage } from './pages/FinancePage';
import { ProfilesPage } from './pages/ProfilesPage';
import { MaintenancePage } from './pages/MaintenancePage';
import { ProjectsPage } from './pages/ProjectsPage';
@@ -199,6 +200,7 @@ function App() {
<Route path="queue" element={<QueuePage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="finance" element={<PermissionRoute permission="cost_centers:read_own"><FinancePage /></PermissionRoute>} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetailPage />} />
+199 -20
View File
@@ -1185,6 +1185,11 @@ export interface AppSettings {
// Staggered batch start defaults
stagger_group_size: number;
stagger_interval_minutes: number;
// Finance budget reset window
billing_enabled: boolean;
printer_kill_switch_enabled: boolean;
finance_budget_reset_day: number;
finance_budget_reset_timezone: string;
// Plate-clear confirmation
require_plate_clear: boolean;
// Shortest job first scheduling
@@ -1897,6 +1902,8 @@ export interface PrintQueueItem {
// Either archive_id OR library_file_id must be set (archive created at print start)
archive_id: number | null;
library_file_id: number | null;
cost_center_id: number | null;
estimated_cost: number | null;
position: number;
scheduled_time: string | null;
require_previous_success: boolean;
@@ -2002,16 +2009,8 @@ export interface PrintQueueItemCreate {
batch_id?: number | null;
// Project to associate the resulting archive with
project_id?: number;
}
export interface PrintBatchCreate {
name: string;
archive_id?: number | null;
library_file_id?: number | null;
/** When set, the listed pending items are assigned to the new batch
* (manual "Group as batch"). When omitted/empty, an empty batch is
* returned so the client can pass batch_id on subsequent addToQueue calls. */
item_ids?: number[];
cost_center_id?: number | null;
estimated_cost?: number | null;
}
export interface PrintQueueItemUpdate {
@@ -2036,6 +2035,8 @@ export interface PrintQueueItemUpdate {
nozzle_offset_cali?: boolean;
// Auto-print G-code injection
gcode_injection?: boolean;
cost_center_id?: number | null;
estimated_cost?: number | null;
}
export interface PrintQueueBulkUpdate {
@@ -2055,6 +2056,7 @@ export interface PrintQueueBulkUpdate {
nozzle_offset_cali?: boolean;
// Auto-print G-code injection
gcode_injection?: boolean;
cost_center_id?: number | null;
}
export interface PrintQueueBulkUpdateResponse {
@@ -2948,6 +2950,114 @@ export interface ExternalLinkUpdate {
open_in_new_tab?: boolean;
}
// Finance types
export interface CostCenterSummary {
id: number;
name: string;
is_private: boolean;
owner_user_id: number | null;
is_active: boolean;
total_balance: number;
total_budget: number | null;
monthly_budget: number | null;
budget_mode: 'none' | 'total' | 'monthly';
budget_limit: number | null;
budget_used: number | null;
budget_available: number | null;
can_print: boolean;
}
export interface CostCenterCreateRequest {
name: string;
total_budget?: number | null;
monthly_budget?: number | null;
is_active?: boolean;
}
export interface CostCenterBudgetUpdateRequest {
total_budget?: number | null;
monthly_budget?: number | null;
}
export interface CostCenterUpdateRequest {
name?: string;
is_active?: boolean;
}
export interface CostCenterMemberRequest {
user_id: number;
can_print?: boolean;
}
export interface CostCenterMemberResponse {
id: number;
cost_center_id: number;
user_id: number;
can_print: boolean;
created_at: string;
}
export interface CostCenterDetail extends CostCenterSummary {
members: CostCenterMemberResponse[];
}
export interface WalletBalance {
user_id: number;
balance: number;
currency: string;
updated_at: string | null;
}
export type WalletTransactionType = 'print_charge' | 'deposit' | 'withdraw' | 'manual_adjustment';
export interface WalletTransaction {
id: number;
user_id: number;
cost_center_id: number | null;
transaction_type: WalletTransactionType;
amount: number;
balance_after: number | null;
description: string | null;
created_by_user_id: number | null;
print_run_id: string | null;
print_archive_id: number | null;
print_queue_id: number | null;
created_at: string;
}
export interface WalletTransactionListResponse {
items: WalletTransaction[];
total: number;
limit: number;
offset: number;
}
export interface WalletAdjustmentRequest {
amount: number;
description?: string;
cost_center_id?: number | null;
}
export interface WalletAdjustmentResponse {
transaction: WalletTransaction;
balance: WalletBalance;
}
export interface TransactionEditRequest {
user_id?: number | null;
cost_center_id?: number | null;
amount?: number | null;
description?: string | null;
}
export interface ManualPrintRequest {
user_id: number;
cost_center_id: number;
amount: number;
description?: string | null;
created_at?: string | null;
}
// Permission type - all available permissions
export type Permission =
| 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate'
@@ -2974,6 +3084,7 @@ export type Permission =
| 'discovery:scan'
| 'firmware:read' | 'firmware:update'
| 'ams_history:read'
| 'cost_centers:read_own' | 'cost_centers:read_all' | 'cost_centers:modify' | 'cost_centers:create'
| 'stats:read' | 'stats:filter_by_user'
| 'system:read'
| 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
@@ -4373,6 +4484,8 @@ export const api = {
vibration_cali?: boolean;
layer_inspect?: boolean;
use_ams?: boolean;
cost_center_id?: number;
estimated_cost?: number;
nozzle_offset_cali?: boolean;
}
) =>
@@ -4784,16 +4897,6 @@ export const api = {
getBatch: (id: number) => request<PrintBatch>(`/queue/batches/${id}`),
cancelBatch: (id: number) =>
request<{ message: string }>(`/queue/batches/${id}`, { method: 'DELETE' }),
createBatch: (data: PrintBatchCreate) =>
request<PrintBatch>('/queue/batches', {
method: 'POST',
body: JSON.stringify(data),
}),
ungroupBatch: (id: number) =>
request<{ ungrouped_count: number; message: string }>(
`/queue/batches/${id}/ungroup`,
{ method: 'POST' },
),
// K-Profiles
getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
@@ -4813,6 +4916,80 @@ export const api = {
method: 'POST',
body: JSON.stringify(profiles),
}),
// Finance
getMyBalance: () => request<WalletBalance>('/finance/me/balance'),
getMyTransactions: (limit = 50, offset = 0) =>
request<WalletTransactionListResponse>(`/finance/me/transactions?limit=${limit}&offset=${offset}`),
getAllTransactions: (limit = 50, offset = 0, userId?: number) => {
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(offset));
if (userId !== undefined) params.set('user_id', String(userId));
return request<WalletTransactionListResponse>(`/finance/transactions?${params.toString()}`);
},
deleteTransaction: (transactionId: number) =>
request<{ status: string }>(`/finance/transactions/${transactionId}`, {
method: 'DELETE',
}),
editTransaction: (transactionId: number, data: TransactionEditRequest) =>
request<WalletTransaction>(`/finance/transactions/${transactionId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
createManualPrint: (data: ManualPrintRequest) =>
request<WalletTransaction>(`/finance/transactions/manual`, {
method: 'POST',
body: JSON.stringify(data),
}),
getMyCostCenters: () => request<CostCenterSummary[]>('/finance/cost-centers/mine'),
listCostCenters: (includeInactive = false) =>
request<CostCenterSummary[]>(`/finance/cost-centers?include_inactive=${includeInactive ? 'true' : 'false'}`),
createCostCenter: (data: CostCenterCreateRequest) =>
request<CostCenterSummary>('/finance/cost-centers', {
method: 'POST',
body: JSON.stringify(data),
}),
updateCostCenter: (costCenterId: number, data: CostCenterUpdateRequest) =>
request<CostCenterSummary>(`/finance/cost-centers/${costCenterId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
updateCostCenterBudgets: (costCenterId: number, data: CostCenterBudgetUpdateRequest) =>
request<CostCenterSummary>(`/finance/cost-centers/${costCenterId}/budgets`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteCostCenter: (costCenterId: number) =>
request<{ status: string }>(`/finance/cost-centers/${costCenterId}`, {
method: 'DELETE',
}),
getCostCenter: (costCenterId: number) =>
request<CostCenterDetail>(`/finance/cost-centers/${costCenterId}`),
upsertCostCenterMember: (costCenterId: number, data: CostCenterMemberRequest) =>
request<CostCenterMemberResponse>(`/finance/cost-centers/${costCenterId}/members`, {
method: 'POST',
body: JSON.stringify(data),
}),
removeCostCenterMember: (costCenterId: number, userId: number) =>
request<{ status: string }>(`/finance/cost-centers/${costCenterId}/members/${userId}`, {
method: 'DELETE',
}),
depositUserBalance: (userId: number, data: WalletAdjustmentRequest) =>
request<WalletAdjustmentResponse>(`/finance/users/${userId}/deposit`, {
method: 'POST',
body: JSON.stringify(data),
}),
withdrawUserBalance: (userId: number, data: WalletAdjustmentRequest) =>
request<WalletAdjustmentResponse>(`/finance/users/${userId}/withdraw`, {
method: 'POST',
body: JSON.stringify(data),
}),
rebuildBalanceLedger: () =>
request<{ status: string }>(`/finance/rebuild-balance-ledger`, {
method: 'POST',
}),
// K-Profile Notes (stored locally, not on printer)
getKProfileNotes: (printerId: number) =>
@@ -6054,6 +6231,8 @@ export const api = {
nozzle_offset_cali?: boolean;
project_id?: number;
cleanup_library_after_dispatch?: boolean;
cost_center_id?: number;
estimated_cost?: number;
}
) =>
request<BackgroundDispatchResponse>(
+3 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, type LucideIcon } from 'lucide-react';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Wallet, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -43,6 +43,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
{ id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'finance', to: '/finance', icon: Wallet, labelKey: 'nav.finance' },
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
{ id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
@@ -293,6 +294,7 @@ export function Layout() {
maintenance: 'maintenance:read',
projects: 'projects:read',
inventory: 'inventory:read',
finance: 'cost_centers:read_own',
files: ['library:read', 'library:read_own', 'library:read_all'],
makerworld: 'makerworld:view',
settings: 'settings:read',
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import type { CostCenterSummary } from '../../api/client';
interface CostCenterSelectProps {
costCenters: CostCenterSummary[];
selectedCostCenterId: number | null;
onChange: (costCenterId: number | null) => void;
}
export function CostCenterSelect({
costCenters,
selectedCostCenterId,
onChange,
}: CostCenterSelectProps) {
const { t } = useTranslation();
if (costCenters.length === 0) return null;
return (
<div className="space-y-1">
<label htmlFor="printCostCenter" className="text-sm text-bambu-gray">
{t('printModal.costCenter', 'Cost center')}
</label>
<select
id="printCostCenter"
value={selectedCostCenterId ?? ''}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
>
{costCenters.map((center) => (
<option key={center.id} value={center.id}>
{center.name}{center.is_private ? ` (${t('printModal.personalDefault', 'Personal')})` : ''}
</option>
))}
</select>
</div>
);
}
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
@@ -18,6 +18,9 @@ export function FilamentMapping({
filamentReqs,
manualMappings,
onManualMappingChange,
onEstimatedCostChange,
budgetAvailable,
quantity = 1,
currencySymbol,
defaultCostPerKg,
defaultExpanded = false,
@@ -118,10 +121,16 @@ export function FilamentMapping({
return total;
}, [filamentComparison, trayCostMap, defaultCostPerKg]);
useEffect(() => {
onEstimatedCostChange?.(totalCost > 0 ? totalCost : null);
}, [onEstimatedCostChange, totalCost]);
const hasAnyCost = useMemo(
() => Array.from(trayCostMap.values()).some((v) => v != null && v > 0),
[trayCostMap]
);
const budgetCheckCost = totalCost * Math.max(1, quantity);
const isBudgetInsufficient = budgetAvailable != null && budgetCheckCost > budgetAvailable;
const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
@@ -344,7 +353,19 @@ export function FilamentMapping({
<span className="text-white">
{totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'}
</span>
{quantity > 1 && totalCost > 0 && (
<span className="ml-2">
{t('printModal.totalCostForQuantity', 'total: {{cost}}', {
cost: `${currencySymbol}${budgetCheckCost.toFixed(2)}`,
})}
</span>
)}
</div>
{isBudgetInsufficient && (
<p className="text-xs text-red-400 mt-2">
{t('printModal.insufficientBudget', 'Insufficient budget for this cost center.')}
</p>
)}
{hasTypeMismatch && (
<p className="text-xs text-orange-400 mt-2">Required filament type not found in printer.</p>
)}
+72 -5
View File
@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea
import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment, CostCenterSummary } from '../../api/client';
import { api } from '../../api/client';
import { useAuth } from '../../contexts/AuthContext';
import { Card, CardContent } from '../Card';
@@ -22,6 +22,7 @@ import { PlateSelector } from './PlateSelector';
import { PrinterSelector } from './PrinterSelector';
import { PrintOptionsPanel } from './PrintOptions';
import { ScheduleOptionsPanel } from './ScheduleOptions';
import { CostCenterSelect } from './CostCenterSelect';
import type {
AssignmentMode,
PrintModalProps,
@@ -55,7 +56,7 @@ export function PrintModal({
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const { hasPermission, user } = useAuth();
// Determine if we're printing a library file
const isLibraryFile = !!libraryFileId && !archiveId;
@@ -179,6 +180,15 @@ export function PrintModal({
return null;
});
// Cost center selection (defaults to queue item's value in edit mode)
const [selectedCostCenterId, setSelectedCostCenterId] = useState<number | null>(() => {
if (mode === 'edit-queue-item' && queueItem?.cost_center_id != null) {
return queueItem.cost_center_id;
}
return null;
});
const [estimatedCost, setEstimatedCost] = useState<number | null>(queueItem?.estimated_cost ?? null);
// Filament overrides for model-based assignment: slot_id -> {type, color}
const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
@@ -257,12 +267,37 @@ export function PrintModal({
const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
const defaultCostPerKg = settings?.default_filament_cost ?? 0;
const billingEnabled = settings?.billing_enabled === true;
const { data: printers, isLoading: loadingPrinters } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const { data: myCostCenters } = useQuery({
queryKey: ['finance', 'cost-centers', 'mine'],
queryFn: api.getMyCostCenters,
enabled: !!user && billingEnabled,
});
const printableCostCenters = useMemo(
() => (myCostCenters || []).filter((center: CostCenterSummary) => center.can_print && center.is_active),
[myCostCenters]
);
const selectedCostCenter = useMemo(
() => printableCostCenters.find((center) => center.id === selectedCostCenterId) ?? null,
[printableCostCenters, selectedCostCenterId]
);
// Select default cost center once loaded (prefer private/personal)
useEffect(() => {
if (printableCostCenters.length === 0) return;
if (selectedCostCenterId != null && printableCostCenters.some((c) => c.id === selectedCostCenterId)) return;
const preferredPrivate = printableCostCenters.find((c) => c.is_private);
setSelectedCostCenterId(preferredPrivate ? preferredPrivate.id : printableCostCenters[0].id);
}, [printableCostCenters, selectedCostCenterId]);
const { data: spoolAssignments } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
@@ -733,6 +768,8 @@ export function PrintModal({
: undefined,
...printOptions,
project_id: projectId ?? undefined,
cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined,
batch_id: autoBatchId ?? undefined,
});
@@ -768,6 +805,8 @@ export function PrintModal({
? new Date(scheduleOptions.scheduledTime).toISOString()
: null,
...printOptions,
cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined,
};
await updateQueueMutation.mutateAsync(updateData);
} else {
@@ -826,7 +865,9 @@ export function PrintModal({
...printOptions,
project_id: projectId,
cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
});
cost_center_id: billingEnabled ? selectedCostCenterId ?? undefined : undefined,
estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost ?? undefined : undefined,
});
} else {
// project_id is intentionally omitted here: reprintArchive targets an existing
// archive that already carries its own project association from the original print.
@@ -835,7 +876,9 @@ export function PrintModal({
plate_name: selectedPlateName,
ams_mapping: printerMapping,
...printOptions,
});
cost_center_id: billingEnabled ? selectedCostCenterId ?? undefined : undefined,
estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost ?? undefined : undefined,
});
}
// Queue remaining copies if quantity > 1
if (effectiveQuantity > 1) {
@@ -861,6 +904,8 @@ export function PrintModal({
? new Date(scheduleOptions.scheduledTime).toISOString()
: null,
...printOptions,
cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined,
};
await updateQueueMutation.mutateAsync(updateData);
} else {
@@ -1020,6 +1065,12 @@ export function PrintModal({
isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
);
useEffect(() => {
if (!showFilamentMapping || archiveDataMissing || selectedPrinters.length !== 1) {
setEstimatedCost(null);
}
}, [archiveDataMissing, selectedPrinters.length, showFilamentMapping]);
// Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
// Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
// the toggle without a specific printer selected. For printer-mode we rely
@@ -1199,6 +1250,9 @@ export function PrintModal({
filamentReqs={effectiveFilamentReqs}
manualMappings={manualMappings}
onManualMappingChange={setManualMappings}
onEstimatedCostChange={setEstimatedCost}
budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
quantity={effectiveQuantity}
defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
currencySymbol={currencySymbol}
defaultCostPerKg={defaultCostPerKg}
@@ -1217,6 +1271,12 @@ export function PrintModal({
defaultExpanded={!!initialSelectedPrinterIds?.length}
showDualNozzleOptions={showDualNozzleOptions}
/>
{billingEnabled && printableCostCenters.length > 0 && (
<CostCenterSelect
costCenters={printableCostCenters}
selectedCostCenterId={selectedCostCenterId}
onChange={setSelectedCostCenterId}
/>
)}
{/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
@@ -1311,6 +1371,13 @@ export function PrintModal({
</div>
)}
{/* Print options */}
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
<div className="pt-3">
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
</div>
)}
{/* Error message */}
{updateQueueMutation.isError && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
@@ -1319,7 +1386,7 @@ export function PrintModal({
)}
{/* Actions */}
<div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
<div className="flex gap-3 pt-4 mt-2 border-t border-bambu-dark-tertiary">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
Cancel
</Button>
@@ -199,6 +199,9 @@ export interface FilamentMappingProps {
filamentReqs: FilamentReqsData | undefined;
manualMappings: Record<number, number>;
onManualMappingChange: (mappings: Record<number, number>) => void;
onEstimatedCostChange?: (estimatedCost: number | null) => void;
budgetAvailable?: number | null;
quantity?: number;
currencySymbol: string;
defaultCostPerKg: number;
/** Per-slot force-color-match flags. The scheduler honors this flag in both
File diff suppressed because it is too large Load Diff
+156
View File
@@ -182,6 +182,8 @@ export function SettingsPage() {
const [templateFilter, setTemplateFilter] = useState('');
const [settingsSearch, setSettingsSearch] = useState('');
const [showLogViewer, setShowLogViewer] = useState(false);
const [showRebuildConfirm, setShowRebuildConfirm] = useState(false);
const [isRebuildLoading, setIsRebuildLoading] = useState(false);
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
// Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
@@ -992,6 +994,11 @@ export function SettingsPage() {
(settings.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
(settings.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
(settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
(settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
(settings.billing_enabled ?? false) !== (localSettings.billing_enabled ?? false) ||
(settings.printer_kill_switch_enabled ?? false) !== (localSettings.printer_kill_switch_enabled ?? false) ||
(settings.finance_budget_reset_day ?? 1) !== (localSettings.finance_budget_reset_day ?? 1) ||
(settings.finance_budget_reset_timezone ?? 'UTC') !== (localSettings.finance_budget_reset_timezone ?? 'UTC');
if (!hasChanges) {
return;
@@ -1084,6 +1091,10 @@ export function SettingsPage() {
chamber_temp_presets: localSettings.chamber_temp_presets,
fan_speed_presets: localSettings.fan_speed_presets,
session_max_hours: localSettings.session_max_hours,
billing_enabled: localSettings.billing_enabled,
printer_kill_switch_enabled: localSettings.printer_kill_switch_enabled,
finance_budget_reset_day: localSettings.finance_budget_reset_day,
finance_budget_reset_timezone: localSettings.finance_budget_reset_timezone,
};
updateMutation.mutate(settingsToSave);
}, 500);
@@ -2129,6 +2140,151 @@ export function SettingsPage() {
: t('settings.energyModeTotalDescription')}
</p>
</div>
<div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.billingEnabled')}</p>
<p className="text-xs text-bambu-gray">{t('settings.billingEnabledDescription')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.billing_enabled ?? false}
onChange={(e) => updateSetting('billing_enabled', e.target.checked)}
className="peer sr-only"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary rounded-full peer-checked:bg-bambu-green peer-checked:after:translate-x-5 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all" />
</label>
</div>
{localSettings.billing_enabled && (
<div className="mt-4 flex items-center justify-between gap-4 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark px-4 py-3">
<div className="flex-1">
<p className="text-white">
{t('settings.printerKillSwitch', 'Unauthorized print kill switch')}
</p>
<p className="text-xs text-bambu-gray mt-1">
{t(
'settings.printerKillSwitchDescription',
'Immediately stop prints that start without authorization.'
)}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.printer_kill_switch_enabled ?? false}
onChange={(e) => updateSetting('printer_kill_switch_enabled', e.target.checked)}
className="peer sr-only"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary rounded-full peer-checked:bg-bambu-green peer-checked:after:translate-x-5 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all" />
</label>
</div>
)}
{localSettings.billing_enabled && (
<div className="mt-4 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-4">
<div className="mb-3">
<h4 className="text-base font-medium text-white flex items-center gap-2">
<Calendar className="w-4 h-4 text-bambu-green" />
{t('settings.financeBudgetReset', 'Finance Monthly Budget Reset')}
</h4>
</div>
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-xs text-bambu-gray mb-1">
{t('settings.financeBudgetResetDay', 'Reset Day')}
</label>
<input
type="number"
min={1}
max={31}
value={localSettings.finance_budget_reset_day ?? 1}
onChange={(e) =>
updateSetting(
'finance_budget_reset_day',
Math.max(1, Math.min(31, parseInt(e.target.value, 10) || 1))
)
}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.financeBudgetResetDayHelp', 'For short months, reset uses the last day of the month.')}
</p>
</div>
<div className="flex-1">
<label className="block text-xs text-bambu-gray mb-1">
{t('settings.financeBudgetResetTimezone', 'Reset timezone')}
</label>
<select
value={localSettings.finance_budget_reset_timezone ?? 'UTC'}
onChange={(e) => updateSetting('finance_budget_reset_timezone', e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
>
<option value="UTC">UTC</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Asia/Singapore">Asia/Singapore</option>
<option value="Australia/Sydney">Australia/Sydney</option>
</select>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.financeBudgetResetTimezoneHelp', 'Budget window start is calculated in this timezone.')}
</p>
</div>
</div>
</div>
)}
{localSettings.billing_enabled && isAdmin && (
<div className="mt-3">
<Button
variant="secondary"
onClick={() => setShowRebuildConfirm(true)}
disabled={isRebuildLoading}
>
{isRebuildLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('settings.rebuildLedgerInProgress', 'Starting...')}
</>
) : (
t('settings.rebuildLedger', 'Rebuild wallet ledger')
)}
</Button>
{showRebuildConfirm && (
<ConfirmModal
title={t('settings.rebuildLedgerConfirmTitle', 'Rebuild wallet ledger?')}
message={t(
'settings.rebuildLedgerConfirmMessage',
'This will rebuild the wallet ledger to repair historical balance values. Run this only if you know what you are doing.'
)}
confirmText={t('common.run', 'Run')}
cancelText={t('common.cancel', 'Cancel')}
isLoading={isRebuildLoading}
loadingText={t('common.running', 'Running')}
variant="warning"
onCancel={() => setShowRebuildConfirm(false)}
onConfirm={async () => {
setIsRebuildLoading(true);
try {
await api.rebuildBalanceLedger();
showToast(t('settings.rebuildLedgerStarted', 'Ledger rebuild started'), 'success');
setShowRebuildConfirm(false);
} catch (err) {
showToast((err as Error).message || 'Failed to start ledger rebuild', 'error');
} finally {
setIsRebuildLoading(false);
}
}}
/>
)}
</div>
)}
</div>
</CardContent>
</Card>