2426 lines
106 KiB
TypeScript
2426 lines
106 KiB
TypeScript
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
|
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
|
import { useState, useEffect } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import {
|
|
Package,
|
|
Users,
|
|
Truck,
|
|
ShoppingCart,
|
|
FileText,
|
|
Plus,
|
|
Search,
|
|
Edit,
|
|
Trash2,
|
|
RefreshCw,
|
|
Receipt,
|
|
BookOpen,
|
|
ArrowRight,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
DollarSign,
|
|
BarChart3,
|
|
Building2,
|
|
Package2,
|
|
TrendingUp,
|
|
Cloud,
|
|
CloudOff,
|
|
Database,
|
|
Loader2,
|
|
Target,
|
|
Briefcase,
|
|
Calculator,
|
|
FolderKanban,
|
|
UserCog,
|
|
AlertTriangle,
|
|
Pencil,
|
|
Smartphone,
|
|
Wallet,
|
|
ExternalLink
|
|
} from "lucide-react";
|
|
import { useLocation } from "wouter";
|
|
import { useSoeMotor, SoeMotor } from "@/contexts/SoeMotorContext";
|
|
|
|
type TabType = "dashboard" | "persons" | "customers" | "suppliers" | "products" | "sales" | "purchases" | "crm" | "accounting" | "projects" | "hr" | "config" | "sync";
|
|
|
|
interface Customer {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
type: string;
|
|
taxId: string;
|
|
email: string;
|
|
phone: string;
|
|
city: string;
|
|
state: string;
|
|
status: string;
|
|
creditLimit: string;
|
|
}
|
|
|
|
interface Supplier {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
taxId: string;
|
|
email: string;
|
|
phone: string;
|
|
city: string;
|
|
state: string;
|
|
status: string;
|
|
}
|
|
|
|
interface Product {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
description: string;
|
|
category: string;
|
|
unit: string;
|
|
costPrice: string;
|
|
salePrice: string;
|
|
stockQty: string;
|
|
minStock: string;
|
|
ncm: string;
|
|
barcode?: string;
|
|
taxGroupId?: number;
|
|
taxGroupName?: string;
|
|
status: string;
|
|
requiresSerialTracking?: boolean;
|
|
trackingType?: string;
|
|
defaultBrand?: string;
|
|
defaultModel?: string;
|
|
}
|
|
|
|
interface TaxGroup {
|
|
id: number;
|
|
nome: string;
|
|
ncm: string;
|
|
cstIcms: string;
|
|
aliqIcms: string;
|
|
}
|
|
|
|
interface SalesOrder {
|
|
id: number;
|
|
orderNumber: string;
|
|
customerId: number;
|
|
customerName?: string;
|
|
orderDate: string;
|
|
deliveryDate: string;
|
|
status: string;
|
|
subtotal: string;
|
|
discount: string;
|
|
tax: string;
|
|
total: string;
|
|
}
|
|
|
|
interface PurchaseOrder {
|
|
id: number;
|
|
orderNumber: string;
|
|
supplierId: number;
|
|
supplierName?: string;
|
|
orderDate: string;
|
|
expectedDate: string;
|
|
status: string;
|
|
subtotal: string;
|
|
total: string;
|
|
}
|
|
|
|
interface ERPNextStatus {
|
|
connected: boolean;
|
|
message: string;
|
|
user?: string;
|
|
url?: string;
|
|
}
|
|
|
|
interface ERPNextCustomer {
|
|
name: string;
|
|
customer_name: string;
|
|
customer_type: string;
|
|
customer_group?: string;
|
|
territory?: string;
|
|
tax_id?: string;
|
|
email_id?: string;
|
|
mobile_no?: string;
|
|
disabled?: number;
|
|
}
|
|
|
|
interface ERPNextSupplier {
|
|
name: string;
|
|
supplier_name: string;
|
|
supplier_type?: string;
|
|
supplier_group?: string;
|
|
tax_id?: string;
|
|
email_id?: string;
|
|
mobile_no?: string;
|
|
disabled?: number;
|
|
}
|
|
|
|
interface ERPNextItem {
|
|
name: string;
|
|
item_code: string;
|
|
item_name: string;
|
|
item_group?: string;
|
|
stock_uom?: string;
|
|
description?: string;
|
|
standard_rate?: number;
|
|
valuation_rate?: number;
|
|
disabled?: number;
|
|
}
|
|
|
|
interface ERPNextSalesOrder {
|
|
name: string;
|
|
customer: string;
|
|
customer_name?: string;
|
|
transaction_date: string;
|
|
delivery_date?: string;
|
|
status: string;
|
|
grand_total: number;
|
|
net_total: number;
|
|
}
|
|
|
|
interface ERPNextLead {
|
|
name: string;
|
|
lead_name: string;
|
|
company_name?: string;
|
|
email_id?: string;
|
|
mobile_no?: string;
|
|
status: string;
|
|
source?: string;
|
|
}
|
|
|
|
interface ERPNextOpportunity {
|
|
name: string;
|
|
party_name: string;
|
|
opportunity_type?: string;
|
|
status: string;
|
|
expected_closing?: string;
|
|
opportunity_amount?: number;
|
|
}
|
|
|
|
interface ErpSegment {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
category: string;
|
|
description?: string;
|
|
modules?: string[];
|
|
features?: Record<string, boolean>;
|
|
}
|
|
|
|
interface ErpConfigData {
|
|
id?: number;
|
|
tenantId?: number;
|
|
segmentId?: number;
|
|
companyName?: string;
|
|
tradeName?: string;
|
|
taxId?: string;
|
|
stateRegistration?: string;
|
|
cityRegistration?: string;
|
|
taxRegime?: string;
|
|
modulesCrm?: number;
|
|
modulesSales?: number;
|
|
modulesPurchases?: number;
|
|
modulesStock?: number;
|
|
modulesFinance?: number;
|
|
modulesAccounting?: number;
|
|
modulesProduction?: number;
|
|
modulesProjects?: number;
|
|
modulesHr?: number;
|
|
modulesServiceOrder?: number;
|
|
}
|
|
|
|
const api = {
|
|
get: async (url: string) => {
|
|
const res = await fetch(url, { credentials: "include" });
|
|
if (!res.ok) throw new Error("Request failed");
|
|
return res.json();
|
|
},
|
|
post: async (url: string, data: any) => {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) throw new Error("Request failed");
|
|
return res.json();
|
|
},
|
|
put: async (url: string, data: any) => {
|
|
const res = await fetch(url, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) throw new Error("Request failed");
|
|
return res.json();
|
|
},
|
|
delete: async (url: string) => {
|
|
const res = await fetch(url, { method: "DELETE", credentials: "include" });
|
|
if (!res.ok) throw new Error("Request failed");
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
function PersonsTab() {
|
|
const { toast } = useToast();
|
|
const [persons, setPersons] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [roleFilter, setRoleFilter] = useState("all");
|
|
const [showDialog, setShowDialog] = useState(false);
|
|
const [editingPerson, setEditingPerson] = useState<any>(null);
|
|
const [formData, setFormData] = useState({
|
|
fullName: "", cpfCnpj: "", email: "", phone: "", whatsapp: "",
|
|
address: "", city: "", state: "", zipCode: "", notes: "", roles: [] as string[]
|
|
});
|
|
|
|
const loadPersons = async () => {
|
|
try {
|
|
setLoading(true);
|
|
let url = "/api/soe/persons";
|
|
const params = new URLSearchParams();
|
|
if (search) params.append("search", search);
|
|
if (roleFilter && roleFilter !== "all") params.append("role", roleFilter);
|
|
if (params.toString()) url += `?${params.toString()}`;
|
|
|
|
const res = await fetch(url, { credentials: "include" });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setPersons(data);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading persons:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { loadPersons(); }, [search, roleFilter]);
|
|
|
|
const handleEdit = (person: any) => {
|
|
setEditingPerson(person);
|
|
setFormData({
|
|
fullName: person.fullName || "",
|
|
cpfCnpj: person.cpfCnpj || "",
|
|
email: person.email || "",
|
|
phone: person.phone || "",
|
|
whatsapp: person.whatsapp || "",
|
|
address: person.address || "",
|
|
city: person.city || "",
|
|
state: person.state || "",
|
|
zipCode: person.zipCode || "",
|
|
notes: person.notes || "",
|
|
roles: Array.isArray(person.roles) ? (typeof person.roles[0] === 'string' ? person.roles : person.roles.map((r: any) => r.roleType || r)) : []
|
|
});
|
|
setShowDialog(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
const method = editingPerson ? "PUT" : "POST";
|
|
const url = editingPerson ? `/api/soe/persons/${editingPerson.id}` : "/api/soe/persons";
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...formData, roles: formData.roles }),
|
|
credentials: "include"
|
|
});
|
|
|
|
if (res.ok) {
|
|
toast({ title: editingPerson ? "Pessoa atualizada!" : "Pessoa cadastrada!" });
|
|
setShowDialog(false);
|
|
setEditingPerson(null);
|
|
setFormData({ fullName: "", cpfCnpj: "", email: "", phone: "", whatsapp: "", address: "", city: "", state: "", zipCode: "", notes: "", roles: [] });
|
|
loadPersons();
|
|
} else {
|
|
toast({ title: "Erro ao salvar pessoa", variant: "destructive" });
|
|
}
|
|
} catch (error) {
|
|
toast({ title: "Erro ao salvar pessoa", variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const toggleRole = (role: string) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
roles: prev.roles.includes(role) ? prev.roles.filter(r => r !== role) : [...prev.roles, role]
|
|
}));
|
|
};
|
|
|
|
const roleLabels: Record<string, string> = {
|
|
customer: "Cliente", supplier: "Fornecedor", employee: "Funcionário",
|
|
technician: "Técnico", partner: "Parceiro"
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex gap-2 flex-1 max-w-xl">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input placeholder="Buscar pessoas..." className="pl-10" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
</div>
|
|
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
|
<SelectTrigger className="w-40"><SelectValue placeholder="Filtrar por papel" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Todos</SelectItem>
|
|
<SelectItem value="customer">Clientes</SelectItem>
|
|
<SelectItem value="supplier">Fornecedores</SelectItem>
|
|
<SelectItem value="employee">Funcionários</SelectItem>
|
|
<SelectItem value="technician">Técnicos</SelectItem>
|
|
<SelectItem value="partner">Parceiros</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button onClick={() => { setEditingPerson(null); setFormData({ fullName: "", cpfCnpj: "", email: "", phone: "", whatsapp: "", address: "", city: "", state: "", zipCode: "", notes: "", roles: [] }); setShowDialog(true); }}>
|
|
<Plus className="w-4 h-4 mr-2" /> Nova Pessoa
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Nome</TableHead>
|
|
<TableHead>CPF/CNPJ</TableHead>
|
|
<TableHead>Telefone</TableHead>
|
|
<TableHead>Cidade/UF</TableHead>
|
|
<TableHead>Papéis</TableHead>
|
|
<TableHead className="text-right">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow><TableCell colSpan={6} className="text-center py-8"><Loader2 className="w-6 h-6 animate-spin mx-auto" /></TableCell></TableRow>
|
|
) : persons.length === 0 ? (
|
|
<TableRow><TableCell colSpan={6} className="text-center py-8 text-muted-foreground">Nenhuma pessoa cadastrada</TableCell></TableRow>
|
|
) : (
|
|
persons.map((person) => (
|
|
<TableRow key={person.id}>
|
|
<TableCell className="font-medium">{person.fullName}</TableCell>
|
|
<TableCell>{person.cpfCnpj}</TableCell>
|
|
<TableCell>{person.phone || person.whatsapp}</TableCell>
|
|
<TableCell>{person.city}/{person.state}</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-1 flex-wrap">
|
|
{(Array.isArray(person.roles) ? person.roles : []).map((role: any, idx: number) => {
|
|
const roleType = typeof role === 'string' ? role : role.roleType;
|
|
return <Badge key={idx} variant="outline" className="text-xs">{roleLabels[roleType] || roleType}</Badge>;
|
|
})}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(person)}><Edit className="w-4 h-4" /></Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingPerson ? "Editar Pessoa" : "Nova Pessoa"}</DialogTitle>
|
|
<DialogDescription>Cadastro unificado de pessoas - uma pessoa pode ter múltiplos papéis</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><Label>Nome Completo</Label><Input value={formData.fullName} onChange={(e) => setFormData({...formData, fullName: e.target.value})} /></div>
|
|
<div><Label>CPF/CNPJ</Label><Input value={formData.cpfCnpj} onChange={(e) => setFormData({...formData, cpfCnpj: e.target.value})} /></div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><Label>E-mail</Label><Input type="email" value={formData.email} onChange={(e) => setFormData({...formData, email: e.target.value})} /></div>
|
|
<div><Label>Telefone</Label><Input value={formData.phone} onChange={(e) => setFormData({...formData, phone: e.target.value})} /></div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div><Label>Cidade</Label><Input value={formData.city} onChange={(e) => setFormData({...formData, city: e.target.value})} /></div>
|
|
<div><Label>Estado</Label><Input value={formData.state} onChange={(e) => setFormData({...formData, state: e.target.value})} /></div>
|
|
<div><Label>CEP</Label><Input value={formData.zipCode} onChange={(e) => setFormData({...formData, zipCode: e.target.value})} /></div>
|
|
</div>
|
|
<div>
|
|
<Label>Papéis</Label>
|
|
<div className="flex gap-2 mt-2 flex-wrap">
|
|
{Object.entries(roleLabels).map(([key, label]) => (
|
|
<Button key={key} type="button" variant={formData.roles.includes(key) ? "default" : "outline"} size="sm" onClick={() => toggleRole(key)}>{label}</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div><Label>Observações</Label><Textarea value={formData.notes} onChange={(e) => setFormData({...formData, notes: e.target.value})} /></div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowDialog(false)}>Cancelar</Button>
|
|
<Button onClick={handleSave}>Salvar</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function SOE() {
|
|
const { user } = useAuth();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [activeTab, setActiveTab] = useState<TabType>("dashboard");
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [showCustomerDialog, setShowCustomerDialog] = useState(false);
|
|
const [showSupplierDialog, setShowSupplierDialog] = useState(false);
|
|
const [showProductDialog, setShowProductDialog] = useState(false);
|
|
const [showSalesDialog, setShowSalesDialog] = useState(false);
|
|
const [showPurchaseDialog, setShowPurchaseDialog] = useState(false);
|
|
const [showBatchImeiDialog, setShowBatchImeiDialog] = useState(false);
|
|
const [selectedProductForDevices, setSelectedProductForDevices] = useState<Product | null>(null);
|
|
const [batchImeiText, setBatchImeiText] = useState("");
|
|
const [editingItem, setEditingItem] = useState<any>(null);
|
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
|
const { motor: erpProfile, setMotor: setErpProfile, usePlus, useERPNext } = useSoeMotor();
|
|
|
|
const tenantId = (user as any)?.tenantId || 1;
|
|
|
|
const { data: erpnextStatus, isLoading: loadingStatus, refetch: refetchStatus } = useQuery<ERPNextStatus>({
|
|
queryKey: ["/api/erpnext/status"],
|
|
queryFn: () => api.get("/api/erpnext/status"),
|
|
retry: false,
|
|
});
|
|
|
|
const isERPNextConnected = erpnextStatus?.connected || false;
|
|
|
|
// Auto-switch to ERPNext if connected and no profile saved
|
|
useEffect(() => {
|
|
if (isERPNextConnected && !localStorage.getItem("arcadia_erp_profile")) {
|
|
setErpProfile("erpnext");
|
|
}
|
|
}, [isERPNextConnected]);
|
|
|
|
const { data: erpnextCustomers = [] } = useQuery<ERPNextCustomer[]>({
|
|
queryKey: ["/api/erpnext/resource/Customer"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/api/erpnext/resource/Customer?limit=100");
|
|
return res.data || [];
|
|
},
|
|
enabled: useERPNext && isERPNextConnected,
|
|
});
|
|
|
|
const { data: erpnextSuppliers = [] } = useQuery<ERPNextSupplier[]>({
|
|
queryKey: ["/api/erpnext/resource/Supplier"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/api/erpnext/resource/Supplier?limit=100");
|
|
return res.data || [];
|
|
},
|
|
enabled: useERPNext && isERPNextConnected,
|
|
});
|
|
|
|
const { data: erpnextItems = [] } = useQuery<ERPNextItem[]>({
|
|
queryKey: ["/api/erpnext/resource/Item"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/api/erpnext/resource/Item?limit=100");
|
|
return res.data || [];
|
|
},
|
|
enabled: useERPNext && isERPNextConnected,
|
|
});
|
|
|
|
const { data: erpnextSalesOrders = [] } = useQuery<ERPNextSalesOrder[]>({
|
|
queryKey: ["/api/erpnext/resource/Sales Order"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/api/erpnext/resource/Sales%20Order?limit=100");
|
|
return res.data || [];
|
|
},
|
|
enabled: useERPNext && isERPNextConnected,
|
|
});
|
|
|
|
const { data: erpnextLeads = [] } = useQuery<ERPNextLead[]>({
|
|
queryKey: ["/api/erpnext/resource/Lead"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/api/erpnext/resource/Lead?limit=100");
|
|
return res.data || [];
|
|
},
|
|
enabled: useERPNext && isERPNextConnected,
|
|
});
|
|
|
|
const { data: erpnextOpportunities = [] } = useQuery<ERPNextOpportunity[]>({
|
|
queryKey: ["/api/erpnext/resource/Opportunity"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/api/erpnext/resource/Opportunity?limit=100");
|
|
return res.data || [];
|
|
},
|
|
enabled: useERPNext && isERPNextConnected,
|
|
});
|
|
|
|
// Plus ERP Queries
|
|
const { data: plusStats } = useQuery({
|
|
queryKey: ["/plus/api/suite/stats"],
|
|
queryFn: () => api.get("/plus/api/suite/stats"),
|
|
enabled: usePlus,
|
|
});
|
|
|
|
const { data: plusClientes = [] } = useQuery<any[]>({
|
|
queryKey: ["/plus/api/suite/clientes"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/plus/api/suite/clientes");
|
|
return res || [];
|
|
},
|
|
enabled: usePlus,
|
|
});
|
|
|
|
const { data: plusFornecedores = [] } = useQuery<any[]>({
|
|
queryKey: ["/plus/api/suite/fornecedores"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/plus/api/suite/fornecedores");
|
|
return res || [];
|
|
},
|
|
enabled: usePlus,
|
|
});
|
|
|
|
const { data: plusProdutos = [] } = useQuery<any[]>({
|
|
queryKey: ["/plus/api/suite/produtos"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/plus/api/suite/produtos");
|
|
return res || [];
|
|
},
|
|
enabled: usePlus,
|
|
});
|
|
|
|
const { data: plusVendas = [] } = useQuery<any[]>({
|
|
queryKey: ["/plus/api/suite/vendas"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/plus/api/suite/vendas");
|
|
return res || [];
|
|
},
|
|
enabled: usePlus,
|
|
});
|
|
|
|
const { data: plusDashboard } = useQuery<any>({
|
|
queryKey: ["/plus/api/suite/dashboard"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/plus/api/suite/dashboard");
|
|
return res || {};
|
|
},
|
|
enabled: usePlus,
|
|
});
|
|
|
|
const { data: plusEmpresas = [] } = useQuery<any[]>({
|
|
queryKey: ["/plus/api/suite/empresas"],
|
|
queryFn: async () => {
|
|
const res = await api.get("/plus/api/suite/empresas");
|
|
return res || [];
|
|
},
|
|
enabled: usePlus,
|
|
});
|
|
|
|
// Unified data - adapts from Plus or ERPNext to common format
|
|
const customers: Customer[] = usePlus
|
|
? (plusClientes || []).map((c: any) => ({
|
|
id: c.id,
|
|
code: String(c.id),
|
|
name: c.razao_social || c.nome,
|
|
type: c.cpf_cnpj?.length > 14 ? "PJ" : "PF",
|
|
taxId: c.cpf_cnpj || "",
|
|
email: c.email || "",
|
|
phone: c.celular || c.telefone || "",
|
|
city: c.cidade || "",
|
|
state: c.uf || "",
|
|
status: c.status === "ativo" ? "active" : "inactive",
|
|
creditLimit: "0",
|
|
}))
|
|
: [];
|
|
|
|
const suppliers: Supplier[] = usePlus
|
|
? (plusFornecedores || []).map((f: any) => ({
|
|
id: f.id,
|
|
code: String(f.id),
|
|
name: f.razao_social || f.nome,
|
|
type: f.cpf_cnpj?.length > 14 ? "PJ" : "PF",
|
|
taxId: f.cpf_cnpj || "",
|
|
email: f.email || "",
|
|
phone: f.telefone || "",
|
|
city: f.cidade || "",
|
|
state: f.uf || "",
|
|
status: f.status === "ativo" ? "active" : "inactive",
|
|
}))
|
|
: [];
|
|
|
|
const { data: localProducts = [] } = useQuery<Product[]>({
|
|
queryKey: ["/api/soe/products"],
|
|
queryFn: () => api.get("/api/soe/products"),
|
|
});
|
|
|
|
const plusProductsMapped: Product[] = usePlus && plusProdutos?.length
|
|
? (plusProdutos || []).map((p: any) => ({
|
|
id: p.id,
|
|
code: p.codigo || String(p.id),
|
|
name: p.nome,
|
|
description: p.descricao || "",
|
|
category: p.categoria?.nome || "Geral",
|
|
unit: p.unidade || "UN",
|
|
costPrice: String(p.valor_compra || 0),
|
|
salePrice: String(p.valor_unitario || 0),
|
|
stockQty: String(p.estoque_atual || 0),
|
|
minStock: String(p.estoque_minimo || 0),
|
|
ncm: p.ncm || "",
|
|
barcode: p.codigo_barras || "",
|
|
status: p.status ? "active" : "inactive",
|
|
}))
|
|
: [];
|
|
|
|
const products: Product[] = localProducts.length > 0 ? localProducts : plusProductsMapped;
|
|
|
|
const { data: taxGroups = [] } = useQuery<TaxGroup[]>({
|
|
queryKey: ["/api/fisco/grupos-tributacao", tenantId],
|
|
queryFn: () => api.get(`/api/fisco/grupos-tributacao?tenantId=${tenantId}`),
|
|
});
|
|
|
|
const { data: segments = [] } = useQuery<ErpSegment[]>({
|
|
queryKey: ["/api/soe/segments"],
|
|
queryFn: () => api.get("/api/soe/segments"),
|
|
});
|
|
|
|
const { data: erpConfigFromServer } = useQuery<ErpConfigData | null>({
|
|
queryKey: ["/api/soe/config"],
|
|
queryFn: () => api.get("/api/soe/config"),
|
|
});
|
|
|
|
const [erpConfigData, setErpConfigData] = useState<ErpConfigData>({
|
|
modulesCrm: 1,
|
|
modulesSales: 1,
|
|
modulesPurchases: 1,
|
|
modulesStock: 1,
|
|
modulesFinance: 1,
|
|
modulesAccounting: 0,
|
|
modulesProduction: 0,
|
|
modulesProjects: 0,
|
|
modulesHr: 0,
|
|
modulesServiceOrder: 0,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (erpConfigFromServer) {
|
|
setErpConfigData(erpConfigFromServer);
|
|
}
|
|
}, [erpConfigFromServer]);
|
|
|
|
const selectedSegment = segments.find(s => s.id === erpConfigData?.segmentId);
|
|
|
|
const handleSeedSegments = async () => {
|
|
try {
|
|
await api.post("/api/soe/segments/seed", {});
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/segments"] });
|
|
toast({ title: "Segmentos carregados com sucesso!" });
|
|
} catch (error) {
|
|
toast({ title: "Erro ao carregar segmentos", variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const handleSaveConfig = async () => {
|
|
try {
|
|
await api.post("/api/soe/config", erpConfigData);
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/config"] });
|
|
toast({ title: "Configuração salva com sucesso!" });
|
|
} catch (error) {
|
|
toast({ title: "Erro ao salvar configuração", variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const { data: salesOrders = [] } = useQuery<SalesOrder[]>({
|
|
queryKey: ["/api/soe/sales-orders", tenantId],
|
|
queryFn: () => api.get(`/api/soe/sales-orders?tenantId=${tenantId}`),
|
|
enabled: !useERPNext,
|
|
});
|
|
|
|
const { data: purchaseOrders = [] } = useQuery<PurchaseOrder[]>({
|
|
queryKey: ["/api/soe/purchase-orders", tenantId],
|
|
queryFn: () => api.get(`/api/soe/purchase-orders?tenantId=${tenantId}`),
|
|
enabled: !useERPNext,
|
|
});
|
|
|
|
const { data: stats = { customers: 0, suppliers: 0, products: 0, salesOrders: 0, purchaseOrders: 0 } } = useQuery({
|
|
queryKey: ["/api/soe/stats", tenantId],
|
|
queryFn: () => api.get(`/api/soe/stats?tenantId=${tenantId}`),
|
|
enabled: !useERPNext,
|
|
});
|
|
|
|
const displayCustomers = useERPNext ? erpnextCustomers.length : (stats.customers || customers.length);
|
|
const displaySuppliers = useERPNext ? erpnextSuppliers.length : (stats.suppliers || suppliers.length);
|
|
const displayProducts = useERPNext ? erpnextItems.length : (stats.products || products.length);
|
|
const displaySalesOrders = useERPNext ? erpnextSalesOrders.length : (stats.salesOrders || salesOrders.length);
|
|
const displayPurchaseOrders = useERPNext ? 0 : (stats.purchaseOrders || purchaseOrders.length);
|
|
|
|
const createCustomerMutation = useMutation({
|
|
mutationFn: (data: any) => api.post("/api/soe/customers", { ...data, tenantId }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/customers"] });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
|
setShowCustomerDialog(false);
|
|
toast({ title: "Cliente criado com sucesso!" });
|
|
},
|
|
onError: () => toast({ title: "Erro ao criar cliente", variant: "destructive" }),
|
|
});
|
|
|
|
const updateCustomerMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/soe/customers/${id}`, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/customers"] });
|
|
setShowCustomerDialog(false);
|
|
setEditingItem(null);
|
|
toast({ title: "Cliente atualizado!" });
|
|
},
|
|
});
|
|
|
|
const deleteCustomerMutation = useMutation({
|
|
mutationFn: (id: number) => api.delete(`/api/soe/customers/${id}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/customers"] });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
|
toast({ title: "Cliente excluído!" });
|
|
},
|
|
});
|
|
|
|
const createSupplierMutation = useMutation({
|
|
mutationFn: (data: any) => api.post("/api/soe/suppliers", { ...data, tenantId }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/suppliers"] });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
|
setShowSupplierDialog(false);
|
|
toast({ title: "Fornecedor criado com sucesso!" });
|
|
},
|
|
});
|
|
|
|
const createProductMutation = useMutation({
|
|
mutationFn: (data: any) => api.post("/api/soe/products", { ...data, tenantId }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/products"] });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
|
setShowProductDialog(false);
|
|
setEditingProduct(null);
|
|
toast({ title: "Produto criado com sucesso!" });
|
|
},
|
|
onError: () => toast({ title: "Erro ao criar produto", variant: "destructive" }),
|
|
});
|
|
|
|
const updateProductMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/api/soe/products/${id}`, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/products"] });
|
|
setShowProductDialog(false);
|
|
setEditingProduct(null);
|
|
toast({ title: "Produto atualizado!" });
|
|
},
|
|
onError: () => toast({ title: "Erro ao atualizar produto", variant: "destructive" }),
|
|
});
|
|
|
|
const deleteProductMutation = useMutation({
|
|
mutationFn: (id: number) => api.delete(`/api/soe/products/${id}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/products"] });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
|
toast({ title: "Produto excluído!" });
|
|
},
|
|
onError: () => toast({ title: "Erro ao excluir produto", variant: "destructive" }),
|
|
});
|
|
|
|
const batchAddDevicesMutation = useMutation({
|
|
mutationFn: ({ productId, devices }: { productId: number; devices: Array<{ imei: string }> }) =>
|
|
api.post(`/api/soe/products/${productId}/devices/batch`, { devices }),
|
|
onSuccess: (data: any) => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/products"] });
|
|
setShowBatchImeiDialog(false);
|
|
setBatchImeiText("");
|
|
setSelectedProductForDevices(null);
|
|
toast({
|
|
title: `${data.inserted} dispositivo(s) adicionado(s)!`,
|
|
description: data.duplicates?.length > 0 ? `${data.duplicates.length} IMEI(s) duplicado(s) ignorado(s)` : undefined
|
|
});
|
|
},
|
|
onError: (error: any) => toast({
|
|
title: "Erro ao adicionar dispositivos",
|
|
description: error?.message || "Verifique os IMEIs informados",
|
|
variant: "destructive"
|
|
}),
|
|
});
|
|
|
|
const createSalesOrderMutation = useMutation({
|
|
mutationFn: (data: any) => api.post("/api/soe/sales-orders", { ...data, tenantId }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/sales-orders"] });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/stats"] });
|
|
setShowSalesDialog(false);
|
|
toast({ title: "Pedido de venda criado!" });
|
|
},
|
|
});
|
|
|
|
const confirmSalesOrderMutation = useMutation({
|
|
mutationFn: (id: number) => api.post(`/api/soe/sales-orders/${id}/confirm`, {}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/sales-orders"] });
|
|
toast({ title: "Pedido confirmado! Lançamentos contábeis gerados." });
|
|
},
|
|
});
|
|
|
|
const generateNfeMutation = useMutation({
|
|
mutationFn: (id: number) => api.post(`/api/soe/sales-orders/${id}/generate-nfe`, {}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/soe/sales-orders"] });
|
|
toast({ title: "NF-e gerada com sucesso!" });
|
|
},
|
|
onError: () => toast({ title: "Erro ao gerar NF-e", variant: "destructive" }),
|
|
});
|
|
|
|
const filteredCustomers = customers.filter(c =>
|
|
c.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
c.code?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
c.taxId?.includes(searchTerm)
|
|
);
|
|
|
|
const filteredSuppliers = suppliers.filter(s =>
|
|
s.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
s.code?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
const filteredProducts = products.filter(p =>
|
|
p.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
p.code?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
switch (status) {
|
|
case "draft": return <Badge variant="outline"><Clock className="w-3 h-3 mr-1" />Rascunho</Badge>;
|
|
case "confirmed": return <Badge className="bg-blue-500"><CheckCircle className="w-3 h-3 mr-1" />Confirmado</Badge>;
|
|
case "invoiced": return <Badge className="bg-green-500"><Receipt className="w-3 h-3 mr-1" />Faturado</Badge>;
|
|
case "delivered": return <Badge className="bg-emerald-600"><Package className="w-3 h-3 mr-1" />Entregue</Badge>;
|
|
case "cancelled": return <Badge variant="destructive"><XCircle className="w-3 h-3 mr-1" />Cancelado</Badge>;
|
|
case "active": return <Badge className="bg-green-500">Ativo</Badge>;
|
|
case "inactive": return <Badge variant="outline">Inativo</Badge>;
|
|
default: return <Badge variant="outline">{status}</Badge>;
|
|
}
|
|
};
|
|
|
|
const handleSaveCustomer = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
const data = {
|
|
code: formData.get("code"),
|
|
name: formData.get("name"),
|
|
type: formData.get("type") || "company",
|
|
taxId: formData.get("taxId"),
|
|
email: formData.get("email"),
|
|
phone: formData.get("phone"),
|
|
address: formData.get("address"),
|
|
city: formData.get("city"),
|
|
state: formData.get("state"),
|
|
creditLimit: formData.get("creditLimit") || "0",
|
|
paymentTerms: Number(formData.get("paymentTerms")) || 30,
|
|
status: "active",
|
|
};
|
|
if (editingItem) {
|
|
updateCustomerMutation.mutate({ id: editingItem.id, data });
|
|
} else {
|
|
createCustomerMutation.mutate(data);
|
|
}
|
|
};
|
|
|
|
const handleSaveSupplier = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
const data = {
|
|
code: formData.get("code"),
|
|
name: formData.get("name"),
|
|
taxId: formData.get("taxId"),
|
|
email: formData.get("email"),
|
|
phone: formData.get("phone"),
|
|
address: formData.get("address"),
|
|
city: formData.get("city"),
|
|
state: formData.get("state"),
|
|
paymentTerms: Number(formData.get("paymentTerms")) || 30,
|
|
status: "active",
|
|
};
|
|
createSupplierMutation.mutate(data);
|
|
};
|
|
|
|
const handleSaveProduct = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
const taxGroupIdValue = formData.get("taxGroupId");
|
|
const trackingType = formData.get("trackingType") as string || "none";
|
|
const data = {
|
|
code: formData.get("code"),
|
|
name: formData.get("name"),
|
|
description: formData.get("description"),
|
|
category: formData.get("category"),
|
|
unit: formData.get("unit") || "UN",
|
|
costPrice: formData.get("costPrice") || "0",
|
|
salePrice: formData.get("salePrice") || "0",
|
|
stockQty: formData.get("stockQty") || "0",
|
|
minStock: formData.get("minStock") || "0",
|
|
ncm: formData.get("ncm"),
|
|
barcode: formData.get("barcode"),
|
|
taxGroupId: taxGroupIdValue ? Number(taxGroupIdValue) : null,
|
|
status: "active",
|
|
requiresSerialTracking: trackingType !== "none",
|
|
trackingType,
|
|
defaultBrand: formData.get("defaultBrand") || null,
|
|
defaultModel: formData.get("defaultModel") || null,
|
|
};
|
|
if (editingProduct) {
|
|
updateProductMutation.mutate({ id: editingProduct.id, data });
|
|
} else {
|
|
createProductMutation.mutate(data);
|
|
}
|
|
};
|
|
|
|
const handleSaveSalesOrder = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
const data = {
|
|
orderNumber: `PV-${Date.now()}`,
|
|
customerId: Number(formData.get("customerId")),
|
|
orderDate: new Date().toISOString(),
|
|
deliveryDate: formData.get("deliveryDate"),
|
|
paymentMethod: formData.get("paymentMethod"),
|
|
notes: formData.get("notes"),
|
|
subtotal: formData.get("subtotal") || "0",
|
|
discount: formData.get("discount") || "0",
|
|
tax: formData.get("tax") || "0",
|
|
total: formData.get("total") || "0",
|
|
status: "draft",
|
|
};
|
|
createSalesOrderMutation.mutate(data);
|
|
};
|
|
|
|
return (
|
|
<BrowserFrame>
|
|
<div className="h-full flex flex-col bg-background">
|
|
<div className="border-b bg-muted/30 px-6 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-blue-800 rounded-lg flex items-center justify-center">
|
|
<Building2 className="w-5 h-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold">Arcádia SOE</h1>
|
|
<p className="text-sm text-muted-foreground">Sistema Operacional Empresarial</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Label htmlFor="erpProfile" className="text-xs font-medium">Motor:</Label>
|
|
<Select value={erpProfile} onValueChange={(v) => setErpProfile(v as SoeMotor)}>
|
|
<SelectTrigger className="w-40 h-8" data-testid="select-soe-motor">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="plus">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 className="w-3 h-3 text-emerald-600" />
|
|
Arcádia Plus
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem value="erpnext" disabled={!isERPNextConnected}>
|
|
<div className="flex items-center gap-2">
|
|
<Cloud className="w-3 h-3 text-blue-600" />
|
|
Arcádia Next {!isERPNextConnected && "(Offline)"}
|
|
</div>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{erpProfile === "plus" && (
|
|
<Badge className="bg-emerald-100 text-emerald-800 border-emerald-200 gap-1">
|
|
<Building2 className="w-3 h-3" />
|
|
Plus
|
|
</Badge>
|
|
)}
|
|
{erpProfile === "erpnext" && isERPNextConnected && (
|
|
<Badge className="bg-blue-100 text-blue-800 border-blue-200 gap-1">
|
|
<Cloud className="w-3 h-3" />
|
|
Next
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => window.location.href = "/fisco"} data-testid="btn-fisco">
|
|
<Receipt className="w-4 h-4 mr-2" />
|
|
Fisco
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => window.location.href = "/contabil"} data-testid="btn-contabil">
|
|
<BookOpen className="w-4 h-4 mr-2" />
|
|
Contábil
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => window.location.href = "/people"}>
|
|
<Users className="w-4 h-4 mr-2" />
|
|
RH
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabType)} className="h-full flex flex-col">
|
|
<div className="border-b px-6">
|
|
<TabsList className="h-12">
|
|
<TabsTrigger value="dashboard" className="gap-2">
|
|
<BarChart3 className="w-4 h-4" />
|
|
Dashboard
|
|
</TabsTrigger>
|
|
<TabsTrigger value="persons" className="gap-2" data-testid="tab-persons">
|
|
<Users className="w-4 h-4" />
|
|
Pessoas
|
|
</TabsTrigger>
|
|
<TabsTrigger value="products" className="gap-2" data-testid="tab-products">
|
|
<Package2 className="w-4 h-4" />
|
|
Produtos
|
|
</TabsTrigger>
|
|
<TabsTrigger value="sales" className="gap-2" data-testid="tab-sales">
|
|
<TrendingUp className="w-4 h-4" />
|
|
Vendas
|
|
</TabsTrigger>
|
|
<TabsTrigger value="purchases" className="gap-2" data-testid="tab-purchases">
|
|
<ShoppingCart className="w-4 h-4" />
|
|
Compras
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="financial"
|
|
className="gap-2"
|
|
data-testid="tab-financial"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
window.location.href = '/financeiro';
|
|
}}
|
|
>
|
|
<Wallet className="w-4 h-4" />
|
|
Financeiro
|
|
<ExternalLink className="w-3 h-3 ml-1" />
|
|
</TabsTrigger>
|
|
{useERPNext && (
|
|
<TabsTrigger value="crm" className="gap-2" data-testid="tab-crm">
|
|
<Target className="w-4 h-4" />
|
|
CRM
|
|
</TabsTrigger>
|
|
)}
|
|
<TabsTrigger value="sync" className="gap-2" data-testid="tab-sync">
|
|
<RefreshCw className="w-4 h-4" />
|
|
Sincronização
|
|
</TabsTrigger>
|
|
{(user as any)?.role === "admin" && (
|
|
<TabsTrigger value="config" className="gap-2" data-testid="tab-config">
|
|
<UserCog className="w-4 h-4" />
|
|
Configuração
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
</div>
|
|
|
|
<ScrollArea className="flex-1 p-6">
|
|
<TabsContent value="dashboard" className="mt-0 space-y-6">
|
|
<div className="grid grid-cols-5 gap-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Clientes</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{displayCustomers}</div>
|
|
{useERPNext && <p className="text-xs text-muted-foreground">ERPNext</p>}
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Fornecedores</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{displaySuppliers}</div>
|
|
{useERPNext && <p className="text-xs text-muted-foreground">ERPNext</p>}
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Produtos</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{displayProducts}</div>
|
|
{useERPNext && <p className="text-xs text-muted-foreground">ERPNext</p>}
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Pedidos Venda</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{displaySalesOrders}</div>
|
|
{useERPNext && <p className="text-xs text-muted-foreground">ERPNext</p>}
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Pedidos Compra</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{displayPurchaseOrders}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{useERPNext && erpnextLeads.length > 0 && (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<Target className="w-4 h-4" />
|
|
Leads (CRM)
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{erpnextLeads.length}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<Briefcase className="w-4 h-4" />
|
|
Oportunidades
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{erpnextOpportunities.length}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="w-5 h-5 text-green-500" />
|
|
Vendas Recentes
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{salesOrders.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Nenhum pedido de venda registrado</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{salesOrders.slice(0, 5).map((order) => (
|
|
<div key={order.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
|
<div>
|
|
<p className="font-medium">{order.orderNumber}</p>
|
|
<p className="text-sm text-muted-foreground">{new Date(order.orderDate).toLocaleDateString("pt-BR")}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-medium">R$ {Number(order.total).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}</p>
|
|
{getStatusBadge(order.status)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Package2 className="w-5 h-5 text-orange-500" />
|
|
Produtos com Estoque Baixo
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{products.filter(p => Number(p.stockQty) <= Number(p.minStock)).length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Nenhum produto com estoque baixo</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{products.filter(p => Number(p.stockQty) <= Number(p.minStock)).slice(0, 5).map((product) => (
|
|
<div key={product.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
|
<div>
|
|
<p className="font-medium">{product.name}</p>
|
|
<p className="text-sm text-muted-foreground">{product.code}</p>
|
|
</div>
|
|
<Badge variant="destructive">{product.stockQty} {product.unit}</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ArrowRight className="w-5 h-5" />
|
|
Integrações
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="p-4 border rounded-lg">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Receipt className="w-6 h-6 text-emerald-600" />
|
|
<h3 className="font-semibold">Arcádia Fisco</h3>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-3">Gere NF-e/NFC-e diretamente dos pedidos de venda</p>
|
|
<Button variant="outline" size="sm" className="w-full" onClick={() => window.location.href = "/fisco"}>
|
|
Acessar Fisco
|
|
</Button>
|
|
</div>
|
|
<div className="p-4 border rounded-lg">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<BookOpen className="w-6 h-6 text-purple-600" />
|
|
<h3 className="font-semibold">Arcádia Contábil</h3>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-3">Lançamentos contábeis automáticos de vendas e compras</p>
|
|
<Button variant="outline" size="sm" className="w-full" onClick={() => window.location.href = "/contabil"}>
|
|
Acessar Contábil
|
|
</Button>
|
|
</div>
|
|
<div className="p-4 border rounded-lg">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Users className="w-6 h-6 text-blue-600" />
|
|
<h3 className="font-semibold">Arcádia People</h3>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-3">Gestão de colaboradores, comissões e folha de pagamento</p>
|
|
<Button variant="outline" size="sm" className="w-full" onClick={() => window.location.href = "/people"}>
|
|
Acessar RH
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Arcádia Insights</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<SupersetDashboard dashboardId="executive-summary" height={480} />
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="persons" className="mt-0 space-y-4">
|
|
<PersonsTab />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="customers" className="mt-0 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Buscar clientes..."
|
|
className="pl-10"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
data-testid="input-search-customers"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => { setEditingItem(null); setShowCustomerDialog(true); }} data-testid="btn-new-customer">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Novo Cliente
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Código</TableHead>
|
|
<TableHead>Nome</TableHead>
|
|
<TableHead>CNPJ/CPF</TableHead>
|
|
<TableHead>Cidade/UF</TableHead>
|
|
<TableHead>Limite</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredCustomers.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
|
Nenhum cliente cadastrado
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredCustomers.map((customer) => (
|
|
<TableRow key={customer.id} data-testid={`row-customer-${customer.id}`}>
|
|
<TableCell className="font-mono">{customer.code}</TableCell>
|
|
<TableCell className="font-medium">{customer.name}</TableCell>
|
|
<TableCell>{customer.taxId}</TableCell>
|
|
<TableCell>{customer.city}/{customer.state}</TableCell>
|
|
<TableCell>R$ {Number(customer.creditLimit || 0).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}</TableCell>
|
|
<TableCell>{getStatusBadge(customer.status)}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="icon" onClick={() => { setEditingItem(customer); setShowCustomerDialog(true); }}>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={() => deleteCustomerMutation.mutate(customer.id)}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="suppliers" className="mt-0 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Buscar fornecedores..."
|
|
className="pl-10"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
data-testid="input-search-suppliers"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => { setEditingItem(null); setShowSupplierDialog(true); }} data-testid="btn-new-supplier">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Novo Fornecedor
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Código</TableHead>
|
|
<TableHead>Nome</TableHead>
|
|
<TableHead>CNPJ</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Cidade/UF</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredSuppliers.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
|
Nenhum fornecedor cadastrado
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredSuppliers.map((supplier) => (
|
|
<TableRow key={supplier.id} data-testid={`row-supplier-${supplier.id}`}>
|
|
<TableCell className="font-mono">{supplier.code}</TableCell>
|
|
<TableCell className="font-medium">{supplier.name}</TableCell>
|
|
<TableCell>{supplier.taxId}</TableCell>
|
|
<TableCell>{supplier.email}</TableCell>
|
|
<TableCell>{supplier.city}/{supplier.state}</TableCell>
|
|
<TableCell>{getStatusBadge(supplier.status)}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="icon">
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon">
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="products" className="mt-0 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Buscar produtos..."
|
|
className="pl-10"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
data-testid="input-search-products"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => { setEditingProduct(null); setShowProductDialog(true); }} data-testid="btn-new-product">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Novo Produto
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Código</TableHead>
|
|
<TableHead>Nome</TableHead>
|
|
<TableHead>NCM</TableHead>
|
|
<TableHead>Custo</TableHead>
|
|
<TableHead>Venda</TableHead>
|
|
<TableHead>Estoque</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredProducts.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
|
Nenhum produto cadastrado
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredProducts.map((product) => (
|
|
<TableRow key={product.id} data-testid={`row-product-${product.id}`}>
|
|
<TableCell className="font-mono">{product.code}</TableCell>
|
|
<TableCell className="font-medium">{product.name}</TableCell>
|
|
<TableCell>{product.ncm}</TableCell>
|
|
<TableCell>R$ {Number(product.costPrice || 0).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}</TableCell>
|
|
<TableCell>R$ {Number(product.salePrice || 0).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}</TableCell>
|
|
<TableCell>
|
|
<Badge variant={Number(product.stockQty) <= Number(product.minStock) ? "destructive" : "default"}>
|
|
{product.stockQty} {product.unit}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>{getStatusBadge(product.status)}</TableCell>
|
|
<TableCell className="text-right space-x-1">
|
|
{product.requiresSerialTracking && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => { setSelectedProductForDevices(product); setShowBatchImeiDialog(true); }}
|
|
data-testid={`btn-batch-imei-${product.id}`}
|
|
>
|
|
<Smartphone className="w-4 h-4 mr-1" />
|
|
IMEIs
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => { setEditingProduct(product); setShowProductDialog(true); }}
|
|
data-testid={`btn-edit-product-${product.id}`}
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => { if (confirm(`Excluir ${product.name}?`)) deleteProductMutation.mutate(product.id); }}
|
|
data-testid={`btn-delete-product-${product.id}`}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="sales" className="mt-0 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Buscar pedidos..."
|
|
className="pl-10"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
data-testid="input-search-sales"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => { setEditingItem(null); setShowSalesDialog(true); }} data-testid="btn-new-sales">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Novo Pedido
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Número</TableHead>
|
|
<TableHead>Cliente</TableHead>
|
|
<TableHead>Data</TableHead>
|
|
<TableHead>Total</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{salesOrders.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
|
Nenhum pedido de venda registrado
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
salesOrders.map((order) => (
|
|
<TableRow key={order.id} data-testid={`row-sales-${order.id}`}>
|
|
<TableCell className="font-mono">{order.orderNumber}</TableCell>
|
|
<TableCell>{order.customerName || `Cliente #${order.customerId}`}</TableCell>
|
|
<TableCell>{new Date(order.orderDate).toLocaleDateString("pt-BR")}</TableCell>
|
|
<TableCell className="font-medium">R$ {Number(order.total).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}</TableCell>
|
|
<TableCell>{getStatusBadge(order.status)}</TableCell>
|
|
<TableCell className="text-right space-x-1">
|
|
{order.status === "draft" && (
|
|
<Button variant="outline" size="sm" onClick={() => confirmSalesOrderMutation.mutate(order.id)}>
|
|
<CheckCircle className="w-4 h-4 mr-1" />
|
|
Confirmar
|
|
</Button>
|
|
)}
|
|
{order.status === "confirmed" && (
|
|
<Button variant="outline" size="sm" onClick={() => generateNfeMutation.mutate(order.id)}>
|
|
<Receipt className="w-4 h-4 mr-1" />
|
|
Gerar NF-e
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="icon">
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="purchases" className="mt-0 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Buscar pedidos..."
|
|
className="pl-10"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
data-testid="input-search-purchases"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => { setEditingItem(null); setShowPurchaseDialog(true); }} data-testid="btn-new-purchase">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Novo Pedido
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Número</TableHead>
|
|
<TableHead>Fornecedor</TableHead>
|
|
<TableHead>Data</TableHead>
|
|
<TableHead>Previsão</TableHead>
|
|
<TableHead>Total</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{purchaseOrders.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
|
Nenhum pedido de compra registrado
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
purchaseOrders.map((order) => (
|
|
<TableRow key={order.id} data-testid={`row-purchase-${order.id}`}>
|
|
<TableCell className="font-mono">{order.orderNumber}</TableCell>
|
|
<TableCell>{order.supplierName || `Fornecedor #${order.supplierId}`}</TableCell>
|
|
<TableCell>{new Date(order.orderDate).toLocaleDateString("pt-BR")}</TableCell>
|
|
<TableCell>{order.expectedDate ? new Date(order.expectedDate).toLocaleDateString("pt-BR") : "-"}</TableCell>
|
|
<TableCell className="font-medium">R$ {Number(order.total).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}</TableCell>
|
|
<TableCell>{getStatusBadge(order.status)}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="icon">
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="sync" className="mt-0 space-y-6">
|
|
<SyncDashboard />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="config" className="mt-0 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">Configuração SOE</h2>
|
|
<p className="text-muted-foreground">Configure o SOE de acordo com seu segmento de negócio</p>
|
|
</div>
|
|
<Button onClick={handleSeedSegments} variant="outline" className="gap-2" data-testid="btn-seed-segments">
|
|
<Database className="w-4 h-4" />
|
|
Carregar Segmentos Padrão
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Building2 className="w-5 h-5" />
|
|
Dados da Empresa
|
|
</CardTitle>
|
|
<CardDescription>Informações básicas da sua empresa</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Razão Social</Label>
|
|
<Input
|
|
value={erpConfigData?.companyName || ""}
|
|
onChange={(e) => setErpConfigData(prev => ({ ...prev, companyName: e.target.value }))}
|
|
placeholder="Razão social da empresa"
|
|
data-testid="input-company-name"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Nome Fantasia</Label>
|
|
<Input
|
|
value={erpConfigData?.tradeName || ""}
|
|
onChange={(e) => setErpConfigData(prev => ({ ...prev, tradeName: e.target.value }))}
|
|
placeholder="Nome fantasia"
|
|
data-testid="input-trade-name"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>CNPJ</Label>
|
|
<Input
|
|
value={erpConfigData?.taxId || ""}
|
|
onChange={(e) => setErpConfigData(prev => ({ ...prev, taxId: e.target.value }))}
|
|
placeholder="00.000.000/0000-00"
|
|
data-testid="input-company-taxid"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Regime Tributário</Label>
|
|
<Select
|
|
value={erpConfigData?.taxRegime || "simples"}
|
|
onValueChange={(val) => setErpConfigData(prev => ({ ...prev, taxRegime: val }))}
|
|
>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="simples">Simples Nacional</SelectItem>
|
|
<SelectItem value="lucro_presumido">Lucro Presumido</SelectItem>
|
|
<SelectItem value="lucro_real">Lucro Real</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Inscrição Estadual</Label>
|
|
<Input
|
|
value={erpConfigData?.stateRegistration || ""}
|
|
onChange={(e) => setErpConfigData(prev => ({ ...prev, stateRegistration: e.target.value }))}
|
|
placeholder="Inscrição estadual"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Inscrição Municipal</Label>
|
|
<Input
|
|
value={erpConfigData?.cityRegistration || ""}
|
|
onChange={(e) => setErpConfigData(prev => ({ ...prev, cityRegistration: e.target.value }))}
|
|
placeholder="Inscrição municipal"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button onClick={handleSaveConfig} className="w-full mt-4" data-testid="btn-save-config">
|
|
Salvar Configurações
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Target className="w-5 h-5" />
|
|
Segmento de Negócio
|
|
</CardTitle>
|
|
<CardDescription>Escolha o perfil que melhor representa sua empresa</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Select
|
|
value={String(erpConfigData?.segmentId || "")}
|
|
onValueChange={(val) => setErpConfigData(prev => ({ ...prev, segmentId: parseInt(val) }))}
|
|
>
|
|
<SelectTrigger data-testid="select-segment">
|
|
<SelectValue placeholder="Selecione um segmento" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{segments.map((seg) => (
|
|
<SelectItem key={seg.id} value={String(seg.id)}>
|
|
{seg.name} ({seg.category})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{selectedSegment && (
|
|
<div className="p-4 bg-muted rounded-lg space-y-2">
|
|
<p className="font-medium">{selectedSegment.name}</p>
|
|
<p className="text-sm text-muted-foreground">{selectedSegment.description}</p>
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{selectedSegment.modules?.map((mod: string) => (
|
|
<Badge key={mod} variant="secondary">{mod}</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FolderKanban className="w-5 h-5" />
|
|
Módulos Ativos
|
|
</CardTitle>
|
|
<CardDescription>Habilite ou desabilite módulos conforme sua necessidade</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{[
|
|
{ key: "modulesCrm", label: "CRM", icon: Users },
|
|
{ key: "modulesSales", label: "Vendas", icon: Receipt },
|
|
{ key: "modulesPurchases", label: "Compras", icon: ShoppingCart },
|
|
{ key: "modulesStock", label: "Estoque", icon: Package },
|
|
{ key: "modulesFinance", label: "Financeiro", icon: DollarSign },
|
|
{ key: "modulesAccounting", label: "Contabilidade", icon: BookOpen },
|
|
{ key: "modulesProduction", label: "Produção", icon: Package2 },
|
|
{ key: "modulesProjects", label: "Projetos", icon: FolderKanban },
|
|
{ key: "modulesHr", label: "RH", icon: UserCog },
|
|
{ key: "modulesServiceOrder", label: "Ordem de Serviço", icon: FileText },
|
|
].map(({ key, label, icon: Icon }) => (
|
|
<div key={key} className="flex items-center justify-between p-3 border rounded-lg">
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="w-4 h-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">{label}</span>
|
|
</div>
|
|
<Switch
|
|
checked={erpConfigData?.[key as keyof typeof erpConfigData] === 1}
|
|
onCheckedChange={(checked) => setErpConfigData(prev => ({ ...prev, [key]: checked ? 1 : 0 }))}
|
|
data-testid={`switch-${key}`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</TabsContent>
|
|
</ScrollArea>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<Dialog open={showCustomerDialog} onOpenChange={setShowCustomerDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingItem ? "Editar Cliente" : "Novo Cliente"}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSaveCustomer}>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="code">Código</Label>
|
|
<Input id="code" name="code" defaultValue={editingItem?.code} required data-testid="input-customer-code" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Nome / Razão Social</Label>
|
|
<Input id="name" name="name" defaultValue={editingItem?.name} required data-testid="input-customer-name" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="type">Tipo</Label>
|
|
<Select name="type" defaultValue={editingItem?.type || "company"}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="company">Pessoa Jurídica</SelectItem>
|
|
<SelectItem value="individual">Pessoa Física</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="taxId">CNPJ/CPF</Label>
|
|
<Input id="taxId" name="taxId" defaultValue={editingItem?.taxId} data-testid="input-customer-taxid" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input id="email" name="email" type="email" defaultValue={editingItem?.email} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">Telefone</Label>
|
|
<Input id="phone" name="phone" defaultValue={editingItem?.phone} />
|
|
</div>
|
|
<div className="space-y-2 col-span-2">
|
|
<Label htmlFor="address">Endereço</Label>
|
|
<Input id="address" name="address" defaultValue={editingItem?.address} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="city">Cidade</Label>
|
|
<Input id="city" name="city" defaultValue={editingItem?.city} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="state">UF</Label>
|
|
<Input id="state" name="state" maxLength={2} defaultValue={editingItem?.state} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="creditLimit">Limite de Crédito</Label>
|
|
<Input id="creditLimit" name="creditLimit" type="number" step="0.01" defaultValue={editingItem?.creditLimit || "0"} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="paymentTerms">Prazo Pagamento (dias)</Label>
|
|
<Input id="paymentTerms" name="paymentTerms" type="number" defaultValue={editingItem?.paymentTerms || 30} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="mt-6">
|
|
<Button type="button" variant="outline" onClick={() => setShowCustomerDialog(false)}>Cancelar</Button>
|
|
<Button type="submit" data-testid="btn-save-customer">Salvar</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={showSupplierDialog} onOpenChange={setShowSupplierDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Novo Fornecedor</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSaveSupplier}>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="code">Código</Label>
|
|
<Input id="code" name="code" required data-testid="input-supplier-code" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Nome / Razão Social</Label>
|
|
<Input id="name" name="name" required data-testid="input-supplier-name" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="taxId">CNPJ</Label>
|
|
<Input id="taxId" name="taxId" data-testid="input-supplier-taxid" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input id="email" name="email" type="email" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">Telefone</Label>
|
|
<Input id="phone" name="phone" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="paymentTerms">Prazo Pagamento (dias)</Label>
|
|
<Input id="paymentTerms" name="paymentTerms" type="number" defaultValue={30} />
|
|
</div>
|
|
<div className="space-y-2 col-span-2">
|
|
<Label htmlFor="address">Endereço</Label>
|
|
<Input id="address" name="address" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="city">Cidade</Label>
|
|
<Input id="city" name="city" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="state">UF</Label>
|
|
<Input id="state" name="state" maxLength={2} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="mt-6">
|
|
<Button type="button" variant="outline" onClick={() => setShowSupplierDialog(false)}>Cancelar</Button>
|
|
<Button type="submit" data-testid="btn-save-supplier">Salvar</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={showProductDialog} onOpenChange={(open) => { setShowProductDialog(open); if (!open) setEditingProduct(null); }}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingProduct ? "Editar Produto" : "Novo Produto"}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSaveProduct} className="flex-1 overflow-y-auto pr-2">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="code">Código</Label>
|
|
<Input id="code" name="code" defaultValue={editingProduct?.code || ""} required data-testid="input-product-code" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Nome</Label>
|
|
<Input id="name" name="name" defaultValue={editingProduct?.name || ""} required data-testid="input-product-name" />
|
|
</div>
|
|
<div className="space-y-2 col-span-2">
|
|
<Label htmlFor="description">Descrição</Label>
|
|
<Textarea id="description" name="description" defaultValue={editingProduct?.description || ""} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="category">Categoria</Label>
|
|
<Input id="category" name="category" defaultValue={editingProduct?.category || ""} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="unit">Unidade</Label>
|
|
<Select name="unit" defaultValue={editingProduct?.unit || "UN"}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="UN">Unidade</SelectItem>
|
|
<SelectItem value="KG">Quilograma</SelectItem>
|
|
<SelectItem value="M">Metro</SelectItem>
|
|
<SelectItem value="L">Litro</SelectItem>
|
|
<SelectItem value="CX">Caixa</SelectItem>
|
|
<SelectItem value="PC">Peça</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="costPrice">Preço de Custo</Label>
|
|
<Input id="costPrice" name="costPrice" type="number" step="0.01" defaultValue={editingProduct?.costPrice || "0"} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="salePrice">Preço de Venda</Label>
|
|
<Input id="salePrice" name="salePrice" type="number" step="0.01" defaultValue={editingProduct?.salePrice || "0"} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="stockQty">Estoque Atual</Label>
|
|
<Input id="stockQty" name="stockQty" type="number" step="0.001" defaultValue={editingProduct?.stockQty || "0"} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="minStock">Estoque Mínimo</Label>
|
|
<Input id="minStock" name="minStock" type="number" step="0.001" defaultValue={editingProduct?.minStock || "0"} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ncm">NCM</Label>
|
|
<Input id="ncm" name="ncm" placeholder="00000000" maxLength={8} defaultValue={editingProduct?.ncm || ""} data-testid="input-product-ncm" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="barcode">Código de Barras</Label>
|
|
<Input id="barcode" name="barcode" defaultValue={editingProduct?.barcode || ""} />
|
|
</div>
|
|
<div className="space-y-2 col-span-2">
|
|
<Label htmlFor="taxGroupId">Grupo Tributário</Label>
|
|
<Select name="taxGroupId" defaultValue={editingProduct?.taxGroupId ? String(editingProduct.taxGroupId) : undefined}>
|
|
<SelectTrigger data-testid="select-product-tax-group">
|
|
<SelectValue placeholder="Selecione um grupo tributário" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{taxGroups.map((group) => (
|
|
<SelectItem key={group.id} value={String(group.id)}>
|
|
{group.nome} - NCM: {group.ncm || "N/A"} (ICMS: {group.aliqIcms || "0"}%)
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Configure grupos tributários no módulo Fisco para vincular as alíquotas de impostos
|
|
</p>
|
|
</div>
|
|
<div className="col-span-2 border-t pt-4 mt-2">
|
|
<h4 className="font-medium mb-3">Rastreamento Serial/IMEI</h4>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="trackingType">Tipo de Rastreamento</Label>
|
|
<Select name="trackingType" defaultValue={editingProduct?.trackingType || "none"}>
|
|
<SelectTrigger data-testid="select-tracking-type">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">Sem Rastreamento</SelectItem>
|
|
<SelectItem value="imei">IMEI (Celulares)</SelectItem>
|
|
<SelectItem value="serial">Número de Série</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="defaultBrand">Marca Padrão</Label>
|
|
<Input id="defaultBrand" name="defaultBrand" placeholder="Ex: Apple, Samsung" defaultValue={editingProduct?.defaultBrand || ""} />
|
|
</div>
|
|
<div className="space-y-2 col-span-2">
|
|
<Label htmlFor="defaultModel">Modelo Padrão</Label>
|
|
<Input id="defaultModel" name="defaultModel" placeholder="Ex: iPhone 15 Pro" defaultValue={editingProduct?.defaultModel || ""} />
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
Produtos com rastreamento IMEI/Serial terão estoque calculado automaticamente pela quantidade de dispositivos cadastrados.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="mt-6">
|
|
<Button type="button" variant="outline" onClick={() => setShowProductDialog(false)}>Cancelar</Button>
|
|
<Button type="submit" data-testid="btn-save-product">Salvar</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={showSalesDialog} onOpenChange={setShowSalesDialog}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Novo Pedido de Venda</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSaveSalesOrder}>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="customerId">Cliente</Label>
|
|
<Select name="customerId" required>
|
|
<SelectTrigger><SelectValue placeholder="Selecione um cliente" /></SelectTrigger>
|
|
<SelectContent>
|
|
{customers.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="deliveryDate">Data de Entrega</Label>
|
|
<Input id="deliveryDate" name="deliveryDate" type="date" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="paymentMethod">Forma de Pagamento</Label>
|
|
<Select name="paymentMethod">
|
|
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="dinheiro">Dinheiro</SelectItem>
|
|
<SelectItem value="cartao_credito">Cartão de Crédito</SelectItem>
|
|
<SelectItem value="cartao_debito">Cartão de Débito</SelectItem>
|
|
<SelectItem value="pix">PIX</SelectItem>
|
|
<SelectItem value="boleto">Boleto</SelectItem>
|
|
<SelectItem value="prazo">A Prazo</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="total">Valor Total</Label>
|
|
<Input id="total" name="total" type="number" step="0.01" defaultValue="0" />
|
|
</div>
|
|
<div className="space-y-2 col-span-2">
|
|
<Label htmlFor="notes">Observações</Label>
|
|
<Textarea id="notes" name="notes" />
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="mt-6">
|
|
<Button type="button" variant="outline" onClick={() => setShowSalesDialog(false)}>Cancelar</Button>
|
|
<Button type="submit" data-testid="btn-save-sales">Salvar como Rascunho</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={showBatchImeiDialog} onOpenChange={(open) => {
|
|
setShowBatchImeiDialog(open);
|
|
if (!open) {
|
|
setSelectedProductForDevices(null);
|
|
setBatchImeiText("");
|
|
}
|
|
}}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Entrada de IMEIs em Lote</DialogTitle>
|
|
<DialogDescription>
|
|
{selectedProductForDevices && (
|
|
<span className="text-primary font-medium">{selectedProductForDevices.name}</span>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="bg-muted/50 p-3 rounded-lg text-sm">
|
|
<p className="font-medium mb-1">Instruções:</p>
|
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
|
<li>Informe um IMEI por linha</li>
|
|
<li>IMEI deve ter entre 14 e 20 dígitos</li>
|
|
<li>IMEIs duplicados serão ignorados automaticamente</li>
|
|
<li>Os dispositivos herdarão marca/modelo do produto</li>
|
|
</ul>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="batchImei">IMEIs (um por linha)</Label>
|
|
<Textarea
|
|
id="batchImei"
|
|
placeholder="351234567890123 352987654321001 869123456789012"
|
|
value={batchImeiText}
|
|
onChange={(e) => setBatchImeiText(e.target.value)}
|
|
rows={8}
|
|
className="font-mono text-sm"
|
|
data-testid="textarea-batch-imei"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{batchImeiText.split('\n').filter(l => l.trim().length >= 14).length} IMEI(s) válido(s) detectado(s)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="mt-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setShowBatchImeiDialog(false)}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!selectedProductForDevices) return;
|
|
const imeis = batchImeiText
|
|
.split('\n')
|
|
.map(l => l.trim())
|
|
.filter(l => l.length >= 14)
|
|
.map(imei => ({ imei }));
|
|
if (imeis.length === 0) {
|
|
toast({ title: "Nenhum IMEI válido informado", variant: "destructive" });
|
|
return;
|
|
}
|
|
batchAddDevicesMutation.mutate({ productId: selectedProductForDevices.id, devices: imeis });
|
|
}}
|
|
disabled={batchAddDevicesMutation.isPending}
|
|
data-testid="btn-submit-batch-imei"
|
|
>
|
|
{batchAddDevicesMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Adicionando...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Smartphone className="w-4 h-4 mr-2" />
|
|
Adicionar Dispositivos
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</BrowserFrame>
|
|
);
|
|
}
|
|
|
|
function SyncDashboard() {
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
const { data: syncStatus, isLoading: loadingStatus } = useQuery({
|
|
queryKey: ["sync-status"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/soe/sync/status", { credentials: "include" });
|
|
if (!res.ok) throw new Error("Failed");
|
|
return res.json();
|
|
},
|
|
refetchInterval: 30000
|
|
});
|
|
|
|
const { data: tenantModules } = useQuery({
|
|
queryKey: ["tenant-modules"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/soe/tenant/modules", { credentials: "include" });
|
|
if (!res.ok) throw new Error("Failed");
|
|
return res.json();
|
|
}
|
|
});
|
|
|
|
const syncMutation = useMutation({
|
|
mutationFn: async (type: string) => {
|
|
const endpoint = type === "persons" ? "/api/retail/plus/sync/customers" :
|
|
type === "sales" ? "/api/retail/plus/sync/sales" :
|
|
`/api/retail/plus/sync/${type}`;
|
|
const body: Record<string, any> = {};
|
|
if (type === "persons") body.tenantId = tenantModules?.tenantId || 1;
|
|
if (type === "sales") body.saleId = 0;
|
|
const res = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Sync failed" }));
|
|
throw new Error(err.error || err.action || "Sync failed");
|
|
}
|
|
return res.json();
|
|
},
|
|
onSuccess: (data, type) => {
|
|
toast({ title: "Sincronização concluída", description: `${type}: ${data.created || 0} criados, ${data.updated || 0} atualizados` });
|
|
queryClient.invalidateQueries({ queryKey: ["sync-status"] });
|
|
},
|
|
onError: (error) => {
|
|
toast({ title: "Erro na sincronização", description: error.message, variant: "destructive" });
|
|
}
|
|
});
|
|
|
|
const features = tenantModules?.features || {};
|
|
|
|
const motors = [
|
|
{
|
|
id: "retail",
|
|
name: "Retail → SOE",
|
|
description: "Sincroniza vendas do PDV, clientes e produtos com o SOE",
|
|
icon: <Building2 className="w-6 h-6" />,
|
|
enabled: features.retail,
|
|
color: "from-orange-500 to-red-500",
|
|
syncActions: [
|
|
{ label: "Sincronizar Pessoas", type: "persons", icon: <Users className="w-4 h-4" /> },
|
|
{ label: "Sincronizar Vendas", type: "sales", icon: <TrendingUp className="w-4 h-4" /> },
|
|
]
|
|
},
|
|
{
|
|
id: "plus",
|
|
name: "Plus (Motor ERP)",
|
|
description: "Motor de execução ERP Laravel - NF-e, Estoque, Financeiro",
|
|
icon: <Database className="w-6 h-6" />,
|
|
enabled: features.plus,
|
|
color: "from-violet-600 to-purple-600",
|
|
syncActions: [
|
|
{ label: "Sincronizar Clientes", type: "persons", icon: <Users className="w-4 h-4" /> },
|
|
{ label: "Sincronizar Vendas", type: "sales", icon: <TrendingUp className="w-4 h-4" /> },
|
|
]
|
|
},
|
|
{
|
|
id: "fisco",
|
|
name: "Motor Fiscal",
|
|
description: "Compliance tributário - NF-e, NFC-e, NCM, CFOP",
|
|
icon: <Receipt className="w-6 h-6" />,
|
|
enabled: features.fisco,
|
|
color: "from-emerald-600 to-emerald-800",
|
|
syncActions: []
|
|
},
|
|
{
|
|
id: "bi",
|
|
name: "Motor BI (Insights)",
|
|
description: "Business Intelligence - Dashboards e análises de dados",
|
|
icon: <BarChart3 className="w-6 h-6" />,
|
|
enabled: features.bi,
|
|
color: "from-blue-500 to-indigo-600",
|
|
syncActions: []
|
|
}
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold" data-testid="text-sync-title">Sincronização de Motores</h2>
|
|
<p className="text-muted-foreground">
|
|
Gerencie a sincronização entre o SOE e os motores de execução.
|
|
Ative módulos em Admin → Módulos.
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline" className="text-sm">
|
|
{motors.filter(m => m.enabled).length}/{motors.length} motores ativos
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{motors.map((motor) => (
|
|
<Card key={motor.id} className={`relative ${!motor.enabled ? 'opacity-60' : ''}`} data-testid={`sync-card-${motor.id}`}>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-3 rounded-xl bg-gradient-to-br ${motor.color} text-white`}>
|
|
{motor.icon}
|
|
</div>
|
|
<div className="flex-1">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
{motor.name}
|
|
{motor.enabled ? (
|
|
<Badge className="bg-emerald-100 text-emerald-700 text-xs" data-testid={`badge-enabled-${motor.id}`}>Ativo</Badge>
|
|
) : (
|
|
<Badge variant="secondary" className="text-xs" data-testid={`badge-disabled-${motor.id}`}>Desativado</Badge>
|
|
)}
|
|
</CardTitle>
|
|
<CardDescription className="text-sm">{motor.description}</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{motor.enabled ? (
|
|
<div className="space-y-2">
|
|
{motor.syncActions.length > 0 ? (
|
|
motor.syncActions.map((action) => (
|
|
<Button
|
|
key={action.type}
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-start gap-2"
|
|
disabled={syncMutation.isPending}
|
|
onClick={() => syncMutation.mutate(action.type)}
|
|
data-testid={`btn-sync-${motor.id}-${action.type}`}
|
|
>
|
|
{syncMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
action.icon
|
|
)}
|
|
{action.label}
|
|
</Button>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">Motor ativo - sincronização automática</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<CloudOff className="w-4 h-4" />
|
|
Ative este módulo em Admin → Módulos para sincronizar
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<Card data-testid="card-sync-info">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
|
Como funciona a Sincronização
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
|
<div className="flex items-start gap-3">
|
|
<CheckCircle className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
|
|
<p><strong>SOE é o kernel:</strong> Todos os dados de negócio passam pelo SOE antes de chegar aos motores de execução</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<CheckCircle className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
|
|
<p><strong>Apps consomem o SOE:</strong> Retail, Engenharia e outros apps lêem e escrevem no SOE, que repassa aos motores</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<CheckCircle className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
|
|
<p><strong>Motores executam:</strong> Plus (ERP Laravel), ERPNext, Fisco e BI são motores invisíveis que o SOE orquestra</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<CheckCircle className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
|
|
<p><strong>Módulos controlam:</strong> Cada tenant pode ativar apenas os motores que precisa em Admin → Módulos</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|