feat: complete 3d printing module files
This commit is contained in:
parent
b2fce05065
commit
100b397ede
9 changed files with 865 additions and 1 deletions
6
App.tsx
6
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 = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/printing/calculator" element={<Calculator />} />
|
||||
<Route path="/printing/filaments" element={<Filaments />} />
|
||||
<Route path="/printing/printers" element={<Printers />} />
|
||||
<Route path="/sourcing" element={<Sourcing />} />
|
||||
<Route path="/orders" element={<Orders />} />
|
||||
<Route path="/sales" element={<Sales />} />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<SidebarProps> = ({ 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' },
|
||||
|
|
|
|||
72
migration_3d_printing.sql
Normal file
72
migration_3d_printing.sql
Normal file
|
|
@ -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);
|
||||
287
pages/Printing/Calculator.tsx
Normal file
287
pages/Printing/Calculator.tsx
Normal file
|
|
@ -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<Printer[]>([]);
|
||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||
const [elecCost, setElecCost] = useState(0.90);
|
||||
|
||||
// Form State
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<string>('');
|
||||
const [selectedFilamentId, setSelectedFilamentId] = useState<string>('');
|
||||
const [weightG, setWeightG] = useState<number>(0);
|
||||
const [timeHours, setTimeHours] = useState<number>(0);
|
||||
const [markup, setMarkup] = useState<number>(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 (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* INPUTS COLUMN */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-6 text-primary">
|
||||
<CalcIcon size={24} />
|
||||
<h2 className="text-xl font-bold">Parameters</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Printer</label>
|
||||
<select
|
||||
className="w-full bg-background border border-border rounded-lg px-3 py-2 cursor-pointer focus:ring-2 focus:ring-primary/50 outline-none transition-all"
|
||||
value={selectedPrinterId}
|
||||
onChange={e => setSelectedPrinterId(e.target.value)}
|
||||
>
|
||||
<option value="">Select Printer</option>
|
||||
{printers.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.powerWatts}W)</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Filament</label>
|
||||
<select
|
||||
className="w-full bg-background border border-border rounded-lg px-3 py-2 cursor-pointer focus:ring-2 focus:ring-primary/50 outline-none transition-all"
|
||||
value={selectedFilamentId}
|
||||
onChange={e => setSelectedFilamentId(e.target.value)}
|
||||
>
|
||||
<option value="">Select Filament</option>
|
||||
{filaments.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.name} ({f.material})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Weight (grams)</label>
|
||||
<div className="relative">
|
||||
<Package className="absolute left-3 top-2.5 text-muted-foreground" size={18} />
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-background border border-border rounded-lg pl-10 pr-3 py-2"
|
||||
value={weightG}
|
||||
onChange={e => setWeightG(Number(e.target.value))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Time (hours)</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-2.5 text-muted-foreground" size={18} />
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-background border border-border rounded-lg pl-10 pr-3 py-2"
|
||||
value={timeHours}
|
||||
onChange={e => setTimeHours(Number(e.target.value))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<label className="block text-sm font-medium mb-2">Profit Margin / Markup (%)</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="500"
|
||||
step="5"
|
||||
className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
value={markup}
|
||||
onChange={e => setMarkup(Number(e.target.value))}
|
||||
/>
|
||||
<div className="text-right mt-1 font-mono text-primary font-bold">{markup}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RESULTS COLUMN */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card border border-border rounded-xl p-6 shadow-xl relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-purple-600"></div>
|
||||
|
||||
<h2 className="text-xl font-bold mb-6">Cost Breakdown</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-orange-500/10 p-2 rounded-full text-orange-500">
|
||||
<Package size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Material</span>
|
||||
</div>
|
||||
<span className="font-mono font-bold">R$ {results.materialCost.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-yellow-500/10 p-2 rounded-full text-yellow-500">
|
||||
<Zap size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Energy</span>
|
||||
</div>
|
||||
<span className="font-mono font-bold">R$ {results.energyCost.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{results.depreciationCost > 0 && (
|
||||
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-gray-500/10 p-2 rounded-full text-gray-500">
|
||||
<RefreshCw size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Wear & Tear</span>
|
||||
</div>
|
||||
<span className="font-mono font-bold">R$ {results.depreciationCost.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border my-4"></div>
|
||||
|
||||
<div className="flex justify-between items-end">
|
||||
<span className="text-muted-foreground">Total Cost</span>
|
||||
<span className="text-xl font-bold">R$ {results.totalCost.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end mt-2 text-green-500">
|
||||
<span className="text-sm font-medium">Profit ({markup}%)</span>
|
||||
<span className="text-lg font-bold">+ R$ {(results.suggestedPrice - results.totalCost).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-border">
|
||||
<div className="text-center mb-2 text-muted-foreground text-sm uppercase tracking-wide">Suggested Price</div>
|
||||
<div className="text-center text-4xl font-black bg-clip-text text-transparent bg-gradient-to-r from-green-400 to-emerald-600">
|
||||
R$ {results.suggestedPrice.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveJob}
|
||||
disabled={!selectedPrinterId || !selectedFilamentId}
|
||||
className="w-full mt-6 bg-primary hover:bg-primary/90 text-primary-foreground py-3 rounded-xl font-bold shadow-lg shadow-primary/20 transition-all flex justify-center items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save size={20} />
|
||||
Save to History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calculator;
|
||||
250
pages/Printing/Filaments.tsx
Normal file
250
pages/Printing/Filaments.tsx
Normal file
|
|
@ -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<Filament[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
// New Filament State
|
||||
const [newFilament, setNewFilament] = useState<Partial<Filament>>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-pink-600">
|
||||
Filaments Library
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage your 3D printing materials.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded-lg flex items-center gap-2 transition-all shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add Filament
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 shadow-xl animate-in fade-in slide-in-from-top-4">
|
||||
<h3 className="text-lg font-semibold mb-4">New Filament</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
placeholder="e.g. PLA Basic White"
|
||||
value={newFilament.name}
|
||||
onChange={e => setNewFilament({ ...newFilament, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Brand</label>
|
||||
<input
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
placeholder="e.g. Voolt3D"
|
||||
value={newFilament.brand}
|
||||
onChange={e => setNewFilament({ ...newFilament, brand: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Material</label>
|
||||
<select
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
value={newFilament.material}
|
||||
onChange={e => setNewFilament({ ...newFilament, material: e.target.value })}
|
||||
>
|
||||
<option value="PLA">PLA</option>
|
||||
<option value="PETG">PETG</option>
|
||||
<option value="ABS">ABS</option>
|
||||
<option value="TPU">TPU</option>
|
||||
<option value="ASA">ASA</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Color</label>
|
||||
<input
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
placeholder="e.g. White"
|
||||
value={newFilament.color}
|
||||
onChange={e => setNewFilament({ ...newFilament, color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Weight (g)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
value={newFilament.spoolWeightG}
|
||||
onChange={e => setNewFilament({ ...newFilament, spoolWeightG: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Price (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
value={newFilament.priceBRL}
|
||||
onChange={e => setNewFilament({ ...newFilament, priceBRL: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4 gap-2">
|
||||
<button
|
||||
onClick={() => setIsAdding(false)}
|
||||
className="px-4 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save Filament
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filaments.map(filament => (
|
||||
<div key={filament.id} className="bg-card border border-border rounded-xl p-4 shadow-sm hover:shadow-md transition-all relative group">
|
||||
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleDelete(filament.id)}
|
||||
className="text-red-500 hover:text-red-400 p-1 bg-background/80 rounded-full"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="bg-primary/10 p-3 rounded-lg text-primary">
|
||||
<Package size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{filament.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{filament.brand} • {filament.material}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Price/Spool:</span>
|
||||
<span className="font-medium">R$ {filament.priceBRL.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Weight:</span>
|
||||
<span>{filament.spoolWeightG}g</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-border pt-2 mt-2">
|
||||
<span className="text-muted-foreground">Cost/gram:</span>
|
||||
<span className="text-green-500 font-bold">
|
||||
R$ {(filament.priceBRL / filament.spoolWeightG).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filaments;
|
||||
193
pages/Printing/Printers.tsx
Normal file
193
pages/Printing/Printers.tsx
Normal file
|
|
@ -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<Printer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const [newPrinter, setNewPrinter] = useState<Partial<Printer>>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-cyan-600">
|
||||
Printers
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage your 3D printers configuration.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded-lg flex items-center gap-2 transition-all shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add Printer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 shadow-xl animate-in fade-in slide-in-from-top-4">
|
||||
<h3 className="text-lg font-semibold mb-4">New Printer</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
placeholder="e.g. Ender 3 V2"
|
||||
value={newPrinter.name}
|
||||
onChange={e => setNewPrinter({ ...newPrinter, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Power Consumption (Watts)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
value={newPrinter.powerWatts}
|
||||
onChange={e => setNewPrinter({ ...newPrinter, powerWatts: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Found on printer label (avg 350W)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Depreciation (R$/hr)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-background border border-border rounded-md px-3 py-2"
|
||||
value={newPrinter.depreciationPerHour}
|
||||
onChange={e => setNewPrinter({ ...newPrinter, depreciationPerHour: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Optional machine wear cost</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4 gap-2">
|
||||
<button
|
||||
onClick={() => setIsAdding(false)}
|
||||
className="px-4 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save Printer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{printers.map(printer => (
|
||||
<div key={printer.id} className="bg-card border border-border rounded-xl p-6 shadow-sm hover:shadow-md transition-all relative group">
|
||||
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleDelete(printer.id)}
|
||||
className="text-red-500 hover:text-red-400 p-1 bg-background/80 rounded-full"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-blue-500/10 p-3 rounded-full text-blue-500">
|
||||
<PrinterIcon size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{printer.name}</h3>
|
||||
<div className="flex items-center gap-1 text-sm text-yellow-500">
|
||||
<Zap size={14} fill="currentColor" />
|
||||
<span>{printer.powerWatts}W</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-3 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Consumption:</span>
|
||||
<span className="font-mono">{(printer.powerWatts / 1000).toFixed(2)} kWh</span>
|
||||
</div>
|
||||
{printer.depreciationPerHour > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Depreciation:</span>
|
||||
<span className="font-mono">R$ {printer.depreciationPerHour.toFixed(2)}/h</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Printers;
|
||||
|
|
@ -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 = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-500 uppercase">Custo Energia (R$/kWh)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs font-bold">R$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={config.electricityCostKwh}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
37
types.ts
37
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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue