mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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
@@ -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>(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user