diff --git a/App.tsx b/App.tsx index 2b292dd..c7f8272 100644 --- a/App.tsx +++ b/App.tsx @@ -11,6 +11,9 @@ import Sales from './pages/Sales'; import Suppliers from './pages/Suppliers'; import Users from './pages/Users'; import Login from './pages/Login'; +import Calculator from './pages/Printing/Calculator'; // 3D +import Filaments from './pages/Printing/Filaments'; // 3D +import Printers from './pages/Printing/Printers'; // 3D import Products from './pages/Products'; // New import Reports from './pages/Reports'; import Settings from './pages/Settings'; @@ -110,6 +113,9 @@ const AppLayout: React.FC = () => { const AppRoutes = () => ( } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/context/CRMContext.tsx b/context/CRMContext.tsx index 57c9abb..94e6bc4 100644 --- a/context/CRMContext.tsx +++ b/context/CRMContext.tsx @@ -284,6 +284,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) => autoSyncSales: settingsData.auto_sync_sales, autoSyncStock: settingsData.auto_sync_stock, sourcingWebhook: settingsData.sourcing_webhook, + electricityCostKwh: settingsData.electricity_cost_kwh, }); } } catch (err) { diff --git a/layouts/Sidebar.tsx b/layouts/Sidebar.tsx index 0633547..3d6b76b 100644 --- a/layouts/Sidebar.tsx +++ b/layouts/Sidebar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, Search, History, Boxes, Store, Users, TrendingUp, LogOut, Wallet, ShoppingCart, Package, BarChart3, Settings } from 'lucide-react'; +import { LayoutDashboard, Search, History, Boxes, Store, Users, TrendingUp, LogOut, Wallet, ShoppingCart, Package, BarChart3, Settings, Printer, Scroll } from 'lucide-react'; + import { useCRM } from '../context/CRMContext'; import clsx from 'clsx'; @@ -15,6 +16,9 @@ const Sidebar: React.FC = ({ isOpen = false, onClose }) => { const navItems = [ { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, + { to: '/printing/calculator', icon: Printer, label: 'Calc. 3D' }, + { to: '/printing/filaments', icon: Scroll, label: 'Filamentos' }, + { to: '/printing/printers', icon: Settings, label: 'Impressoras 3D' }, { to: '/sales', icon: ShoppingCart, label: 'Vendas' }, { to: '/sourcing', icon: Search, label: 'Sourcing' }, { to: '/products', icon: Package, label: 'Produtos' }, diff --git a/migration_3d_printing.sql b/migration_3d_printing.sql new file mode 100644 index 0000000..b03d1be --- /dev/null +++ b/migration_3d_printing.sql @@ -0,0 +1,72 @@ +-- 3D Printing Module Migration + +-- 1. PRINTERS Table +create table if not exists public.printers ( + id uuid default uuid_generate_v4() primary key, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + name text not null, + power_watts numeric default 0, -- Power consumption in Watts + depreciation_per_hour numeric default 0, -- Estimated depreciation cost per hour + user_id uuid references auth.users +); + +-- 2. FILAMENTS Table +create table if not exists public.filaments ( + id uuid default uuid_generate_v4() primary key, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + name text not null, -- e.g. "PLA Generico" + brand text, + material text, -- PLA, PETG, ABS, etc. + color text, + density_g_cm3 numeric default 1.24, -- Optional, for volume calc + spool_weight_g numeric default 1000, -- e.g. 1000g (1kg) + price_brl numeric default 0, -- Cost of the spool + temp_nozzle integer, + temp_bed integer, + user_id uuid references auth.users +); + +-- 3. PRINT JOBS / CALCULATIONS +create table if not exists public.print_jobs ( + id uuid default uuid_generate_v4() primary key, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + name text not null, -- Project/Part name + + -- Inputs + printer_id uuid references public.printers, + filament_id uuid references public.filaments, + weight_g numeric default 0, + print_time_hours numeric default 0, + + -- Costs Snapshot (Calculated at creation time) + energy_cost numeric default 0, + filament_cost numeric default 0, + depreciation_cost numeric default 0, + additional_cost numeric default 0, + + total_cost numeric default 0, + markup_percentage numeric default 0, + final_price numeric default 0, + + status text default 'Draft', -- Draft, Printing, Completed + user_id uuid references auth.users +); + +-- 4. SETTINGS Update (Electricity Cost) +-- Adding a column safely +do $$ +begin + if not exists (select 1 from information_schema.columns where table_name='settings' and column_name='electricity_cost_kwh') then + alter table public.settings add column electricity_cost_kwh numeric default 0.90; + end if; +end $$; + +-- Enable RLS +alter table public.printers enable row level security; +alter table public.filaments enable row level security; +alter table public.print_jobs enable row level security; + +-- Simple Policies (adjust as needed for authenticated users) +create policy "Enable all for users based on user_id" on public.printers for all using (auth.uid() = user_id); +create policy "Enable all for users based on user_id" on public.filaments for all using (auth.uid() = user_id); +create policy "Enable all for users based on user_id" on public.print_jobs for all using (auth.uid() = user_id); diff --git a/pages/Printing/Calculator.tsx b/pages/Printing/Calculator.tsx new file mode 100644 index 0000000..f344d45 --- /dev/null +++ b/pages/Printing/Calculator.tsx @@ -0,0 +1,287 @@ +import React, { useEffect, useState } from 'react'; +import { supabase } from '../../services/supabase'; +import { Filament, Printer } from '../../types'; +import { Calculator as CalcIcon, Save, RefreshCw, Zap, Package, Clock, DollarSign } from 'lucide-react'; + +const Calculator: React.FC = () => { + // Data Lists + const [printers, setPrinters] = useState([]); + const [filaments, setFilaments] = useState([]); + const [elecCost, setElecCost] = useState(0.90); + + // Form State + const [selectedPrinterId, setSelectedPrinterId] = useState(''); + const [selectedFilamentId, setSelectedFilamentId] = useState(''); + const [weightG, setWeightG] = useState(0); + const [timeHours, setTimeHours] = useState(0); + const [markup, setMarkup] = useState(100); // 100% default + + // Results State + const [results, setResults] = useState({ + materialCost: 0, + energyCost: 0, + depreciationCost: 0, + totalCost: 0, + suggestedPrice: 0 + }); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + const { data: printersData } = await supabase.from('printers').select('*'); + const { data: filamentsData } = await supabase.from('filaments').select('*'); + const { data: settingsData } = await supabase.from('settings').select('electricity_cost_kwh').single(); + + if (printersData) { + setPrinters(printersData.map((item: any) => ({ + id: item.id, + name: item.name, + powerWatts: item.power_watts, + depreciationPerHour: item.depreciation_per_hour + }))); + } + + if (filamentsData) { + setFilaments(filamentsData.map((item: any) => ({ + id: item.id, + name: item.name, + brand: item.brand, + material: item.material, + spoolWeightG: item.spool_weight_g, + priceBRL: item.price_brl + }))); + } + + if (settingsData) { + setElecCost(settingsData.electricity_cost_kwh || 0.90); + } + }; + + // Recalculate whenever inputs change + useEffect(() => { + calculate(); + }, [selectedPrinterId, selectedFilamentId, weightG, timeHours, markup, elecCost]); + + const calculate = () => { + const printer = printers.find(p => p.id === selectedPrinterId); + const filament = filaments.find(f => f.id === selectedFilamentId); + + if (!printer || !filament) return; + + // 1. Material Cost + const pricePerGram = filament.priceBRL / filament.spoolWeightG; + const matCost = pricePerGram * weightG; + + // 2. Energy Cost + // Watts / 1000 = kW * Hours * Cost/kWh + const energyCost = (printer.powerWatts / 1000) * timeHours * elecCost; + + // 3. Depreciation + const depCost = printer.depreciationPerHour * timeHours; + + const total = matCost + energyCost + depCost; + const finalPrice = total * (1 + markup / 100); + + setResults({ + materialCost: matCost, + energyCost: energyCost, + depreciationCost: depCost, + totalCost: total, + suggestedPrice: finalPrice + }); + }; + + const handleSaveJob = async () => { + if (!selectedPrinterId || !selectedFilamentId) return; + const name = prompt("Name this print job (e.g. 'Iron Man Helmet'):"); + if (!name) return; + + const payload = { + name, + printer_id: selectedPrinterId, + filament_id: selectedFilamentId, + weight_g: weightG, + print_time_hours: timeHours, + energy_cost: results.energyCost, + filament_cost: results.materialCost, + depreciation_cost: results.depreciationCost, + total_cost: results.totalCost, + markup_percentage: markup, + final_price: results.suggestedPrice, + user_id: (await supabase.auth.getUser()).data.user?.id + }; + + const { error } = await supabase.from('print_jobs').insert([payload]); + if (error) { + alert('Error saving job'); + console.error(error); + } else { + alert('Job saved to history!'); + } + }; + + return ( +
+ {/* INPUTS COLUMN */} +
+
+
+ +

Parameters

+
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ +
+ + setWeightG(Number(e.target.value))} + placeholder="0" + /> +
+
+ +
+ +
+ + setTimeHours(Number(e.target.value))} + placeholder="0" + /> +
+
+
+
+ +
+ + setMarkup(Number(e.target.value))} + /> +
{markup}%
+
+
+
+ + {/* RESULTS COLUMN */} +
+
+
+ +

Cost Breakdown

+ +
+
+
+
+ +
+ Material +
+ R$ {results.materialCost.toFixed(2)} +
+ +
+
+
+ +
+ Energy +
+ R$ {results.energyCost.toFixed(2)} +
+ + {results.depreciationCost > 0 && ( +
+
+
+ +
+ Wear & Tear +
+ R$ {results.depreciationCost.toFixed(2)} +
+ )} + +
+ +
+ Total Cost + R$ {results.totalCost.toFixed(2)} +
+ +
+ Profit ({markup}%) + + R$ {(results.suggestedPrice - results.totalCost).toFixed(2)} +
+
+ +
+
Suggested Price
+
+ R$ {results.suggestedPrice.toFixed(2)} +
+
+ + +
+
+
+ ); +}; + +export default Calculator; diff --git a/pages/Printing/Filaments.tsx b/pages/Printing/Filaments.tsx new file mode 100644 index 0000000..56e4376 --- /dev/null +++ b/pages/Printing/Filaments.tsx @@ -0,0 +1,250 @@ +import React, { useEffect, useState } from 'react'; +import { supabase } from '../../services/supabase'; +import { Filament } from '../../types'; +import { Plus, Trash2, Save, Package } from 'lucide-react'; + +const Filaments: React.FC = () => { + const [filaments, setFilaments] = useState([]); + const [loading, setLoading] = useState(true); + const [isAdding, setIsAdding] = useState(false); + + // New Filament State + const [newFilament, setNewFilament] = useState>({ + name: '', + brand: '', + material: 'PLA', + color: '', + spoolWeightG: 1000, + priceBRL: 0, + tempNozzle: 200, + tempBed: 60 + }); + + useEffect(() => { + fetchFilaments(); + }, []); + + const fetchFilaments = async () => { + try { + const { data, error } = await supabase.from('filaments').select('*'); + if (error) throw error; + + // Map snake_case database fields to camelCase TS interfaces + const mapped = (data || []).map((item: any) => ({ + id: item.id, + name: item.name, + brand: item.brand, + material: item.material, + color: item.color, + spoolWeightG: item.spool_weight_g, + priceBRL: item.price_brl, + tempNozzle: item.temp_nozzle, + tempBed: item.temp_bed + })); + + setFilaments(mapped); + } catch (error) { + console.error('Error fetching filaments:', error); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!newFilament.name || !newFilament.priceBRL) return; + + try { + const payload = { + name: newFilament.name, + brand: newFilament.brand, + material: newFilament.material, + color: newFilament.color, + spool_weight_g: newFilament.spoolWeightG, + price_brl: newFilament.priceBRL, + temp_nozzle: newFilament.tempNozzle, + temp_bed: newFilament.tempBed, + user_id: (await supabase.auth.getUser()).data.user?.id + }; + + const { error } = await supabase.from('filaments').insert([payload]); + if (error) throw error; + + setIsAdding(false); + setNewFilament({ + name: '', + brand: '', + material: 'PLA', + color: '', + spoolWeightG: 1000, + priceBRL: 0, + tempNozzle: 200, + tempBed: 60 + }); + fetchFilaments(); + } catch (error) { + console.error('Error saving filament:', error); + alert('Error saving filament'); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure?')) return; + try { + const { error } = await supabase.from('filaments').delete().eq('id', id); + if (error) throw error; + fetchFilaments(); + } catch (error) { + console.error('Error deleting filament:', error); + } + }; + + return ( +
+
+
+

+ Filaments Library +

+

Manage your 3D printing materials.

+
+ +
+ + {isAdding && ( +
+

New Filament

+
+
+ + setNewFilament({ ...newFilament, name: e.target.value })} + /> +
+
+ + setNewFilament({ ...newFilament, brand: e.target.value })} + /> +
+
+ + +
+
+ + setNewFilament({ ...newFilament, color: e.target.value })} + /> +
+
+ + setNewFilament({ ...newFilament, spoolWeightG: Number(e.target.value) })} + /> +
+
+ + setNewFilament({ ...newFilament, priceBRL: Number(e.target.value) })} + /> +
+
+
+ + +
+
+ )} + + {loading ? ( +
Loading...
+ ) : ( +
+ {filaments.map(filament => ( +
+
+ +
+ +
+
+ +
+
+

{filament.name}

+

{filament.brand} • {filament.material}

+
+
+ +
+
+ Price/Spool: + R$ {filament.priceBRL.toFixed(2)} +
+
+ Weight: + {filament.spoolWeightG}g +
+
+ Cost/gram: + + R$ {(filament.priceBRL / filament.spoolWeightG).toFixed(4)} + +
+
+
+ ))} +
+ )} +
+ ); +}; + +export default Filaments; diff --git a/pages/Printing/Printers.tsx b/pages/Printing/Printers.tsx new file mode 100644 index 0000000..862088c --- /dev/null +++ b/pages/Printing/Printers.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from 'react'; +import { supabase } from '../../services/supabase'; +import { Printer } from '../../types'; +import { Plus, Trash2, Save, Printer as PrinterIcon, Zap } from 'lucide-react'; + +const Printers: React.FC = () => { + const [printers, setPrinters] = useState([]); + const [loading, setLoading] = useState(true); + const [isAdding, setIsAdding] = useState(false); + + const [newPrinter, setNewPrinter] = useState>({ + name: '', + powerWatts: 350, + depreciationPerHour: 0 + }); + + useEffect(() => { + fetchPrinters(); + }, []); + + const fetchPrinters = async () => { + try { + const { data, error } = await supabase.from('printers').select('*'); + if (error) throw error; + + const mapped = (data || []).map((item: any) => ({ + id: item.id, + name: item.name, + powerWatts: item.power_watts, + depreciationPerHour: item.depreciation_per_hour + })); + + setPrinters(mapped); + } catch (error) { + console.error('Error fetching printers:', error); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!newPrinter.name) return; + + try { + const payload = { + name: newPrinter.name, + power_watts: newPrinter.powerWatts, + depreciation_per_hour: newPrinter.depreciationPerHour, + user_id: (await supabase.auth.getUser()).data.user?.id + }; + + const { error } = await supabase.from('printers').insert([payload]); + if (error) throw error; + + setIsAdding(false); + setNewPrinter({ name: '', powerWatts: 350, depreciationPerHour: 0 }); + fetchPrinters(); + } catch (error) { + console.error('Error saving printer:', error); + alert('Error saving printer'); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure?')) return; + try { + const { error } = await supabase.from('printers').delete().eq('id', id); + if (error) throw error; + fetchPrinters(); + } catch (error) { + console.error('Error deleting printer:', error); + } + }; + + return ( +
+
+
+

+ Printers +

+

Manage your 3D printers configuration.

+
+ +
+ + {isAdding && ( +
+

New Printer

+
+
+ + setNewPrinter({ ...newPrinter, name: e.target.value })} + /> +
+
+ + setNewPrinter({ ...newPrinter, powerWatts: Number(e.target.value) })} + /> +

Found on printer label (avg 350W)

+
+
+ + setNewPrinter({ ...newPrinter, depreciationPerHour: Number(e.target.value) })} + /> +

Optional machine wear cost

+
+
+
+ + +
+
+ )} + + {loading ? ( +
Loading...
+ ) : ( +
+ {printers.map(printer => ( +
+
+ +
+ +
+
+ +
+
+

{printer.name}

+
+ + {printer.powerWatts}W +
+
+
+ +
+
+ Consumption: + {(printer.powerWatts / 1000).toFixed(2)} kWh +
+ {printer.depreciationPerHour > 0 && ( +
+ Depreciation: + R$ {printer.depreciationPerHour.toFixed(2)}/h +
+ )} +
+
+ ))} +
+ )} +
+ ); +}; + +export default Printers; diff --git a/pages/Settings.tsx b/pages/Settings.tsx index 1e2e954..117da30 100644 --- a/pages/Settings.tsx +++ b/pages/Settings.tsx @@ -82,6 +82,7 @@ const Settings: React.FC = () => { smtpPort: '587', smtpUser: '', smtpPass: '', + electricityCostKwh: 0.90, }); // Sync state when settings load from DB @@ -216,6 +217,19 @@ const Settings: React.FC = () => { /> +
+ +
+ R$ + setConfig({ ...config, electricityCostKwh: parseFloat(e.target.value) })} + className="w-full bg-black/20 border border-white/10 rounded-lg pl-8 pr-3 py-2 text-sm text-slate-200 focus:border-emerald-500 outline-none transition-all font-mono" + /> +
+
diff --git a/types.ts b/types.ts index 6273b72..0286866 100644 --- a/types.ts +++ b/types.ts @@ -165,3 +165,40 @@ export interface BiddingTender { marketReferencePrice: number; url: string; } + +// 3D Printing Module Types +export interface Printer { + id: string; + name: string; + powerWatts: number; + depreciationPerHour: number; +} + +export interface Filament { + id: string; + name: string; + brand?: string; + material: string; + color?: string; + spoolWeightG: number; + priceBRL: number; + tempNozzle?: number; + tempBed?: number; +} + +export interface PrintJob { + id: string; + name: string; + printerId: string; + filamentId: string; + weightG: number; + printTimeHours: number; + energyCost: number; + filamentCost: number; + depreciationCost: number; + additionalCost: number; + totalCost: number; + markupPercentage: number; + finalPrice: number; + status: 'Draft' | 'Printing' | 'Completed'; +}