Initialize fresh project without Clerk
This commit is contained in:
408
src/client/pages/AdminDashboard.tsx
Normal file
408
src/client/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, Upload, Info, Image as ImageIcon, Box, Database } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import type { AppUser, UserRole } from '../types';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import api from '../services/api';
|
||||
import { GeometrySettings } from '../components/admin/GeometrySettings';
|
||||
import { BackupRestore } from '../components/admin/BackupRestore';
|
||||
|
||||
const roleLabels: Record<UserRole, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
admin: { label: 'Administrador', color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', icon: <Crown size={14} /> },
|
||||
user: { label: 'Usuário', color: 'bg-primary/20 text-primary border-primary/30', icon: <UserIcon size={14} /> },
|
||||
guest: { label: 'Convidado', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: <Eye size={14} /> },
|
||||
};
|
||||
|
||||
export const AdminDashboard: React.FC = () => {
|
||||
const { appUser, isAdmin } = useAuth();
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterRole, setFilterRole] = useState<UserRole | 'all'>('all');
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'organization' | 'settings' | 'stock' | 'backup'>('users');
|
||||
const [logoLoading, setLogoLoading] = useState(false);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
if (!appUser) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/users');
|
||||
setUsers(response.data.map((u: AppUser) => ({ ...u, id: u._id || u.id })));
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [appUser]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: UserRole) => {
|
||||
if (!appUser) return;
|
||||
|
||||
setActionLoading(userId);
|
||||
try {
|
||||
const response = await api.patch(`/users/${userId}/role`, { role: newRole });
|
||||
const updated = response.data;
|
||||
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
console.error('Error updating role:', error);
|
||||
alert(err.response?.data?.error || 'Erro ao atualizar role');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBan = async (userId: string, isBanned: boolean) => {
|
||||
if (!appUser) return;
|
||||
|
||||
setActionLoading(userId);
|
||||
try {
|
||||
const response = await api.patch(`/users/${userId}/ban`, { isBanned });
|
||||
const updated = response.data;
|
||||
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
console.error('Error toggling ban:', error);
|
||||
alert(err.response?.data?.error || 'Erro ao alterar status');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u => {
|
||||
const matchesSearch = u.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesRole = filterRole === 'all' || u.role === filterRole;
|
||||
return matchesSearch && matchesRole;
|
||||
});
|
||||
|
||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validations
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Por favor, selecione uma imagem PNG, JPG ou SVG.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 500 * 1024) {
|
||||
alert('O arquivo deve ter no máximo 500KB.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLogoLoading(true);
|
||||
try {
|
||||
// Note: In the future, this should upload to our own backend
|
||||
// For now, we'll keep the UI but mark it as pending backend integration
|
||||
alert('Funcionalidade de upload de logo em migração para sistema nativo.');
|
||||
} catch (error) {
|
||||
console.error('Error uploading logo:', error);
|
||||
alert('Erro ao atualizar o logo.');
|
||||
} finally {
|
||||
setLogoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin()) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||
<Shield size={64} className="text-error/50" />
|
||||
<h1 className="text-2xl font-bold text-text-main">Acesso Negado</h1>
|
||||
<p className="text-text-muted">Você não tem permissão para acessar esta página.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-text-main tracking-tight flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
|
||||
<Shield size={24} className="text-white" />
|
||||
</div>
|
||||
Administração
|
||||
</h1>
|
||||
<p className="text-text-muted mt-2">Configurações globais e gerenciamento de usuários</p>
|
||||
</div>
|
||||
{activeTab === 'users' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={fetchUsers}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-surface hover:bg-surface-hover border border-border/40 rounded-xl text-text-main font-semibold transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||
Atualizar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="flex p-1 bg-surface-soft rounded-xl border border-border/40 w-fit">
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
|
||||
activeTab === 'users'
|
||||
? "bg-surface text-primary shadow-sm border border-border/40"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<Users size={16} />
|
||||
Usuários
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('organization')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
|
||||
activeTab === 'organization'
|
||||
? "bg-surface text-primary shadow-sm border border-border/40"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Organização
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
|
||||
activeTab === 'settings'
|
||||
? "bg-surface text-primary shadow-sm border border-border/40"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<Box size={16} />
|
||||
Geometrias
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('backup')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
|
||||
activeTab === 'backup'
|
||||
? "bg-surface text-primary shadow-sm border border-border/40"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<Database size={16} />
|
||||
Backup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'users' ? (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center">
|
||||
<Users size={20} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-text-main">{users.length}</p>
|
||||
<p className="text-xs text-text-muted font-medium">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center">
|
||||
<Crown size={20} className="text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-text-main">{users.filter(u => u.role === 'admin').length}</p>
|
||||
<p className="text-xs text-text-muted font-medium">Admins</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-500/20 flex items-center justify-center">
|
||||
<UserCheck size={20} className="text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-text-main">{users.filter(u => u.role === 'user').length}</p>
|
||||
<p className="text-xs text-text-muted font-medium">Usuários</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
|
||||
<UserX size={20} className="text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-text-main">{users.filter(u => u.isBanned).length}</p>
|
||||
<p className="text-xs text-text-muted font-medium">Banidos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nome ou email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterRole}
|
||||
onChange={(e) => setFilterRole(e.target.value as UserRole | 'all')}
|
||||
aria-label="Filtrar por role"
|
||||
className="px-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
>
|
||||
<option value="all">Todos os Roles</option>
|
||||
<option value="admin">Administradores</option>
|
||||
<option value="user">Usuários</option>
|
||||
<option value="guest">Convidados</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-surface rounded-2xl border border-border/40 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<RefreshCw size={32} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
|
||||
<Users size={48} className="mb-4 opacity-50" />
|
||||
<p>Nenhum usuário encontrado</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-surface-soft">
|
||||
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Usuário</th>
|
||||
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Status</th>
|
||||
<th className="text-right px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{filteredUsers.map((u) => {
|
||||
const roleInfo = roleLabels[u.role];
|
||||
const isCurrentUser = u.email === appUser?.email;
|
||||
const isActionDisabled = actionLoading === u.id;
|
||||
|
||||
return (
|
||||
<tr key={u.id} className={`hover:bg-surface-hover transition-colors ${u.isBanned ? 'opacity-60' : ''}`}>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">
|
||||
{u.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-text-main">{u.name}</p>
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs text-primary font-medium">(Você)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{u.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={(e) => handleRoleChange(u.id, e.target.value as UserRole)}
|
||||
disabled={isCurrentUser || isActionDisabled || u.isBanned}
|
||||
aria-label={`Alterar role de ${u.name}`}
|
||||
className={`px-3 py-1.5 rounded-lg border text-sm font-semibold transition-all ${roleInfo.color} disabled:opacity-50 disabled:cursor-not-allowed bg-transparent`}
|
||||
>
|
||||
<option value="guest">Convidado</option>
|
||||
<option value="user">Usuário</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{u.isBanned ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 text-sm font-semibold">
|
||||
<UserX size={14} />
|
||||
Banido
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 text-sm font-semibold">
|
||||
<UserCheck size={14} />
|
||||
Ativo
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{!isCurrentUser && u.role !== 'admin' && (
|
||||
<button
|
||||
onClick={() => handleToggleBan(u.id, !u.isBanned)}
|
||||
disabled={isActionDisabled}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-all ${u.isBanned
|
||||
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isActionDisabled ? (
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
) : u.isBanned ? (
|
||||
'Desbanir'
|
||||
) : (
|
||||
'Banir'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : activeTab === 'organization' ? (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300">
|
||||
<div className="bg-surface rounded-2xl p-8 border border-border/40 text-center space-y-4">
|
||||
<ImageIcon size={48} className="mx-auto text-text-muted opacity-20" />
|
||||
<h2 className="text-xl font-bold text-text-main">Gestão de Identidade Visual</h2>
|
||||
<p className="text-text-muted max-w-md mx-auto">
|
||||
O gerenciamento nativo de logos está sendo implementado. No momento, o logo atual é gerenciado via configurações do sistema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'settings' ? (
|
||||
<GeometrySettings />
|
||||
) : activeTab === 'backup' ? (
|
||||
<BackupRestore />
|
||||
) : (
|
||||
<div className="bg-surface rounded-2xl border border-border/40 p-6">
|
||||
<div className="text-center py-10">
|
||||
<h2 className="text-xl font-bold text-text-main">Gestão de Estoque</h2>
|
||||
<p className="text-text-muted mt-2">Acesse a nova página dedicada ao controle de estoque.</p>
|
||||
<a
|
||||
href="/stock"
|
||||
className="mt-6 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Ir para Estoque
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
157
src/client/pages/AnalysisDashboard.tsx
Normal file
157
src/client/pages/AnalysisDashboard.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getProjectAnalysis, type AnalysisResult } from '../services/analysisService';
|
||||
import { AlertCircle, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const AnalysisDashboard: React.FC<Props> = ({ projectId }) => {
|
||||
const [analysis, setAnalysis] = useState<AnalysisResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAnalysis = async () => {
|
||||
try {
|
||||
const response = await getProjectAnalysis(projectId);
|
||||
setAnalysis(response.data);
|
||||
setLoading(false);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erro ao carregar análise';
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAnalysis();
|
||||
}, [projectId]);
|
||||
|
||||
if (loading) return <div className="text-center p-8">Carregando análise...</div>;
|
||||
if (error) return <div className="text-error p-4">{error}</div>;
|
||||
|
||||
if (analysis.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-text-secondary">
|
||||
Nenhum dado suficiente para análise. Cadastre registros de aplicação e esquemas de pintura.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderStatusIcon = (status: 'approved' | 'warning' | 'critical') => {
|
||||
if (status === 'approved') return <CheckCircle className="text-success w-5 h-5" />;
|
||||
if (status === 'warning') return <AlertCircle className="text-warning w-5 h-5" />;
|
||||
return <XCircle className="text-error w-5 h-5" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-main mb-2">Relatório de Conformidade Técnica</h2>
|
||||
<p className="text-text-secondary">Comparativo entre valores teóricos (Esquema) e reais (Aplicação/Inspeção)</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-surface-hover text-text-secondary text-sm font-medium">
|
||||
<tr>
|
||||
<th className="p-4">Peça / Lote</th>
|
||||
<th className="p-4">Esquema</th>
|
||||
<th className="p-4 text-center">Rendimento (m²/L)</th>
|
||||
<th className="p-4 text-center">Diluição (%)</th>
|
||||
<th className="p-4 text-center">Espessura (μm)</th>
|
||||
<th className="p-4 text-center">Status Global</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{analysis.map((item, index) => (
|
||||
<tr key={index} className="hover:bg-surface-soft transition-colors">
|
||||
<td className="p-4 font-medium text-text-main">{item.pieceDescription}</td>
|
||||
<td className="p-4 text-text-secondary text-sm">
|
||||
{item.schemeName}
|
||||
<div className="text-xs text-text-muted">{item.schemeType}</div>
|
||||
</td>
|
||||
|
||||
{/* Yield */}
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{item.realYield}</span>
|
||||
{renderStatusIcon(item.yieldStatus)}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">Meta: {item.theoreticalYield}</span>
|
||||
<span className={`text-xs ${item.yieldStatus === 'critical' ? 'text-error' : item.yieldStatus === 'warning' ? 'text-warning' : 'text-success'}`}>
|
||||
{item.yieldVariance > 0 ? '+' : ''}{item.yieldVariance}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Dilution */}
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{item.realDilution}%</span>
|
||||
{renderStatusIcon(item.dilutionStatus)}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">Max: {item.targetDilution}%</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* DFT */}
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{item.realDFT}</span>
|
||||
{renderStatusIcon(item.dftStatus)}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{item.minDFT} - {item.maxDFT}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Overall Status */}
|
||||
<td className="p-4 text-center">
|
||||
{(item.yieldStatus === 'critical' || item.dftStatus === 'critical') ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error">
|
||||
REPROVADO
|
||||
</span>
|
||||
) : (item.yieldStatus === 'warning' || item.dftStatus === 'warning') ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning">
|
||||
ATENÇÃO
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success">
|
||||
APROVADO
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-text-secondary">
|
||||
<div className="bg-surface p-4 rounded-lg border border-border">
|
||||
<div className="flex items-center gap-2 mb-2 font-medium text-text-main">
|
||||
<CheckCircle className="text-success w-4 h-4" /> Aprovado
|
||||
</div>
|
||||
Variação tolerável (até 20%) em relação aos parâmetros teóricos e normas.
|
||||
</div>
|
||||
<div className="bg-surface p-4 rounded-lg border border-border">
|
||||
<div className="flex items-center gap-2 mb-2 font-medium text-text-main">
|
||||
<AlertCircle className="text-warning w-4 h-4" /> Atenção
|
||||
</div>
|
||||
Variação moderada (20-30%). Requer acompanhamento técnico para ajustes.
|
||||
</div>
|
||||
<div className="bg-surface p-4 rounded-lg border border-border">
|
||||
<div className="flex items-center gap-2 mb-2 font-medium text-text-main">
|
||||
<XCircle className="text-error w-4 h-4" /> Crítico
|
||||
</div>
|
||||
Desvio significativo (>30%) ou fora das normas. Risco de falha do revestimento.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
496
src/client/pages/CalculatorDashboard.tsx
Normal file
496
src/client/pages/CalculatorDashboard.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
import { Ruler, Droplets, CloudRain, PiggyBank, Paintbrush, ArrowRightLeft, Info, Calculator, Layers, HelpCircle } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface InputGroupProps {
|
||||
label: React.ReactNode;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const InputGroup: React.FC<InputGroupProps> = ({ label, value, onChange, placeholder, unit }) => (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em]">{label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-surface-soft border border-border/40 rounded-xl px-4 py-2.5 text-text-main font-bold focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all placeholder:text-text-muted/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{unit && <span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs font-bold text-text-muted">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CalculatorDashboard: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('conversion');
|
||||
|
||||
// 1. Conversion
|
||||
const [microns, setMicrons] = useState<string>('');
|
||||
const [mils, setMils] = useState<string>('');
|
||||
|
||||
const handleMicronChange = (val: string) => {
|
||||
setMicrons(val);
|
||||
if (val) setMils((parseFloat(val) / 25.4).toFixed(2));
|
||||
else setMils('');
|
||||
};
|
||||
const handleMilsChange = (val: string) => {
|
||||
setMils(val);
|
||||
if (val) setMicrons((parseFloat(val) * 25.4).toFixed(1));
|
||||
else setMicrons('');
|
||||
};
|
||||
|
||||
// 2. Thickness
|
||||
const [epsParams, setEpsParams] = useState({ sv: '', dilution: '', wft: '' });
|
||||
const epsResult = useMemo(() => {
|
||||
const { sv, dilution, wft } = epsParams;
|
||||
if (sv && wft) {
|
||||
const svVal = parseFloat(sv);
|
||||
const dilVal = parseFloat(dilution) || 0;
|
||||
const wftVal = parseFloat(wft);
|
||||
return (wftVal * svVal) / (100 + dilVal);
|
||||
}
|
||||
return null;
|
||||
}, [epsParams]);
|
||||
|
||||
const [epuParams, setEpuParams] = useState({ sv: '', dilution: '', dft: '' });
|
||||
const epuResult = useMemo(() => {
|
||||
const { sv, dilution, dft } = epuParams;
|
||||
if (sv && dft) {
|
||||
const svVal = parseFloat(sv);
|
||||
const dilVal = parseFloat(dilution) || 0;
|
||||
const dftVal = parseFloat(dft);
|
||||
return (dftVal * (100 + dilVal)) / svVal;
|
||||
}
|
||||
return null;
|
||||
}, [epuParams]);
|
||||
|
||||
const [deadVolParams, setDeadVolParams] = useState({ roughness: '', sv: '', area: '' });
|
||||
const deadVolResult = useMemo(() => {
|
||||
const { roughness, sv, area } = deadVolParams;
|
||||
if (roughness && sv && area) {
|
||||
const rVal = parseFloat(roughness);
|
||||
const svVal = parseFloat(sv);
|
||||
const aVal = parseFloat(area);
|
||||
const volDry = (aVal * rVal * 0.5) / 1000;
|
||||
const volWet = volDry / (svVal / 100);
|
||||
return { dry: volDry, wet: volWet };
|
||||
}
|
||||
return null;
|
||||
}, [deadVolParams]);
|
||||
|
||||
// 3. Dew Point
|
||||
const [envParams, setEnvParams] = useState({ temp: '', rh: '' });
|
||||
const { dewPoint, dpStatus } = useMemo(() => {
|
||||
const { temp, rh } = envParams;
|
||||
if (temp && rh) {
|
||||
const T = parseFloat(temp);
|
||||
const RH = parseFloat(rh);
|
||||
const a = 17.27;
|
||||
const b = 237.7;
|
||||
const alpha = ((a * T) / (b + T)) + Math.log(RH / 100);
|
||||
const Td = (b * alpha) / (a - alpha);
|
||||
|
||||
const delta = T - Td;
|
||||
let status = '';
|
||||
if (delta < 3) status = 'Risco: Condensação iminente (Delta < 3°C)';
|
||||
else status = 'Condição Segura (Delta > 3°C)';
|
||||
|
||||
return { dewPoint: Td, dpStatus: status };
|
||||
}
|
||||
return { dewPoint: null, dpStatus: '' };
|
||||
}, [envParams]);
|
||||
|
||||
// 4. Consumption & Cost
|
||||
const [consAreaParams, setConsAreaParams] = useState({ area: '', eps: '', sv: '', loss: '' });
|
||||
const consAreaResult = useMemo(() => {
|
||||
const { area, eps, sv, loss } = consAreaParams;
|
||||
if (area && eps && sv) {
|
||||
const a = parseFloat(area);
|
||||
const e = parseFloat(eps);
|
||||
const s = parseFloat(sv);
|
||||
const l = parseFloat(loss) || 0;
|
||||
const theo = (a * e) / (10 * s);
|
||||
return theo / (1 - (l / 100));
|
||||
}
|
||||
return null;
|
||||
}, [consAreaParams]);
|
||||
|
||||
const [consWeightParams, setConsWeightParams] = useState({ weight: '', relation: '' });
|
||||
const consWeightResult = useMemo(() => {
|
||||
const { weight, relation } = consWeightParams;
|
||||
if (weight && relation) {
|
||||
const w = parseFloat(weight);
|
||||
const r = parseFloat(relation);
|
||||
const tons = w / 1000;
|
||||
return tons * r;
|
||||
}
|
||||
return null;
|
||||
}, [consWeightParams]);
|
||||
|
||||
const [costParams, setCostParams] = useState({ coats: '1', area: '', eps: '', sv: '', dilution: '0', loss: '', price: '' });
|
||||
const costResult = useMemo(() => {
|
||||
const { coats, area, eps, sv, dilution, loss, price } = costParams;
|
||||
if (area && eps && sv && price) {
|
||||
const c = parseFloat(coats) || 1;
|
||||
const a = parseFloat(area);
|
||||
const e = parseFloat(eps);
|
||||
const s = parseFloat(sv);
|
||||
const d = parseFloat(dilution) || 0;
|
||||
const l = parseFloat(loss) || 0;
|
||||
const p = parseFloat(price);
|
||||
|
||||
const theoPaint = (a * e) / (10 * s);
|
||||
const realPaintPerCoat = theoPaint / (1 - (l / 100));
|
||||
const totalPaint = realPaintPerCoat * c;
|
||||
|
||||
const totalThinner = totalPaint * (d / 100);
|
||||
const totalCost = totalPaint * p;
|
||||
|
||||
return { totalCost, totalPaint, totalThinner };
|
||||
}
|
||||
return null;
|
||||
}, [costParams]);
|
||||
|
||||
// 5. Nozzles
|
||||
const [nozzleCode, setNozzleCode] = useState('');
|
||||
const [showNozzleHelp, setShowNozzleHelp] = useState(false);
|
||||
const [nozzleResult, setNozzleResult] = useState<{ fan: number, flow: number, desc: string } | null>(null);
|
||||
|
||||
const calculateNozzle = () => {
|
||||
if (nozzleCode.length < 3) return;
|
||||
const widthDigit = parseInt(nozzleCode[0]);
|
||||
const holeDigits = parseInt(nozzleCode.slice(1));
|
||||
|
||||
const fan = widthDigit * 5;
|
||||
const flow = (holeDigits * holeDigits) * 0.0039;
|
||||
|
||||
let desc = '';
|
||||
if (holeDigits < 15) desc = "Acabamento fino, líquidos leves (Verniz, Stain)";
|
||||
else if (holeDigits < 19) desc = "Uso geral, média viscosidade (Látex, Esmalte)";
|
||||
else if (holeDigits < 25) desc = "Alta produtividade, viscosidade alta (Epóxi, PU)";
|
||||
else desc = "Extrema cobertura, materiais pesados (Massa, Ignífugo)";
|
||||
|
||||
setNozzleResult({ fan, flow, desc });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-700">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">Ferramentas & Cálculos</h1>
|
||||
<p className="text-sm text-text-muted font-medium tracking-wide">Utilitários técnicos para pintura industrial</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center justify-between border-b border-border/40 scrollbar-hide overflow-x-auto">
|
||||
<nav className="flex space-x-10 min-w-max px-2" aria-label="Tabs">
|
||||
{[
|
||||
{ id: 'conversion', label: 'Conversões', icon: ArrowRightLeft },
|
||||
{ id: 'thickness', label: 'Espessura', icon: Ruler },
|
||||
{ id: 'dewpoint', label: 'Ambiente', icon: CloudRain },
|
||||
{ id: 'consumption', label: 'Consumo & Custo', icon: PiggyBank },
|
||||
{ id: 'nozzles', label: 'Bicos Airless', icon: Paintbrush },
|
||||
].map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
'whitespace-nowrap py-5 px-1 border-b-2 font-bold text-xs uppercase tracking-[0.15em] flex items-center gap-3 transition-all relative',
|
||||
active
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-main hover:border-border'
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx("w-4 h-4", active ? "text-primary" : "text-text-muted")} />
|
||||
{tab.label}
|
||||
{active && <span className="absolute bottom-[-1px] left-0 right-0 h-[2px] bg-primary shadow-[0_0_12px_rgba(13,127,242,0.6)]"></span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content for Tabs */}
|
||||
<div className="min-h-[400px]">
|
||||
|
||||
{/* 1. CONVERSION */}
|
||||
{activeTab === 'conversion' && (
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<Card className="p-8 space-y-8 bg-surface border-border/40 shadow-soft">
|
||||
<div className="flex flex-col gap-2 border-b border-border/40 pb-6">
|
||||
<h3 className="text-xl font-bold text-text-main">Conversor de Unidades</h3>
|
||||
<p className="text-sm text-text-muted">Microns (μm) ↔ Milesimos de Polegada (mils)</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
|
||||
<InputGroup
|
||||
label={<>Microns (<span className="normal-case">μm</span>)</>}
|
||||
unit="μm"
|
||||
value={microns}
|
||||
onChange={handleMicronChange}
|
||||
placeholder="0"
|
||||
/>
|
||||
<div className="hidden md:flex justify-center text-text-muted"><ArrowRightLeft /></div>
|
||||
<InputGroup label="Mils" unit="mils" value={mils} onChange={handleMilsChange} placeholder="0" />
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 rounded-xl p-4 flex gap-3 text-sm text-text-secondary">
|
||||
<Info className="w-5 h-5 text-primary shrink-0" />
|
||||
<p>Fator de conversão: <span className="font-bold text-text-main">1 mil = 25.4 μm</span>.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. THICKNESS */}
|
||||
{activeTab === 'thickness' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
{/* EPS Calc */}
|
||||
<Card className="p-6 space-y-6 border-border/40 hover:shadow-md transition-all">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
|
||||
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-500"><Ruler size={18} /></div>
|
||||
<h3 className="font-bold text-text-main">Cálculo de EPS (Seca)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<InputGroup label="Espessura Úmida (EPU)" unit="μm" value={epsParams.wft} onChange={(v) => setEpsParams({ ...epsParams, wft: v })} />
|
||||
<InputGroup label="Sólidos por Volume" unit="%" value={epsParams.sv} onChange={(v) => setEpsParams({ ...epsParams, sv: v })} />
|
||||
<InputGroup label="Diluição Adicionada" unit="%" value={epsParams.dilution} onChange={(v) => setEpsParams({ ...epsParams, dilution: v })} />
|
||||
</div>
|
||||
<div className="pt-4 mt-4 bg-surface-soft rounded-xl p-4 text-center">
|
||||
<span className="text-[10px] uppercase font-bold text-text-muted">Resultado Esperado</span>
|
||||
<div className="text-3xl font-black text-primary mt-1">{epsResult ? epsResult.toFixed(1) : '--'} <span className="text-sm text-text-muted">μm</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* EPU Calc */}
|
||||
<Card className="p-6 space-y-6 border-border/40 hover:shadow-md transition-all">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
|
||||
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-500"><Droplets size={18} /></div>
|
||||
<h3 className="font-bold text-text-main">Cálculo de EPU (Úmida)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<InputGroup label="EPS Desejada" unit="μm" value={epuParams.dft} onChange={(v) => setEpuParams({ ...epuParams, dft: v })} />
|
||||
<InputGroup label="Sólidos por Volume" unit="%" value={epuParams.sv} onChange={(v) => setEpuParams({ ...epuParams, sv: v })} />
|
||||
<InputGroup label="Diluição Prevista" unit="%" value={epuParams.dilution} onChange={(v) => setEpuParams({ ...epuParams, dilution: v })} />
|
||||
</div>
|
||||
<div className="pt-4 mt-4 bg-surface-soft rounded-xl p-4 text-center">
|
||||
<span className="text-[10px] uppercase font-bold text-text-muted">Aplicar Camada de</span>
|
||||
<div className="text-3xl font-black text-indigo-500 mt-1">{epuResult ? epuResult.toFixed(0) : '--'} <span className="text-sm text-text-muted">μm</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Dead Volume */}
|
||||
<Card className="p-6 space-y-6 border-border/40 hover:shadow-md transition-all">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
|
||||
<div className="p-2 bg-amber-500/10 rounded-lg text-amber-500"><Layers size={18} /></div>
|
||||
<h3 className="font-bold text-text-main">Volume Morto</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<InputGroup label="Área Total" unit="m²" value={deadVolParams.area} onChange={(v) => setDeadVolParams({ ...deadVolParams, area: v })} />
|
||||
<InputGroup label="Rugosidade Média" unit="μm" value={deadVolParams.roughness} onChange={(v) => setDeadVolParams({ ...deadVolParams, roughness: v })} />
|
||||
<InputGroup label="Sólidos por Volume" unit="%" value={deadVolParams.sv} onChange={(v) => setDeadVolParams({ ...deadVolParams, sv: v })} />
|
||||
</div>
|
||||
<div className="pt-4 mt-4 bg-surface-soft rounded-xl p-4 text-center space-y-2">
|
||||
<div>
|
||||
<span className="text-[9px] uppercase font-bold text-text-muted">Volume Tinta (L)</span>
|
||||
<div className="text-2xl font-black text-text-main mt-0.5">{deadVolResult ? deadVolResult.wet.toFixed(2) : '--'} <span className="text-xs text-text-muted">L</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. DEW POINT */}
|
||||
{activeTab === 'dewpoint' && (
|
||||
<div className="max-w-xl mx-auto space-y-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<Card className="p-8 space-y-8 bg-surface border-border/40 shadow-soft">
|
||||
<div className="flex flex-col gap-2 border-b border-border/40 pb-6">
|
||||
<h3 className="text-xl font-bold text-text-main">Ponto de Orvalho</h3>
|
||||
<p className="text-sm text-text-muted">Cálculo da temperatura de condensação</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<InputGroup label="Temp. Ambiente" unit="°C" value={envParams.temp} onChange={(v) => setEnvParams({ ...envParams, temp: v })} />
|
||||
<InputGroup label="Umidade Relativa" unit="%" value={envParams.rh} onChange={(v) => setEnvParams({ ...envParams, rh: v })} />
|
||||
</div>
|
||||
|
||||
<div className={clsx(
|
||||
"rounded-xl p-6 text-center transition-all border",
|
||||
dewPoint === null ? "bg-surface-soft border-transparent" :
|
||||
(dpStatus.includes('Risco') ? "bg-error/10 border-error/30" : "bg-success/10 border-success/30")
|
||||
)}>
|
||||
<span className="text-[10px] uppercase font-bold text-text-muted block mb-2">Ponto de Orvalho Calculado</span>
|
||||
<div className={clsx("text-4xl font-black mb-2", dpStatus.includes('Risco') ? "text-error" : "text-success")}>
|
||||
{dewPoint ? dewPoint.toFixed(1) : '--'}°C
|
||||
</div>
|
||||
<span className={clsx("text-xs font-bold uppercase tracking-widest", dpStatus.includes('Risco') ? "text-error" : "text-success")}>
|
||||
{dpStatus || 'Aguardando dados...'}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. CONSUMPTION & COST */}
|
||||
{activeTab === 'consumption' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Area Consumption */}
|
||||
<Card className="p-6 space-y-6 border-border/40">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-lg text-emerald-500"><Paintbrush size={18} /></div>
|
||||
<h3 className="font-bold text-text-main">Consumo por Área</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputGroup label="Área" unit="m²" value={consAreaParams.area} onChange={(v) => setConsAreaParams({ ...consAreaParams, area: v })} />
|
||||
<InputGroup label="EPS" unit="μm" value={consAreaParams.eps} onChange={(v) => setConsAreaParams({ ...consAreaParams, eps: v })} />
|
||||
<InputGroup label="Sólidos Vol." unit="%" value={consAreaParams.sv} onChange={(v) => setConsAreaParams({ ...consAreaParams, sv: v })} />
|
||||
<InputGroup label="Perdas" unit="%" value={consAreaParams.loss} onChange={(v) => setConsAreaParams({ ...consAreaParams, loss: v })} />
|
||||
</div>
|
||||
<div className="pt-4 mt-4 bg-surface-soft rounded-xl p-4 text-center">
|
||||
<span className="text-[10px] uppercase font-bold text-text-muted">Consumo Estimado</span>
|
||||
<div className="text-3xl font-black text-emerald-500 mt-1">{consAreaResult ? consAreaResult.toFixed(1) : '--'} <span className="text-sm text-text-muted">Litros</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weight Consumption */}
|
||||
<Card className="p-6 space-y-6 border-border/40">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
|
||||
<div className="p-2 bg-orange-500/10 rounded-lg text-orange-500"><PiggyBank size={18} /></div>
|
||||
<h3 className="font-bold text-text-main">Consumo por Peso</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<InputGroup label="Peso Total" unit="Kg" value={consWeightParams.weight} onChange={(v) => setConsWeightParams({ ...consWeightParams, weight: v })} />
|
||||
<InputGroup label="Relação" unit="L/Ton" value={consWeightParams.relation} onChange={(v) => setConsWeightParams({ ...consWeightParams, relation: v })} />
|
||||
</div>
|
||||
<div className="pt-4 mt-8 bg-surface-soft rounded-xl p-4 text-center">
|
||||
<span className="text-[10px] uppercase font-bold text-text-muted">Volume Necessário</span>
|
||||
<div className="text-3xl font-black text-orange-500 mt-1">{consWeightResult ? consWeightResult.toFixed(1) : '--'} <span className="text-sm text-text-muted">Litros</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Cost Estimator */}
|
||||
<Card className="p-6 space-y-6 border-border/40 lg:col-span-2">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary"><Calculator size={18} /></div>
|
||||
<h3 className="font-bold text-text-main">Estimativa de Custos</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<InputGroup label="Demãos" unit="un" value={costParams.coats} onChange={(v) => setCostParams({ ...costParams, coats: v })} />
|
||||
<InputGroup label="Área" unit="m²" value={costParams.area} onChange={(v) => setCostParams({ ...costParams, area: v })} />
|
||||
<InputGroup label="EPS" unit="μm" value={costParams.eps} onChange={(v) => setCostParams({ ...costParams, eps: v })} />
|
||||
<InputGroup label="Preço" unit="R$/L" value={costParams.price} onChange={(v) => setCostParams({ ...costParams, price: v })} />
|
||||
<InputGroup label="Sólidos Vol." unit="%" value={costParams.sv} onChange={(v) => setCostParams({ ...costParams, sv: v })} />
|
||||
<InputGroup label="Perdas" unit="%" value={costParams.loss} onChange={(v) => setCostParams({ ...costParams, loss: v })} />
|
||||
<InputGroup label="Diluição" unit="%" value={costParams.dilution} onChange={(v) => setCostParams({ ...costParams, dilution: v })} />
|
||||
</div>
|
||||
<div className="pt-4 mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-surface-soft rounded-xl p-4 text-center">
|
||||
<span className="text-[9px] uppercase font-bold text-text-muted">Volume Tinta</span>
|
||||
<div className="text-xl font-black text-text-main">{costResult ? costResult.totalPaint.toFixed(1) : '--'} L</div>
|
||||
</div>
|
||||
<div className="bg-surface-soft rounded-xl p-4 text-center">
|
||||
<span className="text-[9px] uppercase font-bold text-text-muted">Volume Diluente</span>
|
||||
<div className="text-xl font-black text-text-main">{costResult ? costResult.totalThinner.toFixed(1) : '--'} L</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 rounded-xl p-4 text-center border border-primary/20">
|
||||
<span className="text-[9px] uppercase font-bold text-text-muted">Custo Estimado</span>
|
||||
<div className="text-2xl font-black text-primary">R$ {costResult ? costResult.totalCost.toFixed(2) : '--'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. Nozzles */}
|
||||
{activeTab === 'nozzles' && (
|
||||
<div className="max-w-xl mx-auto space-y-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<Card className="p-8 space-y-8 bg-surface border-border/40 shadow-soft">
|
||||
<div className="flex flex-col gap-2 border-b border-border/40 pb-6">
|
||||
<h3 className="text-xl font-bold text-text-main">Seletor de Bicos Airless</h3>
|
||||
<p className="text-sm text-text-muted">Insira o código do bico (ex: 517) para ver detalhes</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<InputGroup label="Código do Bico" placeholder="ex: 517" value={nozzleCode} onChange={setNozzleCode} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNozzleHelp(!showNozzleHelp)}
|
||||
className="mb-[2px] p-3 rounded-xl bg-surface-soft text-text-muted hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Como ler o código?"
|
||||
>
|
||||
<HelpCircle size={20} />
|
||||
</button>
|
||||
<Button onClick={calculateNozzle} className="mb-[2px]">Calcular</Button>
|
||||
</div>
|
||||
|
||||
{showNozzleHelp && (
|
||||
<div className="bg-surface-soft border border-border/40 rounded-xl p-4 text-sm space-y-3 animate-in fade-in zoom-in-95 duration-200">
|
||||
<h4 className="font-bold text-text-main flex items-center gap-2">
|
||||
<Info size={16} className="text-primary" />
|
||||
Entendendo o Código (ex: 517)
|
||||
</h4>
|
||||
<ul className="space-y-2 text-text-secondary">
|
||||
<li className="flex gap-2">
|
||||
<span className="font-black text-primary shrink-0">1º Dígito</span>
|
||||
<div>
|
||||
<strong className="text-text-main">Ângulo do Leque:</strong> Multiplique por 5 para saber a largura em cm (aprox a 30cm da superfície).
|
||||
<br /><span className="text-xs text-text-muted">Ex: 5xx = 50° (aprox. 25cm).</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="font-black text-primary shrink-0">Últimos</span>
|
||||
<div>
|
||||
<strong className="text-text-main">Orifício (Vazão):</strong> Diâmetro em milésimos de polegada. Quanto maior, mais tinta sai.
|
||||
<br /><span className="text-xs text-text-muted">Ex: x17 = 0.017". Indicado para látex/esmalte.</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nozzleResult && (
|
||||
<div className="bg-surface-soft border border-border/20 rounded-2xl p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-8 text-center">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block mb-1">Abertura Leque</span>
|
||||
<span className="text-3xl font-black text-text-main">{nozzleResult.fan} <span className="text-sm text-text-muted font-bold">cm</span></span>
|
||||
<p className="text-[10px] text-text-muted mt-1">(Aprox. a 30cm)</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block mb-1">Vazão Aprox.</span>
|
||||
<span className="text-3xl font-black text-primary">{nozzleResult.flow.toFixed(2)} <span className="text-sm text-text-muted font-bold">L/min</span></span>
|
||||
<p className="text-[10px] text-text-muted mt-1">(@ 2000 psi)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border/20">
|
||||
<p className="text-center text-sm font-medium text-text-secondary">{nozzleResult.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-primary/5 rounded-xl p-4 flex gap-3 text-sm text-text-secondary">
|
||||
<Info className="w-5 h-5 text-primary shrink-0" />
|
||||
<p className="text-xs">Valores teóricos de referência. Consulte sempre a ficha técnica do fabricante do equipamento.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
694
src/client/pages/DataSheetLibrary.tsx
Normal file
694
src/client/pages/DataSheetLibrary.tsx
Normal file
@@ -0,0 +1,694 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from '../components/Button';
|
||||
import { Card } from '../components/Card';
|
||||
import { Search, Plus, Trash2, FileText, Download, X, Loader2, Edit2 } from 'lucide-react';
|
||||
import * as dataSheetService from '../services/dataSheetService';
|
||||
import type { TechnicalDataSheet } from '../types';
|
||||
import { format } from 'date-fns';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
|
||||
export const DataSheetLibrary: React.FC = () => {
|
||||
const { isGuest, isAdmin } = useAuth();
|
||||
const { showGuestWarning } = useToast();
|
||||
const [sheets, setSheets] = useState<TechnicalDataSheet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
manufacturer: '',
|
||||
type: '',
|
||||
solidsVolume: '',
|
||||
yieldTheoretical: '',
|
||||
dftReference: '',
|
||||
yieldFactor: '',
|
||||
wftMin: '',
|
||||
wftMax: '',
|
||||
dftMin: '',
|
||||
dftMax: '',
|
||||
reducer: '',
|
||||
mixingRatioWeight: '',
|
||||
mixingRatioVolume: '',
|
||||
dilution: '',
|
||||
notes: '',
|
||||
fileUrl: ''
|
||||
});
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSheets();
|
||||
}, []);
|
||||
|
||||
const fetchSheets = async () => {
|
||||
try {
|
||||
const response = await dataSheetService.getDataSheets();
|
||||
setSheets(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching sheets', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const file = e.target.files[0];
|
||||
setSelectedFile(file);
|
||||
|
||||
// Auto-extract only for new uploads
|
||||
setExtracting(true);
|
||||
try {
|
||||
const response = await dataSheetService.extractDataSheet(file);
|
||||
const data = response.data;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: prev.name || data.name || '',
|
||||
manufacturer: prev.manufacturer || data.manufacturer || '',
|
||||
type: prev.type || data.type || '',
|
||||
solidsVolume: data.solidsVolume ? String(data.solidsVolume) : prev.solidsVolume,
|
||||
yieldTheoretical: data.yieldTheoretical ? String(data.yieldTheoretical) : prev.yieldTheoretical,
|
||||
dftReference: data.dftReference ? String(data.dftReference) : prev.dftReference,
|
||||
yieldFactor: data.yieldFactor ? String(data.yieldFactor) : prev.yieldFactor,
|
||||
wftMin: data.wftMin ? String(data.wftMin) : prev.wftMin,
|
||||
wftMax: data.wftMax ? String(data.wftMax) : prev.wftMax,
|
||||
dftMin: data.dftMin ? String(data.dftMin) : prev.dftMin,
|
||||
dftMax: data.dftMax ? String(data.dftMax) : prev.dftMax,
|
||||
reducer: data.reducer || prev.reducer,
|
||||
mixingRatioWeight: data.mixingRatioWeight || prev.mixingRatioWeight,
|
||||
mixingRatioVolume: data.mixingRatioVolume || prev.mixingRatioVolume,
|
||||
fileUrl: data.tempFilePath // Store temporary path
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Extraction failed', err);
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (sheet: TechnicalDataSheet) => {
|
||||
setEditingId(sheet.id);
|
||||
setFormData({
|
||||
name: sheet.name,
|
||||
manufacturer: sheet.manufacturer || '',
|
||||
type: sheet.type || '',
|
||||
solidsVolume: sheet.solidsVolume ? String(sheet.solidsVolume) : '',
|
||||
yieldTheoretical: sheet.yieldTheoretical ? String(sheet.yieldTheoretical) : '',
|
||||
dftReference: sheet.dftReference ? String(sheet.dftReference) : '',
|
||||
yieldFactor: sheet.yieldFactor ? String(sheet.yieldFactor) : '',
|
||||
wftMin: sheet.wftMin ? String(sheet.wftMin) : '',
|
||||
wftMax: sheet.wftMax ? String(sheet.wftMax) : '',
|
||||
dftMin: sheet.dftMin ? String(sheet.dftMin) : '',
|
||||
dftMax: sheet.dftMax ? String(sheet.dftMax) : '',
|
||||
reducer: sheet.reducer || '',
|
||||
mixingRatioWeight: sheet.mixingRatioWeight || '',
|
||||
mixingRatioVolume: sheet.mixingRatioVolume || '',
|
||||
dilution: sheet.dilution ? String(sheet.dilution) : '',
|
||||
notes: sheet.notes || '',
|
||||
fileUrl: sheet.fileUrl || ''
|
||||
});
|
||||
setSelectedFile(null); // Reset file selection unless user uploads new one
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setEditingId(null);
|
||||
setFormData({
|
||||
name: '', manufacturer: '', type: '', solidsVolume: '', yieldTheoretical: '',
|
||||
dftReference: '', yieldFactor: '', wftMin: '', wftMax: '', dftMin: '', dftMax: '',
|
||||
reducer: '', mixingRatioWeight: '', mixingRatioVolume: '', notes: '', fileUrl: '',
|
||||
dilution: ''
|
||||
});
|
||||
setSelectedFile(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isGuest()) {
|
||||
showGuestWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name) return;
|
||||
|
||||
setUploading(true);
|
||||
const data = new FormData();
|
||||
|
||||
if (selectedFile) {
|
||||
data.append('file', selectedFile);
|
||||
} else if (formData.fileUrl) {
|
||||
data.append('fileUrl', formData.fileUrl);
|
||||
}
|
||||
|
||||
data.append('name', formData.name);
|
||||
data.append('manufacturer', formData.manufacturer);
|
||||
data.append('type', formData.type);
|
||||
data.append('solidsVolume', formData.solidsVolume);
|
||||
data.append('yieldTheoretical', formData.yieldTheoretical);
|
||||
data.append('dftReference', formData.dftReference);
|
||||
data.append('yieldFactor', formData.yieldFactor);
|
||||
data.append('wftMin', formData.wftMin);
|
||||
data.append('wftMax', formData.wftMax);
|
||||
data.append('dftMin', formData.dftMin);
|
||||
data.append('dftMax', formData.dftMax);
|
||||
data.append('reducer', formData.reducer);
|
||||
data.append('mixingRatioWeight', formData.mixingRatioWeight);
|
||||
data.append('mixingRatioVolume', formData.mixingRatioVolume);
|
||||
data.append('dilution', formData.dilution);
|
||||
data.append('notes', formData.notes);
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await dataSheetService.updateDataSheet(editingId, data);
|
||||
} else {
|
||||
if (!selectedFile && !formData.fileUrl) {
|
||||
alert('Selecione um arquivo.');
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
await dataSheetService.createDataSheet(data);
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
fetchSheets();
|
||||
} catch (error) {
|
||||
console.error('Error saving sheet', error);
|
||||
alert('Erro ao salvar ficha técnica.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (isGuest()) {
|
||||
showGuestWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Tem certeza que deseja excluir esta ficha?')) return;
|
||||
try {
|
||||
await dataSheetService.deleteDataSheet(id);
|
||||
fetchSheets();
|
||||
} catch (error) {
|
||||
console.error('Error deleting sheet', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTechnicalData = (field: string, value: string) => {
|
||||
const newData = { ...formData, [field]: value };
|
||||
|
||||
// Convert to numbers for calculation
|
||||
const solids = field === 'solidsVolume' ? parseFloat(value) : parseFloat(newData.solidsVolume);
|
||||
const yieldT = field === 'yieldTheoretical' ? parseFloat(value) : parseFloat(newData.yieldTheoretical);
|
||||
const dft = field === 'dftReference' ? parseFloat(value) : parseFloat(newData.dftReference);
|
||||
const factor = field === 'yieldFactor' ? parseFloat(value) : parseFloat(newData.yieldFactor);
|
||||
|
||||
if (field === 'solidsVolume' && !isNaN(solids)) {
|
||||
// Formula: Factor = Solids * 10
|
||||
newData.yieldFactor = (solids * 10).toFixed(2);
|
||||
// If we have DFT, update Yield: Yield = Factor / DFT
|
||||
if (!isNaN(dft) && dft !== 0) {
|
||||
newData.yieldTheoretical = (solids * 10 / dft).toFixed(2);
|
||||
}
|
||||
} else if (field === 'yieldTheoretical' && !isNaN(yieldT)) {
|
||||
// If we have DFT, update Factor: Factor = Yield * DFT
|
||||
if (!isNaN(dft)) {
|
||||
newData.yieldFactor = (yieldT * dft).toFixed(2);
|
||||
newData.solidsVolume = (yieldT * dft / 10).toFixed(1);
|
||||
}
|
||||
} else if (field === 'dftReference' && !isNaN(dft)) {
|
||||
// If we have Factor, update Yield: Yield = Factor / DFT
|
||||
if (!isNaN(factor) && dft !== 0) {
|
||||
newData.yieldTheoretical = (factor / dft).toFixed(2);
|
||||
} else if (!isNaN(solids) && dft !== 0) {
|
||||
newData.yieldTheoretical = (solids * 10 / dft).toFixed(2);
|
||||
}
|
||||
} else if (field === 'yieldFactor' && !isNaN(factor)) {
|
||||
newData.solidsVolume = (factor / 10).toFixed(1);
|
||||
if (!isNaN(dft) && dft !== 0) {
|
||||
newData.yieldTheoretical = (factor / dft).toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
setFormData(newData);
|
||||
};
|
||||
|
||||
const filteredSheets = sheets.filter(s =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.manufacturer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.type?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-lg shadow-primary/5">
|
||||
<FileText className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-5xl font-black text-text-main tracking-tight mb-0">Biblioteca Técnica</h1>
|
||||
<p className="text-sm text-text-muted font-medium tracking-widest uppercase">Repositório de Fichas de Produto</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="relative hidden lg:block group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar tintas ou fabricantes..."
|
||||
className="h-14 w-80 bg-surface border border-border/40 rounded-2xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin() && (
|
||||
<Button onClick={handleOpenModal} size="lg" className="shadow-primary/30 h-14">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nova Ficha
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile/Small Screen Search */}
|
||||
<div className="relative lg:hidden">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
className="h-12 w-full bg-surface border border-border/40 rounded-xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-32 space-y-4">
|
||||
<Loader2 size={40} className="text-primary animate-spin" />
|
||||
<p className="text-sm font-bold text-text-muted uppercase tracking-[0.2em]">Carregando Biblioteca...</p>
|
||||
</div>
|
||||
) : filteredSheets.length === 0 ? (
|
||||
<div className="text-center py-32 bg-surface/50 rounded-[40px] border border-dashed border-border/40">
|
||||
<div className="mx-auto h-24 w-24 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/10 mb-8 border border-border/20">
|
||||
<FileText className="w-12 h-12" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-text-main tracking-tight">Nenhuma ficha disponível</h3>
|
||||
<p className="mt-2 text-text-muted font-medium max-w-sm mx-auto">Sua biblioteca de produtos está vazia. Comece carregando um PDF técnico.</p>
|
||||
{isAdmin() && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
className="mt-8 border-border/50"
|
||||
onClick={handleOpenModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Cadastrar Produto
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-8">
|
||||
{filteredSheets.map((sheet) => (
|
||||
<div key={sheet.id} className="group relative">
|
||||
{/* Card Background Bloom Effect */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 rounded-[32px] blur-xl opacity-0 group-hover:opacity-100 transition-all duration-700"></div>
|
||||
|
||||
<Card className="p-0 flex flex-col h-full relative overflow-hidden bg-surface border border-border/40 rounded-[32px] group-hover:border-primary/40 transition-all duration-500 shadow-soft group-hover:shadow-2xl">
|
||||
{/* Header Section */}
|
||||
<div className="p-8 pb-4 relative">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-black text-primary uppercase tracking-[0.25em]">{sheet.manufacturer || 'FABRICANTE N/D'}</span>
|
||||
<h3 className="text-2xl font-black text-text-main tracking-tighter leading-none group-hover:text-primary transition-colors" title={sheet.name}>
|
||||
{sheet.name}
|
||||
</h3>
|
||||
</div>
|
||||
{isAdmin() && (
|
||||
<div className="flex gap-1 p-1 bg-surface-soft/80 backdrop-blur-md rounded-2xl border border-border/40 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all">
|
||||
<button
|
||||
onClick={() => handleEdit(sheet)}
|
||||
className="p-2.5 text-text-muted hover:text-primary transition-all rounded-xl hover:bg-primary/5"
|
||||
title="Editar Ficha"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(sheet.id)}
|
||||
className="p-2.5 text-text-muted hover:text-error transition-all rounded-xl hover:bg-error/5"
|
||||
title="Remover Ficha"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-[9px] font-black uppercase tracking-widest rounded-full border border-primary/20">
|
||||
{sheet.type || 'S/ TIPO'}
|
||||
</span>
|
||||
{sheet.reducer && (
|
||||
<span className="px-3 py-1 bg-surface-highlight text-text-secondary text-[9px] font-black uppercase tracking-widest rounded-full border border-border/50">
|
||||
DILUENTE: {sheet.reducer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body Section */}
|
||||
<div className="px-8 space-y-5 flex-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-surface-soft/50 rounded-2xl border border-border/20 flex flex-col">
|
||||
<span className="text-[9px] text-text-muted font-bold uppercase tracking-wider mb-1">Sólidos por Vol.</span>
|
||||
<span className="text-xl font-black text-text-main">{sheet.solidsVolume || '0'}%</span>
|
||||
</div>
|
||||
<div className="p-4 bg-surface-soft/50 rounded-2xl border border-border/20 flex flex-col">
|
||||
<span className="text-[9px] text-text-muted font-bold uppercase tracking-wider mb-1">Rend. Teórico</span>
|
||||
<span className="text-xl font-black text-text-main">{sheet.yieldTheoretical || '0'} <span className="text-[9px] text-text-muted">m²/L</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-primary/[0.03] border border-primary/10 rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-black text-primary/70 uppercase tracking-widest flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></div>
|
||||
Performance Ref.
|
||||
</span>
|
||||
<span className="text-xs font-black text-primary">{sheet.yieldFactor || '0.00'} <span className="text-[8px] font-bold">m².μm/L</span></span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase">EPS de Referência</span>
|
||||
<span className="text-sm font-bold text-text-main">{sheet.dftReference || '--'} <span className="text-[10px]">μm</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sheet.mixingRatioVolume && (
|
||||
<div className="py-1">
|
||||
<div className="text-[10px] font-bold text-text-muted uppercase tracking-widest border-b border-border/40 pb-2 mb-3">Relação de Mistura (Vol.)</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-black text-text-main">{sheet.mixingRatioVolume.split(':')[0] || '1'}</span>
|
||||
<span className="text-[10px] font-bold text-text-muted">PART A</span>
|
||||
</div>
|
||||
<div className="h-4 w-[1px] bg-border/40"></div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-black text-text-main">{sheet.mixingRatioVolume.split(':')[1] || '1'}</span>
|
||||
<span className="text-[10px] font-bold text-text-muted">PART B</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="p-8 pt-6 border-t border-border/40 mt-6 flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black text-text-muted uppercase tracking-tighter">Última Atualização</span>
|
||||
<span className="text-[11px] font-bold text-text-secondary">{format(new Date(sheet.uploadDate), 'dd MMM, yyyy')}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
if (!sheet.fileUrl) return null;
|
||||
|
||||
const fileUrl = sheet.fileUrl.startsWith('http')
|
||||
? sheet.fileUrl
|
||||
: `${dataSheetService.getBaseUrl()}/datasheets/file/${sheet.fileUrl}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 h-10 px-6 bg-surface-highlight hover:bg-primary hover:text-white border border-border/40 rounded-xl text-xs font-black transition-all group/btn"
|
||||
>
|
||||
<Download size={14} className="group-hover/btn:-translate-y-0.5 transition-transform" />
|
||||
PDF TÉCNICO
|
||||
</a>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal - Reusable for Create and Edit */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="bg-surface rounded-2xl shadow-2xl w-full max-w-lg border border-border/50 flex flex-col max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-border flex justify-between items-center bg-surface">
|
||||
<h2 className="text-xl font-bold text-text-main">
|
||||
{editingId ? 'Editar Ficha Técnica' : 'Nova Ficha Técnica'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-text-muted hover:text-text-main p-1 rounded-lg hover:bg-surface-soft transition-colors"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className={`p-4 rounded-xl border border-dashed transition-all ${selectedFile ? 'bg-primary/5 border-primary' : 'bg-surface-soft border-border hover:border-primary/50'}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-lg ${extracting ? 'bg-amber-100 text-amber-600' : 'bg-white text-primary shadow-sm'}`}>
|
||||
{extracting ? <Loader2 size={24} className="animate-spin" /> : <FileText size={24} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="pdf-upload" className="block text-sm font-semibold text-text-main mb-1 cursor-pointer">
|
||||
{extracting ? 'Analisando documento...' : (editingId ? 'Substituir PDF (Opcional)' : 'Carregar PDF')}
|
||||
</label>
|
||||
<input
|
||||
id="pdf-upload"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileSelect}
|
||||
className="w-full text-xs text-text-secondary file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-primary file:text-white hover:file:bg-primary-dark cursor-pointer"
|
||||
disabled={extracting}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1 truncate">
|
||||
{extracting
|
||||
? 'Utilizando OCR se necessário...'
|
||||
: (editingId ? 'Mantenha vazio para usar o atual.' : 'Preenchemos os campos automaticamente para você.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Nome do Produto *</label>
|
||||
<input
|
||||
id="product-name"
|
||||
required
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Ex: Rezinc Wand 500"
|
||||
title="Nome do Produto"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Fabricante</label>
|
||||
<input
|
||||
id="manufacturer"
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.manufacturer}
|
||||
onChange={e => setFormData({ ...formData, manufacturer: e.target.value })}
|
||||
placeholder="Ex: WEG"
|
||||
title="Fabricante"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Redutor (Diluente)</label>
|
||||
<input
|
||||
id="reducer"
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.reducer}
|
||||
onChange={e => setFormData({ ...formData, reducer: e.target.value })}
|
||||
placeholder="Ex: 420.0000"
|
||||
title="Redutor (Diluente)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Tipo de Tinta</label>
|
||||
<input
|
||||
id="paint-type"
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
||||
placeholder="Ex: Epóxi"
|
||||
title="Tipo de Tinta"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-bold text-primary uppercase tracking-wider bg-primary/10 px-2 py-0.5 rounded">Relação de Mistura</span>
|
||||
<div className="h-px bg-border flex-1"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Em Peso (Comp A : B)</label>
|
||||
<input
|
||||
id="mixing-ratio-weight"
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.mixingRatioWeight}
|
||||
onChange={e => setFormData({ ...formData, mixingRatioWeight: e.target.value })}
|
||||
placeholder="100 : 101"
|
||||
title="Relação de Mistura em Peso"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Em Volume (Comp A : B)</label>
|
||||
<input
|
||||
id="mixing-ratio-volume"
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.mixingRatioVolume}
|
||||
onChange={e => setFormData({ ...formData, mixingRatioVolume: e.target.value })}
|
||||
placeholder="1 : 1"
|
||||
title="Relação de Mistura em Volume"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-bold text-primary uppercase tracking-wider bg-primary/10 px-2 py-0.5 rounded">Dados Técnicos</span>
|
||||
<div className="h-px bg-border flex-1"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 grid grid-cols-2 gap-4 p-3 bg-surface-soft rounded-xl border border-border/50">
|
||||
<div className="col-span-2 text-[10px] font-bold text-text-muted uppercase mb-1">Intervalos de Aplicação</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-text-secondary mb-1">Úmida (WFT µm)</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input type="number" className="w-full p-1.5 text-xs rounded border border-border bg-surface" value={formData.wftMin} onChange={e => setFormData({ ...formData, wftMin: e.target.value })} placeholder="Min" />
|
||||
<span className="text-xs text-text-muted">/</span>
|
||||
<input type="number" className="w-full p-1.5 text-xs rounded border border-border bg-surface" value={formData.wftMax} onChange={e => setFormData({ ...formData, wftMax: e.target.value })} placeholder="Max" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-text-secondary mb-1">Seca (DFT µm)</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input type="number" className="w-full p-1.5 text-xs rounded border border-border bg-surface" value={formData.dftMin} onChange={e => setFormData({ ...formData, dftMin: e.target.value })} placeholder="Min" />
|
||||
<span className="text-xs text-text-muted">/</span>
|
||||
<input type="number" className="w-full p-1.5 text-xs rounded border border-border bg-surface" value={formData.dftMax} onChange={e => setFormData({ ...formData, dftMax: e.target.value })} placeholder="Max" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Vol. Sólidos (%)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="solids-volume"
|
||||
type="number" step="0.1"
|
||||
className="w-full p-2.5 pl-3 pr-8 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.solidsVolume}
|
||||
onChange={e => updateTechnicalData('solidsVolume', e.target.value)}
|
||||
placeholder="0"
|
||||
title="Volume de Sólidos (%)"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Espessura Ref. (DFT)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="dft-reference"
|
||||
type="number" step="1"
|
||||
className="w-full p-2.5 pl-3 pr-10 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.dftReference}
|
||||
onChange={e => updateTechnicalData('dftReference', e.target.value)}
|
||||
placeholder="0"
|
||||
title="Espessura Referência (DFT)"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">µm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Rendimento Teórico</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="yield-theoretical"
|
||||
type="number" step="0.01"
|
||||
className="w-full p-2.5 pl-3 pr-10 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.yieldTheoretical}
|
||||
onChange={e => updateTechnicalData('yieldTheoretical', e.target.value)}
|
||||
placeholder="0.00"
|
||||
title="Rendimento Teórico"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">m²/L</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Fator de Rendimento</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="yield-factor"
|
||||
type="number" step="0.1"
|
||||
className="w-full p-2.5 pl-3 pr-16 rounded-lg border-2 border-primary/30 bg-primary/5 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-bold"
|
||||
value={formData.yieldFactor}
|
||||
onChange={e => updateTechnicalData('yieldFactor', e.target.value)}
|
||||
placeholder="0.0"
|
||||
title="Fator de Rendimento"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-primary/70 text-[10px] font-bold leading-tight text-right w-12">m².µm/L</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Diluição Recomendada (%)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="dilution"
|
||||
type="number"
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
value={formData.dilution}
|
||||
onChange={e => setFormData({ ...formData, dilution: e.target.value })}
|
||||
placeholder="0"
|
||||
title="Diluição Recomendada (%)"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-text-muted italic">
|
||||
* O Fator de Rendimento representa a área coberta por 1L para 1µm de espessura (Sólidos % × 10).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 flex justify-end gap-3 border-t border-border mt-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</Button>
|
||||
<Button type="submit" disabled={uploading}>
|
||||
{uploading ? (editingId ? 'Salvando...' : 'Enviando...') : (editingId ? 'Salvar Alterações' : 'Criar Ficha')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
551
src/client/pages/DeveloperDashboard.tsx
Normal file
551
src/client/pages/DeveloperDashboard.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
Users,
|
||||
Building2,
|
||||
Image as ImageIcon,
|
||||
LayoutDashboard,
|
||||
Globe,
|
||||
UploadCloud,
|
||||
X,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSystemSettings } from '../context/SystemSettingsContext';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { systemSettingsService } from '../services/systemSettingsService';
|
||||
import type { GlobalUser, GlobalOrganization } from '../services/systemSettingsService';
|
||||
|
||||
export const DeveloperDashboard: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'organizations' | 'users' | 'branding'>('overview');
|
||||
const { settings, updateSettings, isLoading } = useSystemSettings();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [appName, setAppName] = useState('');
|
||||
const [appSubtitle, setAppSubtitle] = useState('');
|
||||
const [logoUrl, setLogoUrl] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [globalUsers, setGlobalUsers] = useState<GlobalUser[]>([]);
|
||||
const [globalOrgs, setGlobalOrgs] = useState<GlobalOrganization[]>([]);
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
|
||||
// Fetch data when tab changes
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (activeTab === 'users') {
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
const data = await systemSettingsService.getGlobalUsers();
|
||||
setGlobalUsers(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showToast('Erro ao carregar usuários.', 'error');
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
} else if (activeTab === 'organizations') {
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
const data = await systemSettingsService.getGlobalOrganizations();
|
||||
setGlobalOrgs(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showToast('Erro ao carregar organizações.', 'error');
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [activeTab, showToast]);
|
||||
|
||||
// Load initial values
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setAppName(settings.appName);
|
||||
setAppSubtitle(settings.appSubtitle);
|
||||
setLogoUrl(settings.appLogoUrl || '');
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validations
|
||||
if (file.size > 2 * 1024 * 1024) { // 2MB
|
||||
showToast('O logo deve ter no máximo 2MB.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
console.log('Iniciando upload...');
|
||||
const uploadedUrl = await systemSettingsService.uploadLogo(file);
|
||||
console.log('Upload concluído. URL recebida:', uploadedUrl);
|
||||
|
||||
if (uploadedUrl) {
|
||||
setLogoUrl(uploadedUrl);
|
||||
showToast('Logo carregado! URL: ' + uploadedUrl, 'success');
|
||||
} else {
|
||||
showToast('Erro: O servidor não retornou a URL da imagem.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showToast('Erro ao enviar o logo.', 'error');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBranding = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateSettings({
|
||||
appName,
|
||||
appSubtitle,
|
||||
appLogoUrl: logoUrl
|
||||
});
|
||||
showToast('Branding global atualizado com sucesso!', 'success');
|
||||
} catch (error) {
|
||||
showToast('Erro ao atualizar branding.', 'error');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center">Carregando configurações...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8 animate-in fade-in duration-500">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-indigo-500 font-bold mb-1">
|
||||
<Terminal size={16} />
|
||||
<span>Developer Access Only</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-black text-text-main tracking-tight">Dashboard do Desenvolvedor</h1>
|
||||
<p className="text-text-muted mt-1">Gestão Global do Sistema TS-GPI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="flex items-center gap-2 p-1 bg-surface-soft rounded-2xl border border-border/20 w-fit">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
|
||||
activeTab === 'overview'
|
||||
? "bg-surface text-indigo-500 shadow-sm border border-border/40"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
Visão Geral
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('organizations')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
|
||||
activeTab === 'organizations'
|
||||
? "bg-surface text-indigo-500 shadow-sm border border-border/40"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<Building2 size={16} />
|
||||
Organizações
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
|
||||
activeTab === 'users'
|
||||
? "bg-surface text-indigo-500 shadow-sm border border-border/40"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<Users size={16} />
|
||||
Usuários Globais
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('branding')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
|
||||
activeTab === 'branding'
|
||||
? "bg-surface text-indigo-500 shadow-sm border border-border/40"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<ImageIcon size={16} />
|
||||
Branding Global
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="bg-surface rounded-3xl border border-border/40 min-h-[500px] overflow-hidden">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="p-12 flex flex-col items-center justify-center text-center h-full">
|
||||
<div className="w-16 h-16 rounded-2xl bg-indigo-500/10 flex items-center justify-center mb-6 text-indigo-500 ring-1 ring-indigo-500/20">
|
||||
<Globe size={32} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-text-main">Sistema Operacional</h2>
|
||||
<p className="text-text-muted mt-2 max-w-sm">
|
||||
Você está no nível mais alto de privilégios. Todas as alterações feitas aqui impactam todas as instâncias do app.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'branding' && (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-3">
|
||||
<ImageIcon className="text-indigo-500" />
|
||||
Identidade Visual do APP
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSaveBranding} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Nome da Aplicação
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={appName}
|
||||
onChange={(e) => setAppName(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-surface-soft border border-border/50 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
|
||||
placeholder="Ex: GPI"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">Aparecerá no topo do menu lateral.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Subtítulo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={appSubtitle}
|
||||
onChange={(e) => setAppSubtitle(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-xl bg-surface-soft border border-border/50 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
|
||||
placeholder="Ex: Gestão de Pintura Industrial"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">Nome extenso abaixo da sigla.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Logo do App (Público)
|
||||
</label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-xl bg-surface-soft border border-border/50 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all font-mono text-sm text-text-muted"
|
||||
placeholder="https://... ou /uploads/..."
|
||||
readOnly
|
||||
/>
|
||||
{logoUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogoUrl('')}
|
||||
className="p-2 text-text-muted hover:text-error hover:bg-error/10 rounded-xl transition-colors"
|
||||
title="Limpar logo"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center justify-center gap-2 w-full py-4 border-2 border-dashed border-border/60 rounded-xl hover:border-indigo-500 hover:bg-indigo-500/5 transition-all text-text-muted hover:text-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud size={20} />
|
||||
<span className="font-bold text-sm">Carregar arquivo local (Desktop)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
aria-label="Carregar logo do aplicativo"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">A imagem será armazenada no servidor e acessível publicamente.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-4 bg-surface-soft rounded-xl border border-border/30">
|
||||
<span className="text-xs font-bold text-text-muted uppercase tracking-wider mb-2 block">Preview</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shrink-0 overflow-hidden shadow-lg">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-white font-black text-xl">{appName?.[0] || 'G'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-black text-xl tracking-tight text-text-main leading-none">
|
||||
{appName || 'Nome App'}
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold text-indigo-500 uppercase tracking-wider mt-1">
|
||||
{appSubtitle || 'Subtítulo do App'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className={clsx(
|
||||
"px-6 py-3 rounded-xl font-bold text-white shadow-lg shadow-indigo-500/20 transition-all flex items-center gap-2",
|
||||
isSaving
|
||||
? "bg-indigo-400 cursor-not-allowed"
|
||||
: "bg-indigo-600 hover:bg-indigo-500 hover:scale-[1.02]"
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Salvando...' : 'Salvar Alterações Globais'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeTab === 'organizations') && (
|
||||
<div className="p-8">
|
||||
<h2 className="text-xl font-bold mb-6 flex items-center gap-2">
|
||||
<Building2 className="text-indigo-500" />
|
||||
Estrutura Organizacional
|
||||
</h2>
|
||||
<p className="text-text-muted mb-6">Visão hierárquica das organizações e seus membros.</p>
|
||||
|
||||
{isLoadingData ? (
|
||||
<div className="text-center py-8 text-text-muted">Carregando organizações...</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{globalOrgs.map((org) => {
|
||||
const admins = org.members.filter(m => m.role === 'admin');
|
||||
const commonUsers = org.members.filter(m => m.role !== 'admin');
|
||||
|
||||
return (
|
||||
<div key={org._id} className={clsx(
|
||||
"bg-surface-soft border rounded-xl overflow-hidden transition-all",
|
||||
org.isBanned
|
||||
? "border-red-500/50 shadow-[0_0_15px_rgba(239,68,68,0.1)]"
|
||||
: "border-border/40 hover:border-indigo-500/30"
|
||||
)}>
|
||||
<div className="p-4 bg-surface border-b border-border/40 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="text-xs font-bold text-text-muted uppercase tracking-wider">Organização</div>
|
||||
{org.isBanned && (
|
||||
<span className="px-2 py-0.5 bg-red-500/10 text-red-500 rounded text-[10px] font-bold border border-red-500/20 flex items-center gap-1">
|
||||
<X size={10} /> BLOQUEADA
|
||||
</span>
|
||||
)}
|
||||
{!org.isBanned && (
|
||||
<span className="px-2 py-0.5 bg-green-500/10 text-green-500 rounded text-[10px] font-bold border border-green-500/20">
|
||||
ATIVA
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-bold text-text-main text-base mb-0.5">{org.name || 'Organização Sem Nome'}</div>
|
||||
<div className="font-mono text-indigo-400/80 text-xs select-all" title="ID da Organização">{org._id}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-bold text-text-muted uppercase tracking-wider mb-1">Total de Membros</div>
|
||||
<span className="bg-surface-hover px-2 py-1 rounded text-sm font-bold">{org.memberCount}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm(org.isBanned ? 'Deseja desbloquear esta organização?' : 'Deseja BLOQUEAR esta organização? Os usuários perderão o acesso imediatamente.')) {
|
||||
try {
|
||||
await systemSettingsService.toggleOrganizationBan(org._id, !org.isBanned);
|
||||
showToast(`Organização ${org.isBanned ? 'desbloqueada' : 'bloqueada'} com sucesso!`, 'success');
|
||||
// Refresh list
|
||||
const data = await systemSettingsService.getGlobalOrganizations();
|
||||
setGlobalOrgs(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Erro ao alterar status.', 'error');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg transition-colors border",
|
||||
org.isBanned
|
||||
? "border-green-500/30 bg-green-500/10 text-green-500 hover:bg-green-500/20"
|
||||
: "border-red-500/30 bg-red-500/10 text-red-500 hover:bg-red-500/20"
|
||||
)}
|
||||
title={org.isBanned ? "Desbloquear Organização" : "Bloquear Organização"}
|
||||
>
|
||||
{org.isBanned ? (
|
||||
<Users size={18} /> // Unblock icon
|
||||
) : (
|
||||
<X size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6 opacity-90">
|
||||
{/* Admins Column */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-indigo-500 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Users size={14} /> Administradores
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{admins.map(admin => (
|
||||
<div key={admin.userId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-indigo-500/20">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-500 font-bold text-xs">
|
||||
{admin.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-bold text-sm truncate text-text-main">{admin.name}</div>
|
||||
<div className="text-[10px] text-text-muted truncate">{admin.email}</div>
|
||||
</div>
|
||||
{admin.isBanned && <span className="ml-auto text-error text-[10px] uppercase font-bold">Banido</span>}
|
||||
</div>
|
||||
))}
|
||||
{admins.length === 0 && <p className="text-sm text-text-muted italic">Sem administradores.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Column */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Users size={14} /> Usuários & Convidados
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{commonUsers.map(user => (
|
||||
<div key={user.userId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-border/40">
|
||||
<div className={clsx(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs",
|
||||
user.role === 'user' ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"
|
||||
)}>
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-bold text-sm truncate text-text-main">{user.name}</div>
|
||||
<div className="text-[10px] text-text-muted truncate">{user.email}</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"ml-auto text-[10px] uppercase font-bold px-1.5 py-0.5 rounded",
|
||||
user.role === 'user' ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"
|
||||
)}>
|
||||
{user.role}
|
||||
</span>
|
||||
{user.isBanned && <span className="text-error text-[10px] uppercase font-bold ml-1">Banido</span>}
|
||||
</div>
|
||||
))}
|
||||
{commonUsers.length === 0 && <p className="text-sm text-text-muted italic">Sem outros membros.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2 bg-surface border-t border-border/40 text-[10px] text-text-muted text-right">
|
||||
Última atividade: {org.lastActive ? new Date(org.lastActive).toLocaleDateString() + ' ' + new Date(org.lastActive).toLocaleTimeString() : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{globalOrgs.length === 0 && (
|
||||
<div className="p-8 text-center text-text-muted bg-surface-soft rounded-xl border border-border/40">
|
||||
Nenhuma organização encontrada.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeTab === 'users') && (
|
||||
<div className="p-8">
|
||||
<h2 className="text-xl font-bold mb-6 flex items-center gap-2">
|
||||
<Users className="text-indigo-500" />
|
||||
Base Global de Usuários
|
||||
</h2>
|
||||
|
||||
{isLoadingData ? (
|
||||
<div className="text-center py-8 text-text-muted">Carregando usuários...</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 text-xs text-text-muted uppercase tracking-wider">
|
||||
<th className="p-4">Nome / Email</th>
|
||||
<th className="p-4">Role Global</th>
|
||||
<th className="p-4">Status</th>
|
||||
<th className="p-4 text-right">Cadastrado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{globalUsers.map((user) => (
|
||||
<tr key={user._id} className="border-b border-border/20 hover:bg-surface-soft/50 transition-colors">
|
||||
<td className="p-4">
|
||||
<div className="font-bold text-text-main">{user.name}</div>
|
||||
<div className="text-xs text-text-muted">{user.email}</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={clsx(
|
||||
"px-2 py-1 rounded text-xs font-bold uppercase",
|
||||
user.role === 'admin' ? "bg-indigo-500/10 text-indigo-500" :
|
||||
user.role === 'user' ? "bg-green-500/10 text-green-500" :
|
||||
"bg-gray-500/10 text-gray-500"
|
||||
)}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{user.isBanned ? (
|
||||
<span className="text-error font-bold text-xs flex items-center gap-1">
|
||||
<X size={12} /> Banido
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-500 font-bold text-xs">Ativo</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-right text-text-muted text-sm">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{globalUsers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-8 text-center text-text-muted">Nenhum usuário encontrado.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
198
src/client/pages/GuestDashboard.tsx
Normal file
198
src/client/pages/GuestDashboard.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { LayoutDashboard, User } from 'lucide-react';
|
||||
import type { Project, PaintingScheme } from '../types';
|
||||
import { ColorBubble } from '../components/ColorBubble';
|
||||
|
||||
interface DashboardProject extends Project {
|
||||
schemes: (Partial<PaintingScheme> & { colorHex?: string })[];
|
||||
paintedWeight?: number;
|
||||
}
|
||||
|
||||
export const GuestDashboard: React.FC = () => {
|
||||
const [projects, setProjects] = useState<DashboardProject[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await api.get('/projects/dashboard');
|
||||
setProjects(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard projects:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-500 shadow-sm">
|
||||
<LayoutDashboard className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">Painel Principal</h1>
|
||||
<p className="text-sm text-text-muted font-medium tracking-wide">Visão geral das obras e esquemas ativos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{projects.map((project) => (
|
||||
<div key={project.id} className="group relative">
|
||||
{/* Card Background Bloom Effect */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 rounded-[32px] blur-xl opacity-0 group-hover:opacity-100 transition-all duration-700"></div>
|
||||
|
||||
<div
|
||||
onClick={() => navigate(`/project/${project.id}`)}
|
||||
className="p-0 flex flex-col h-full relative overflow-hidden bg-surface border border-border/40 rounded-[32px] group-hover:border-primary/40 transition-all duration-500 shadow-soft group-hover:shadow-2xl cursor-pointer"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<div className="p-8 pb-4 relative">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-black text-primary uppercase tracking-[0.25em]">{project.client || 'CLIENTE N/D'}</span>
|
||||
<h3 className="text-2xl font-black text-text-main tracking-tighter leading-none group-hover:text-primary transition-colors" title={project.name}>
|
||||
{project.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-[9px] font-black uppercase tracking-widest rounded-full border border-primary/20">
|
||||
OBRA ATIVA
|
||||
</span>
|
||||
{project.technician && (
|
||||
<span className="px-3 py-1 bg-surface-highlight text-text-secondary text-[9px] font-black uppercase tracking-widest rounded-full border border-border/50">
|
||||
RESP: {project.technician}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body Section */}
|
||||
<div className="px-8 space-y-5 flex-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-surface-soft/50 rounded-2xl border border-border/20 flex flex-col">
|
||||
<span className="text-[9px] text-text-muted font-bold uppercase tracking-wider mb-1">Peso Total</span>
|
||||
<span className="text-xl font-black text-text-main">
|
||||
{project.weightKg ? Number(project.weightKg).toLocaleString('pt-BR') : '0'} <span className="text-[9px] text-text-muted">kg</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 bg-surface-soft/50 rounded-2xl border border-border/20 flex flex-col">
|
||||
<span className="text-[9px] text-text-muted font-bold uppercase tracking-wider mb-1">Evolução</span>
|
||||
<span className="text-xl font-black text-text-main">
|
||||
{project.weightKg && project.paintedWeight !== undefined
|
||||
? Math.min(Math.round((project.paintedWeight / project.weightKg) * 100), 100)
|
||||
: 0}
|
||||
<span className="text-[9px] text-text-muted">%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schemes List */}
|
||||
{project.schemes && project.schemes.length > 0 && (
|
||||
<div className="py-1">
|
||||
<div className="text-[10px] font-bold text-text-muted uppercase tracking-widest border-b border-border/40 pb-2 mb-3">Esquemas de Pintura</div>
|
||||
<div className="space-y-2">
|
||||
{project.schemes.slice(0, 2).map((scheme, index) => (
|
||||
<div key={index} className="p-3 bg-surface-soft/50 rounded-xl border border-border/20">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-black text-primary uppercase tracking-wide mb-1">
|
||||
{scheme.coat || scheme.type || 'Camada'}
|
||||
</div>
|
||||
<div className="text-sm font-black text-text-main line-clamp-1 mb-1.5">
|
||||
{scheme.name}
|
||||
</div>
|
||||
|
||||
{/* Info Row: Cor */}
|
||||
{scheme.color && (
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<span className="w-1 h-1 rounded-full bg-text-muted opacity-50"></span>
|
||||
<span className="text-[13px] font-bold text-text-muted">
|
||||
Cor: {scheme.color}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Row: EPS e Diluente */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(scheme.epsMin || scheme.epsMax) && (
|
||||
<span className="font-mono bg-primary/5 text-primary px-2 py-1 rounded border border-primary/10 font-bold text-[13px]">
|
||||
EPS: {scheme.epsMin}-{scheme.epsMax}µm
|
||||
</span>
|
||||
)}
|
||||
{scheme.thinnerSymbol && (
|
||||
<span className="px-2 py-1 bg-surface-highlight text-text-secondary rounded border border-border/50 font-bold text-[13px]">
|
||||
{scheme.thinnerSymbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{scheme.colorHex && (
|
||||
<ColorBubble colorHex={scheme.colorHex} className="w-6 h-6 shadow-md flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{project.schemes.length > 2 && (
|
||||
<div className="text-[9px] text-center text-primary font-black bg-primary/5 py-1.5 rounded border border-primary/10">
|
||||
+ {project.schemes.length - 2} ESQUEMAS ADICIONAIS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="p-8 pt-6 border-t border-border/40 mt-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-surface-highlight flex items-center justify-center border border-border/40">
|
||||
<User size={14} className="text-text-muted" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black text-text-muted uppercase tracking-tighter">Responsável</span>
|
||||
<span className="text-[11px] font-bold text-text-secondary">{project.technician || 'Não Definido'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 h-10 px-6 bg-surface-highlight hover:bg-primary hover:text-white border border-border/40 rounded-xl text-xs font-black transition-all group/btn">
|
||||
<LayoutDashboard size={14} className="group-hover/btn:scale-110 transition-transform" />
|
||||
VER PROJETO
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="col-span-full py-20 text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-surface-soft rounded-2xl flex items-center justify-center text-text-muted mb-4">
|
||||
<LayoutDashboard size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-main">Nenhum projeto encontrado</h3>
|
||||
<p className="text-text-muted mt-2">Você não possui acesso a nenhum projeto ativo no momento.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
344
src/client/pages/InspectionsList.tsx
Normal file
344
src/client/pages/InspectionsList.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../services/api';
|
||||
import { Button } from '../components/Button';
|
||||
import { Plus, Pencil, Trash2, ClipboardCheck, Search, Paintbrush, Hammer, Camera } from 'lucide-react';
|
||||
import { MobileList } from '../components/MobileList';
|
||||
import { CreateInspectionModal } from '../components/modals/CreateInspectionModal';
|
||||
import { format } from 'date-fns';
|
||||
import type { Inspection, Project } from '../types';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
|
||||
export const InspectionsList: React.FC = () => {
|
||||
const [inspections, setInspections] = useState<Inspection[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editItem, setEditItem] = useState<Inspection | undefined>(undefined);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch inspections
|
||||
try {
|
||||
const response = await api.get('/inspections');
|
||||
console.log('Inspeções carregadas do banco:', response.data);
|
||||
setInspections(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching inspections:', error);
|
||||
}
|
||||
|
||||
// Fetch projects for lookup
|
||||
try {
|
||||
const projRes = await api.get('/projects');
|
||||
setProjects(projRes.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInspections = fetchData; // Alias for compatibility with existing calls
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir esta inspeção?')) return;
|
||||
try {
|
||||
await api.delete(`/inspections/${id}`);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting inspection', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredInspections = inspections.filter(i => {
|
||||
const project = projects.find(p => p.id === i.projectId);
|
||||
const projectName = project?.name?.toLowerCase() || '';
|
||||
const clientName = project?.client?.toLowerCase() || '';
|
||||
const search = searchTerm.toLowerCase();
|
||||
|
||||
return (
|
||||
(i.inspector && i.inspector.toLowerCase().includes(search)) ||
|
||||
(i.pieceDescription && i.pieceDescription.toLowerCase().includes(search)) ||
|
||||
(i.defects && i.defects.toLowerCase().includes(search)) ||
|
||||
(i.type === 'surface_treatment' && i.batch?.toLowerCase().includes(search)) ||
|
||||
projectName.includes(search) ||
|
||||
clientName.includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const getStatusIndicator = (targetType: 'painting' | 'surface_treatment', currentItem: Inspection) => {
|
||||
const normalize = (s: string | undefined | null) =>
|
||||
(s || '').normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
||||
|
||||
const getPid = (p: string | { _id?: string; id?: string } | null | undefined): string => {
|
||||
if (!p) return '';
|
||||
if (typeof p === 'string') return p;
|
||||
const obj = p as { _id?: string; id?: string };
|
||||
return obj._id || obj.id || String(p);
|
||||
};
|
||||
|
||||
const tProj = getPid(currentItem.projectId);
|
||||
const tDesc = normalize(currentItem.pieceDescription);
|
||||
const tBatch = normalize(currentItem.batch);
|
||||
|
||||
const matches = inspections.filter(ins => {
|
||||
const iProj = getPid(ins.projectId);
|
||||
const iType = ins.type || 'painting';
|
||||
if (iProj !== tProj || iType !== targetType) return false;
|
||||
const iDesc = normalize(ins.pieceDescription);
|
||||
const iBatch = normalize(ins.batch);
|
||||
return (tDesc !== '' && (tDesc === iDesc || tDesc === iBatch)) ||
|
||||
(tBatch !== '' && (tBatch === iDesc || tBatch === iBatch));
|
||||
});
|
||||
|
||||
// DEBUG AVANÇADO: Só logamos quando falha em achar uma peça que deveria existir
|
||||
if (matches.length === 0 && (tDesc.includes('perfis') || tDesc.includes('chapas'))) {
|
||||
const inSameProject = inspections.filter(ins => getPid(ins.projectId) === tProj);
|
||||
const otherStages = inSameProject.filter(ins => (ins.type || 'painting') === targetType);
|
||||
|
||||
if (otherStages.length > 0) {
|
||||
console.group(`Diagnóstico de Falha: ${targetType} para "${tDesc}"`);
|
||||
console.log("Peça atual:", { desc: tDesc, lote: tBatch, proj: tProj });
|
||||
console.log(`Encontrei ${otherStages.length} registros de ${targetType} no projeto, mas nenhum casou com a descrição/lote.`);
|
||||
console.log("Dados dos registros ignorados para conferência:", otherStages.map(v => ({ desc: v.pieceDescription, lote: v.batch })));
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return { status: 'none', color: 'border-text-muted/30 text-text-muted/30' };
|
||||
|
||||
const latest = [...matches].sort((a, b) => {
|
||||
const dA = a.date ? new Date(a.date).getTime() : 0;
|
||||
const dB = b.date ? new Date(b.date).getTime() : 0;
|
||||
return dB - dA;
|
||||
})[0];
|
||||
|
||||
const app = latest.appearance;
|
||||
if (app === 'rejected') return { status: 'rejected', color: 'bg-error border-error text-error' };
|
||||
if (app === 'notes' || app === 'warning') return { status: 'warning', color: 'bg-amber-500 border-amber-500 text-amber-500' };
|
||||
if (app === 'approved') return { status: 'approved', color: 'bg-success border-success text-success' };
|
||||
|
||||
return { status: 'none', color: 'border-text-muted/30 text-text-muted/30' };
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Tipo',
|
||||
accessor: (i: Inspection) => (
|
||||
<div className={clsx(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center",
|
||||
i.type === 'surface_treatment' ? "bg-amber-100 text-amber-600" : "bg-primary/10 text-primary"
|
||||
)}>
|
||||
{i.type === 'surface_treatment' ? <Hammer size={16} /> : <Paintbrush size={16} />}
|
||||
</div>
|
||||
),
|
||||
className: "w-12"
|
||||
},
|
||||
{
|
||||
header: 'Obra / Projeto',
|
||||
accessor: (i: Inspection) => {
|
||||
const project = projects.find(p => p.id === i.projectId);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-main text-xs uppercase tracking-tight truncate max-w-[150px]" title={project?.name}>
|
||||
{project?.name || '---'}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted font-bold uppercase truncate max-w-[150px]">{project?.client || '---'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Data / Inspetor',
|
||||
accessor: (i: Inspection) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-main text-xs uppercase tracking-tight">
|
||||
{i.date ? format(new Date(i.date), 'dd/MM/yyyy') : 'Sem Data'}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted font-bold uppercase">{i.inspector || '---'}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Detalhes',
|
||||
accessor: (i: Inspection) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-main font-bold text-xs uppercase">{i.pieceDescription || '--'}</span>
|
||||
{i.type === 'surface_treatment' ? (
|
||||
<div className="flex gap-2 text-[10px] text-text-muted mt-0.5">
|
||||
<span className="bg-surface-hover px-1.5 rounded">{i.treatmentType === 'dry_abrasive_blasting' ? 'Jateamento' : i.treatmentType}</span>
|
||||
<span className="bg-surface-hover px-1.5 rounded font-bold">{i.cleaningDegree}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-text-muted mt-0.5">Pintura Convencional</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Peso (Kg)',
|
||||
accessor: (i: Inspection) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-text-main font-bold text-xs">{i.weightKg ? i.weightKg.toLocaleString('pt-BR') : '0'}</span>
|
||||
<span className="text-[10px] text-text-muted font-bold tracking-tighter uppercase">KG</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Evidências',
|
||||
accessor: (i: Inspection) => i.photos && i.photos.length > 0 ? (
|
||||
<div className="flex items-center gap-1 text-text-muted text-xs font-medium">
|
||||
<Camera size={14} />
|
||||
<span>{i.photos.length}</span>
|
||||
</div>
|
||||
) : null,
|
||||
className: "hidden md:table-cell"
|
||||
},
|
||||
{
|
||||
header: 'Situação',
|
||||
accessor: (i: Inspection) => {
|
||||
const treat = getStatusIndicator('surface_treatment', i);
|
||||
const paint = getStatusIndicator('painting', i);
|
||||
|
||||
const isAnyRejected = treat.status === 'rejected' || paint.status === 'rejected';
|
||||
const isBothApproved = treat.status === 'approved' && paint.status === 'approved';
|
||||
const isAnyNotes = treat.status === 'warning' || paint.status === 'warning';
|
||||
|
||||
return (
|
||||
<span className={clsx(
|
||||
"px-2 py-0.5 rounded text-[10px] font-black uppercase whitespace-nowrap",
|
||||
isAnyRejected ? "bg-error/10 text-error" :
|
||||
isBothApproved ? "bg-success/10 text-success" :
|
||||
isAnyNotes ? "bg-amber-500/10 text-amber-500" :
|
||||
"bg-surface-soft text-text-muted"
|
||||
)}>
|
||||
{isAnyRejected ? 'Reprovado' :
|
||||
isBothApproved ? 'Aprovado' :
|
||||
isAnyNotes ? 'Ressalvas' :
|
||||
'Pendente'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Realizado',
|
||||
accessor: (i: Inspection) => {
|
||||
const treat = getStatusIndicator('surface_treatment', i);
|
||||
const paint = getStatusIndicator('painting', i);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx("w-2.5 h-2.5 rounded-full border-2", treat.color)}></div>
|
||||
<span className={clsx("text-[10px] font-bold uppercase", treat.status === 'none' ? "text-text-muted/50" : "text-text-secondary")}>Tratamento</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx("w-2.5 h-2.5 rounded-full border-2", paint.color)}></div>
|
||||
<span className={clsx("text-[10px] font-bold uppercase", paint.status === 'none' ? "text-text-muted/50" : "text-text-secondary")}>Pintura</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-lg shadow-primary/5">
|
||||
<ClipboardCheck className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-5xl font-black text-text-main tracking-tight mb-0">Inspeções</h1>
|
||||
<p className="text-sm text-text-muted font-medium tracking-widest uppercase text-xs">Controle de qualidade e conformidade</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="relative hidden lg:block group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar inspeções..."
|
||||
className="h-14 w-64 bg-surface border border-border/40 rounded-2xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nova Inspeção
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
|
||||
<MobileList
|
||||
data={filteredInspections}
|
||||
columns={columns}
|
||||
keyExtractor={(item) => item.id}
|
||||
titleAccessor={(item) => item.date ? format(new Date(item.date), 'dd/MM/yyyy') : 'Sem data'}
|
||||
subtitleAccessor={(item) => `${item.inspector || ''} - ${item.pieceDescription || ''}`}
|
||||
actionRender={(item) => (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => { setEditItem(item); setIsModalOpen(true); }}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
|
||||
aria-label="Editar inspeção"
|
||||
title="Editar inspeção"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
|
||||
aria-label="Excluir inspeção"
|
||||
title="Excluir inspeção"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{filteredInspections.length === 0 && (
|
||||
<div className="text-center py-24">
|
||||
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6 border border-border/20">
|
||||
<ClipboardCheck className="w-10 h-10" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-main">Nenhuma inspeção encontrada</h3>
|
||||
<p className="mt-2 text-text-muted font-medium">Os registros de qualidade aparecerão aqui após serem realizados.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateInspectionModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => { setIsModalOpen(false); setEditItem(undefined); }}
|
||||
onSuccess={fetchInspections}
|
||||
initialData={editItem}
|
||||
existingInspections={inspections}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
377
src/client/pages/InstrumentList.tsx
Normal file
377
src/client/pages/InstrumentList.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
import type { IInstrument, CreateInstrumentDTO } from '../types/Instrument';
|
||||
import { Plus, Search, Edit2, Trash2, Calendar, Gauge, XCircle } from 'lucide-react';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
const InstrumentList: React.FC = () => {
|
||||
const [instruments, setInstruments] = useState<IInstrument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingInstrument, setEditingInstrument] = useState<IInstrument | null>(null);
|
||||
const { showToast } = useToast();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState<CreateInstrumentDTO>({
|
||||
name: '',
|
||||
type: '',
|
||||
manufacturer: '',
|
||||
modelName: '',
|
||||
serialNumber: '',
|
||||
calibrationDate: '',
|
||||
calibrationExpirationDate: '',
|
||||
status: 'active',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const fetchInstruments = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/instruments');
|
||||
setInstruments(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch instruments', error);
|
||||
showToast('Erro ao carregar instrumentos', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstruments();
|
||||
}, [fetchInstruments]);
|
||||
|
||||
const handleOpenModal = (instrument?: IInstrument) => {
|
||||
if (instrument) {
|
||||
setEditingInstrument(instrument);
|
||||
setFormData({
|
||||
name: instrument.name,
|
||||
type: instrument.type,
|
||||
manufacturer: instrument.manufacturer || '',
|
||||
modelName: instrument.modelName || '',
|
||||
serialNumber: instrument.serialNumber,
|
||||
calibrationDate: instrument.calibrationDate ? instrument.calibrationDate.split('T')[0] : '',
|
||||
calibrationExpirationDate: instrument.calibrationExpirationDate ? instrument.calibrationExpirationDate.split('T')[0] : '',
|
||||
status: instrument.status,
|
||||
notes: instrument.notes || ''
|
||||
});
|
||||
} else {
|
||||
setEditingInstrument(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: '',
|
||||
manufacturer: '',
|
||||
modelName: '',
|
||||
serialNumber: '',
|
||||
calibrationDate: '',
|
||||
calibrationExpirationDate: '',
|
||||
status: 'active',
|
||||
notes: ''
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir este instrumento?')) return;
|
||||
try {
|
||||
await api.delete(`/instruments/${id}`);
|
||||
setInstruments(instruments.filter(i => i._id !== id));
|
||||
showToast('Instrumento excluído com sucesso', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete instrument', error);
|
||||
showToast('Erro ao excluir instrumento', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingInstrument) {
|
||||
const response = await api.put(`/instruments/${editingInstrument._id}`, formData);
|
||||
setInstruments(instruments.map(i => i._id === editingInstrument._id ? response.data : i));
|
||||
showToast('Instrumento atualizado com sucesso', 'success');
|
||||
} else {
|
||||
const response = await api.post('/instruments', formData);
|
||||
setInstruments([...instruments, response.data]);
|
||||
showToast('Instrumento criado com sucesso', 'success');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save instrument', error);
|
||||
showToast('Erro ao salvar instrumento', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-green-500/10 text-green-500';
|
||||
case 'inactive': return 'bg-gray-500/10 text-gray-500';
|
||||
case 'maintenance': return 'bg-yellow-500/10 text-yellow-500';
|
||||
case 'expired': return 'bg-red-500/10 text-red-500';
|
||||
default: return 'bg-gray-500/10 text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'Ativo';
|
||||
case 'inactive': return 'Inativo';
|
||||
case 'maintenance': return 'Manutenção';
|
||||
case 'expired': return 'Vencido';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredInstruments = instruments.filter(i =>
|
||||
i.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
i.serialNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
i.type.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-main">Instrumentos de Medição</h1>
|
||||
<p className="text-text-muted text-sm">Gerencie seus instrumentos e calibrações</p>
|
||||
</div>
|
||||
{isAdmin() && (
|
||||
<button
|
||||
onClick={() => handleOpenModal()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Novo Instrumento
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nome, serial ou tipo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-surface rounded-xl border border-border/40 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-text-muted">Carregando...</div>
|
||||
) : filteredInstruments.length === 0 ? (
|
||||
<div className="text-center py-20 bg-surface rounded-2xl border border-border/40 border-dashed">
|
||||
<Gauge size={48} className="mx-auto text-text-muted/50 mb-4" />
|
||||
<h3 className="text-lg font-bold text-text-main">Nenhum instrumento encontrado</h3>
|
||||
<p className="text-text-muted text-sm mt-1">Cadastre seus instrumentos para controlar a calibração.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredInstruments.map((instrument) => (
|
||||
<div key={instrument._id} className="bg-surface p-5 rounded-2xl border border-border/40 hover:border-primary/50 transition-all group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-primary/10 rounded-xl text-primary">
|
||||
<Gauge size={24} />
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-bold uppercase ${getStatusColor(instrument.status)}`}>
|
||||
{getStatusLabel(instrument.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-text-main mb-1">{instrument.name}</h3>
|
||||
<p className="text-sm text-text-muted mb-4">{instrument.type} • {instrument.manufacturer}</p>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span className="font-mono bg-surface-hover px-1.5 rounded text-xs">S/N: {instrument.serialNumber}</span>
|
||||
</div>
|
||||
{instrument.calibrationExpirationDate && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar size={14} className={new Date(instrument.calibrationExpirationDate) < new Date() ? "text-red-500" : "text-text-muted"} />
|
||||
<span className={new Date(instrument.calibrationExpirationDate) < new Date() ? "text-red-500 font-bold" : "text-text-secondary"}>
|
||||
Vence: {new Date(instrument.calibrationExpirationDate).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin() && (
|
||||
<div className="flex gap-2 pt-4 border-t border-border/40">
|
||||
<button
|
||||
onClick={() => handleOpenModal(instrument)}
|
||||
className="flex-1 py-2 rounded-lg bg-surface-hover hover:bg-surface-hover/80 text-text-main text-sm font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit2 size={16} /> Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(instrument._id)}
|
||||
className="p-2 rounded-lg hover:bg-red-500/10 text-text-muted hover:text-red-500 transition-colors"
|
||||
title="Excluir Instrumento"
|
||||
aria-label="Excluir Instrumento"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-surface w-full max-w-lg rounded-2xl shadow-2xl border border-border/40 p-6 animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-text-main">
|
||||
{editingInstrument ? 'Editar Instrumento' : 'Novo Instrumento'}
|
||||
</h2>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-text-muted hover:text-text-main" title="Fechar Modal">
|
||||
<XCircle size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Nome</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
|
||||
placeholder="Ex: Higrômetro"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Tipo</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
|
||||
placeholder="Ex: Ambiental"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Fabricante</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.manufacturer}
|
||||
onChange={e => setFormData({ ...formData, manufacturer: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
|
||||
placeholder="Ex: Minipa"
|
||||
title="Fabricante do instrumento"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Modelo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.modelName}
|
||||
onChange={e => setFormData({ ...formData, modelName: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
|
||||
placeholder="Ex: ET-2000"
|
||||
title="Modelo do instrumento"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Número de Série</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.serialNumber}
|
||||
onChange={e => setFormData({ ...formData, serialNumber: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main font-mono"
|
||||
placeholder="Ex: 123456"
|
||||
title="Número de série"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Data Calibração</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.calibrationDate}
|
||||
onChange={e => setFormData({ ...formData, calibrationDate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
|
||||
title="Data da última calibração"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Vencimento</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.calibrationExpirationDate}
|
||||
onChange={e => setFormData({ ...formData, calibrationExpirationDate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
|
||||
title="Data de vencimento da calibração"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Status</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={e => setFormData({ ...formData, status: e.target.value as IInstrument['status'] })}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
|
||||
title="Status do instrumento"
|
||||
>
|
||||
<option value="active">Ativo</option>
|
||||
<option value="inactive">Inativo</option>
|
||||
<option value="maintenance">Em Manutenção</option>
|
||||
<option value="expired">Vencido</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Observações</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main resize-none"
|
||||
placeholder="Observações adicionais..."
|
||||
title="Observações"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="flex-1 py-3 rounded-xl bg-surface-hover text-text-main font-bold hover:bg-surface-hover/80 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-3 rounded-xl bg-primary text-white font-bold hover:bg-primary-dark transition-colors shadow-lg shadow-primary/20"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstrumentList;
|
||||
115
src/client/pages/Login.tsx
Normal file
115
src/client/pages/Login.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from "react";
|
||||
import { Hammer } from "lucide-react";
|
||||
import { useAuth } from "../context/useAuth";
|
||||
import { getBaseUrl } from "../services/api";
|
||||
|
||||
const API_URL = getBaseUrl();
|
||||
|
||||
export const Login = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMsg("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setErrorMsg(data.error || "Erro ao efetuar login");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
login(data.token, data.user);
|
||||
} catch (err) {
|
||||
setErrorMsg("Falha na conexão com o servidor.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-surface-soft relative overflow-hidden">
|
||||
{/* Background decorative elements */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] animate-pulse" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-primary/5 rounded-full blur-[120px]" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center">
|
||||
{/* Logo Area */}
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center text-white font-bold text-3xl shadow-2xl shadow-primary/40 mb-4 animate-in zoom-in duration-700">
|
||||
G
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-text-main tracking-tight mb-1">GPI</h1>
|
||||
<p className="text-text-muted text-sm font-medium uppercase tracking-widest">Gestão de Pintura Industrial</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Login Form */}
|
||||
<div className="w-full bg-surface rounded-[2rem] border border-border/40 shadow-2xl shadow-primary/5 p-8 animate-in slide-in-from-bottom-8 duration-1000">
|
||||
<h2 className="text-xl font-bold text-text-main mb-6 text-center">Entrar na sua conta</h2>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-error/10 border border-error/20 text-error text-sm text-center">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-text-secondary" htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="bg-surface-soft border border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl px-4 py-3 text-text-main outline-none transition-all"
|
||||
placeholder="seu@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-sm font-semibold text-text-secondary" htmlFor="password">Senha</label>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-surface-soft border border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl px-4 py-3 text-text-main outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-4 bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-primary/20 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Entrando..." : "Entrar"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-2 text-text-muted/60 text-xs font-medium">
|
||||
<Hammer size={14} />
|
||||
<span>© 2026 GPI - Eficiência Industrial</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
src/client/pages/OrganizationSelector.tsx
Normal file
49
src/client/pages/OrganizationSelector.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { Building2, RefreshCw } from 'lucide-react';
|
||||
|
||||
export const OrganizationSelector: React.FC = () => {
|
||||
const { appUser, isSignedIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSignedIn && appUser?.organizationId) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [isSignedIn, appUser, navigate]);
|
||||
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-surface rounded-2xl border border-border/40 p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-amber-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<Building2 className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-main mb-2">
|
||||
Não Conectado
|
||||
</h1>
|
||||
<p className="text-text-muted mb-6">
|
||||
Você precisa estar logado para acessar esta página.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="w-full py-3 bg-primary text-white rounded-xl font-bold"
|
||||
>
|
||||
Ir para Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-text-main font-bold mb-2">Redirecionando...</p>
|
||||
<p className="text-text-muted text-sm">Carregando sua organização</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
184
src/client/pages/PartsList.tsx
Normal file
184
src/client/pages/PartsList.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../services/api';
|
||||
import { Button } from '../components/Button';
|
||||
import { Plus, Pencil, Trash2, Box, Search } from 'lucide-react';
|
||||
import { MobileList } from '../components/MobileList';
|
||||
import { CreatePartModal } from '../components/modals/CreatePartModal';
|
||||
import type { Part, GeometryType } from '../types';
|
||||
|
||||
export const PartsList: React.FC = () => {
|
||||
const [parts, setParts] = useState<Part[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editItem, setEditItem] = useState<Part | undefined>(undefined);
|
||||
const [geometryTypes, setGeometryTypes] = useState<GeometryType[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchParts();
|
||||
fetchGeometryTypes();
|
||||
}, []);
|
||||
|
||||
const fetchParts = async () => {
|
||||
try {
|
||||
const response = await api.get('/parts');
|
||||
setParts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching parts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGeometryTypes = async () => {
|
||||
try {
|
||||
const response = await api.get('/geometry-types');
|
||||
setGeometryTypes(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching geometry types:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir esta peça?')) return;
|
||||
try {
|
||||
await api.delete(`/parts/${id}`);
|
||||
fetchParts();
|
||||
} catch (error) {
|
||||
console.error('Error deleting part', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getLossForType = (type?: string) => {
|
||||
if (!type) return '--';
|
||||
const geo = geometryTypes.find(g => g.name === type);
|
||||
return geo ? `${geo.efficiencyLoss}%` : '--';
|
||||
};
|
||||
|
||||
const filteredParts = parts.filter(p =>
|
||||
p.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(p.type && p.type.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Peça / Descrição',
|
||||
accessor: (p: Part) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-main text-xs uppercase tracking-tight">{p.description}</span>
|
||||
<span className="text-[10px] text-text-muted font-medium uppercase">{p.type || 'Sem Categoria'}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Perdas Estim.(%)',
|
||||
accessor: (p: Part) => (
|
||||
<span className="text-orange-500 font-black text-xs">{getLossForType(p.type)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Peso (Kg)',
|
||||
accessor: (p: Part) => (
|
||||
<span className="text-text-main font-bold text-xs">{p.weight ? `${p.weight} kg` : '--'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Área (m²)',
|
||||
accessor: (p: Part) => (
|
||||
<span className="text-primary font-black text-xs">{p.area ? `${p.area} m²` : '--'}</span>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-lg shadow-primary/5">
|
||||
<Box className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-5xl font-black text-text-main tracking-tight mb-0">Peças & Geometria</h1>
|
||||
<p className="text-sm text-text-muted font-medium tracking-widest uppercase text-xs">Catálogo global de itens cadastrados</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="relative hidden lg:block group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar peças..."
|
||||
className="h-14 w-64 bg-surface border border-border/40 rounded-2xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nova Peça
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
|
||||
<MobileList
|
||||
data={filteredParts}
|
||||
columns={columns}
|
||||
keyExtractor={(item) => item.id}
|
||||
titleAccessor="description"
|
||||
subtitleAccessor={(item) => `${item.type || 'N/A'} - ${item.weight || 0}kg`}
|
||||
actionRender={(item) => (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => { setEditItem(item); setIsModalOpen(true); }}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
|
||||
aria-label="Editar peça"
|
||||
title="Editar peça"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
|
||||
aria-label="Excluir peça"
|
||||
title="Excluir peça"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{filteredParts.length === 0 && (
|
||||
<div className="text-center py-24">
|
||||
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6 border border-border/20">
|
||||
<Box className="w-10 h-10" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-main">Nenhuma peça encontrada</h3>
|
||||
<p className="mt-2 text-text-muted font-medium">Você ainda não possui peças cadastradas no sistema.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreatePartModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => { setIsModalOpen(false); setEditItem(undefined); }}
|
||||
onSuccess={fetchParts}
|
||||
initialData={editItem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
896
src/client/pages/ProjectDetails.tsx
Normal file
896
src/client/pages/ProjectDetails.tsx
Normal file
@@ -0,0 +1,896 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useLocation } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import { Button } from '../components/Button';
|
||||
import { Card } from '../components/Card';
|
||||
import { ArrowLeft, Layers, PenTool, ClipboardCheck, Activity, Trash2, Pencil, Copy, RefreshCw, Thermometer, Droplets, Sun } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { format } from 'date-fns';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
|
||||
import { CreatePartModal } from '../components/modals/CreatePartModal';
|
||||
import { CreatePaintingSchemeModal } from '../components/modals/CreatePaintingSchemeModal';
|
||||
import { CreateControlRecordModal } from '../components/modals/CreateControlRecordModal';
|
||||
import { CreateInspectionModal } from '../components/modals/CreateInspectionModal';
|
||||
import { CreateProjectModal } from '../components/modals/CreateProjectModal';
|
||||
import { CloneSchemeModal } from '../components/modals/CloneSchemeModal';
|
||||
import { ImportSchemeModal } from '../components/modals/ImportSchemeModal';
|
||||
import { MobileList } from '../components/MobileList';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Project, Part, PaintingScheme, Inspection, ApplicationRecord, GeometryType, TechnicalDataSheet } from '../types';
|
||||
import { AnalysisDashboard } from './AnalysisDashboard';
|
||||
import * as geometryTypeService from '../services/geometryTypeService';
|
||||
import { ColorBubble } from '../components/ColorBubble';
|
||||
|
||||
export const ProjectDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { isAdmin, isUser, appUser, isGuest } = useAuth();
|
||||
const { showGuestWarning } = useToast();
|
||||
|
||||
const canEditItem = (item: { createdBy?: string }) => {
|
||||
if (isAdmin()) return true;
|
||||
if (!isUser() || !appUser) return false;
|
||||
return item.createdBy === appUser.id;
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [isPartModalOpen, setIsPartModalOpen] = useState(false);
|
||||
const [isSchemeModalOpen, setIsSchemeModalOpen] = useState(false);
|
||||
const [isControlModalOpen, setIsControlModalOpen] = useState(false);
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
const [isEditProjectModalOpen, setIsEditProjectModalOpen] = useState(false);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isExchangeMode, setIsExchangeMode] = useState(false);
|
||||
|
||||
interface LocationState {
|
||||
activeTab?: 'parts' | 'scheme' | 'control' | 'inspection' | 'analysis';
|
||||
}
|
||||
|
||||
const initialTab = (location.state as LocationState)?.activeTab || 'parts';
|
||||
const [activeTab, setActiveTab] = useState<'parts' | 'scheme' | 'control' | 'inspection' | 'analysis'>(initialTab);
|
||||
const [editingScheme, setEditingScheme] = useState<PaintingScheme | undefined>(undefined);
|
||||
const [cloningScheme, setCloningScheme] = useState<PaintingScheme | undefined>(undefined);
|
||||
const [editingPart, setEditingPart] = useState<Part | undefined>(undefined);
|
||||
const [editingInspection, setEditingInspection] = useState<Inspection | undefined>(undefined);
|
||||
const [editingRecord, setEditingRecord] = useState<ApplicationRecord | undefined>(undefined);
|
||||
const [geometryTypes, setGeometryTypes] = useState<GeometryType[]>([]);
|
||||
|
||||
const getLossForType = (type?: string) => {
|
||||
if (!type) return '--';
|
||||
const geo = geometryTypes.find(g => g.name === type);
|
||||
return geo ? `${geo.efficiencyLoss}%` : '--';
|
||||
};
|
||||
|
||||
const handleDeleteScheme = async (schemeId: string) => {
|
||||
if (isGuest()) {
|
||||
showGuestWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Tem certeza que deseja excluir este esquema?')) return;
|
||||
try {
|
||||
await api.delete(`/painting-schemes/${schemeId}`);
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
console.error('Error deleting scheme', error);
|
||||
alert('Erro ao excluir esquema');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePart = async (partId: string) => {
|
||||
if (isGuest()) {
|
||||
showGuestWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Tem certeza que deseja excluir esta peça?')) return;
|
||||
try {
|
||||
await api.delete(`/parts/${partId}`);
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
console.error('Error deleting part', error);
|
||||
alert('Erro ao excluir peça');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInspection = async (id: string) => {
|
||||
if (isGuest()) {
|
||||
showGuestWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Tem certeza que deseja excluir esta inspeção?')) return;
|
||||
try {
|
||||
await api.delete(`/inspections/${id}`);
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
console.error('Error deleting inspection', error);
|
||||
alert('Erro ao excluir inspeção');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRecord = async (id: string) => {
|
||||
if (isGuest()) {
|
||||
showGuestWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Tem certeza que deseja excluir este registro?')) return;
|
||||
try {
|
||||
await api.delete(`/application-records/${id}`);
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
console.error('Error deleting record', error);
|
||||
alert('Erro ao excluir registro');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!id) return;
|
||||
|
||||
if (isGuest()) {
|
||||
showGuestWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Tem certeza que deseja excluir este projeto COMPLETO? Todos os dados vinculados serão perdidos permanentemente.')) return;
|
||||
try {
|
||||
await api.delete(`/projects/${id}`);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Error deleting project', error);
|
||||
alert('Erro ao excluir projeto');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditScheme = (scheme: PaintingScheme) => {
|
||||
setEditingScheme(scheme);
|
||||
setIsSchemeModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditPart = (part: Part) => {
|
||||
setEditingPart(part);
|
||||
setIsPartModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditInspection = (insp: Inspection) => {
|
||||
setEditingInspection(insp);
|
||||
setIsInspectionModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditRecord = (record: ApplicationRecord) => {
|
||||
setEditingRecord(record);
|
||||
setIsControlModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const fetchProject = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get(`/projects/${id}`);
|
||||
setProject(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching project:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProject();
|
||||
// Buscar tipos de geometria para as perdas
|
||||
geometryTypeService.getAllTypes().then(res => setGeometryTypes(res.data)).catch(console.error);
|
||||
}, [fetchProject]);
|
||||
|
||||
if (loading) return <div className="p-8 text-center">Carregando detalhes...</div>;
|
||||
if (!project) return <div className="p-8 text-center text-error">Projeto não encontrado.</div>;
|
||||
|
||||
const tabs: { id: typeof activeTab, label: string, icon: React.ElementType }[] = [
|
||||
{ id: 'parts', label: 'Geometria & Peças', icon: Layers },
|
||||
{ id: 'scheme', label: 'Esquema de Pintura', icon: PenTool },
|
||||
{ id: 'inspection', label: 'Inspeção', icon: ClipboardCheck },
|
||||
{ id: 'control', label: 'Controle de Aplicação', icon: Activity },
|
||||
{ id: 'analysis', label: 'Análise de Conformidade', icon: ClipboardCheck },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-700">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||
<div className="space-y-4">
|
||||
<Link to="/projects">
|
||||
<button className="flex items-center text-[10px] font-bold text-text-muted uppercase tracking-[0.2em] hover:text-primary transition-colors group">
|
||||
<ArrowLeft className="w-3 h-3 mr-2 group-hover:-translate-x-1 transition-transform" />
|
||||
Voltar aos Projetos
|
||||
</button>
|
||||
</Link>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">{project.name}</h1>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full bg-primary/10 text-primary text-[10px] font-black uppercase tracking-widest border border-primary/20 shadow-sm shadow-primary/5">
|
||||
<Activity className="w-3 h-3 mr-2" />
|
||||
Em Andamento
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex -space-x-2">
|
||||
<div className="w-6 h-6 rounded-full bg-surface-highlight border-2 border-surface flex items-center justify-center text-[10px] font-bold">M</div>
|
||||
<div className="w-6 h-6 rounded-full bg-primary/20 border-2 border-surface flex items-center justify-center text-[10px] font-bold text-primary">G</div>
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary font-medium">{project.client} • {project.environment}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin() && (
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => setIsEditProjectModalOpen(true)}>
|
||||
<Pencil className="w-4 h-4 mr-2" /> Editar Obra
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" className="bg-error/10 text-error border border-error/20 hover:bg-error transition-all" onClick={handleDeleteProject}>
|
||||
<Trash2 className="w-4 h-4 mr-2" /> Excluir
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Premium Summary Grid */}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-surface border border-border/40 shadow-soft p-8">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5">
|
||||
<Activity size={120} strokeWidth={1} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 relative z-10">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Data de Início</span>
|
||||
<p className="text-lg font-bold text-text-main">{project.startDate ? format(new Date(project.startDate), 'dd MMM, yyyy') : '-'}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Previsão Término</span>
|
||||
<p className="text-lg font-bold text-text-main">{project.endDate ? format(new Date(project.endDate), 'dd MMM, yyyy') : '-'}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Peso da Obra (KGF)</span>
|
||||
<p className="text-lg font-bold text-text-main">
|
||||
{project.weightKg ? Number(project.weightKg).toLocaleString('pt-BR') : '0'} <span className="text-sm text-text-muted">kg</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Evolução%</span>
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{project.weightKg && project.paintedWeight !== undefined
|
||||
? Math.min(Math.round((project.paintedWeight / project.weightKg) * 100), 100)
|
||||
: 0}
|
||||
<span className="text-sm">%</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 pt-6 border-t border-border/20 grid grid-cols-1 md:grid-cols-2 gap-6 relative z-10">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Localização/Amb.</span>
|
||||
<p className="text-sm font-medium text-text-secondary">{project.environment || '-'}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Eng. Responsável</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||
<p className="text-sm font-bold text-text-main">{project.technician || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex items-center justify-between border-b border-border/40 scrollbar-hide overflow-x-auto">
|
||||
<nav className="flex space-x-10" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
'whitespace-nowrap py-5 px-1 border-b-2 font-bold text-xs uppercase tracking-[0.15em] flex items-center gap-3 transition-all relative',
|
||||
active
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-main hover:border-border'
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx("w-4 h-4", active ? "text-primary" : "text-text-muted")} />
|
||||
{tab.label}
|
||||
{active && <span className="absolute bottom-[-1px] left-0 right-0 h-[2px] bg-primary shadow-[0_0_12px_rgba(13,127,242,0.6)]"></span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[300px]">
|
||||
{activeTab === 'parts' && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div className="flex justify-between items-center bg-surface-soft/30 p-4 rounded-2xl border border-border/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<Layers size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-main tracking-tight mb-0">Listagem de Geometrias</h2>
|
||||
<p className="text-xs text-text-muted font-medium">Gerencie as peças e áreas cadastradas para esta obra</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin() && (
|
||||
<Button size="sm" onClick={() => setIsPartModalOpen(true)}>
|
||||
<Layers className="w-4 h-4 mr-2" /> Cadastrar Peça
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{project.parts?.length === 0 ? (
|
||||
<div className="py-20 text-center border-2 border-dashed border-border/40 rounded-3xl">
|
||||
<Layers className="w-12 h-12 text-text-muted/20 mx-auto mb-4" />
|
||||
<p className="text-text-muted font-medium">Nenhuma peça cadastrada neste projeto.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{project.parts?.map((part: Part) => (
|
||||
<Card key={part.id} className="relative group overflow-hidden border-border/20 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-500">
|
||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-primary/20 group-hover:bg-primary transition-colors"></div>
|
||||
<div className="flex justify-between items-start pl-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-primary uppercase tracking-widest">{part.type || 'GEOMETRIA'}</span>
|
||||
<h4 className="text-lg font-bold text-text-main tracking-tight leading-tight">{part.description}</h4>
|
||||
</div>
|
||||
{isAdmin() && (
|
||||
<div className="flex gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all">
|
||||
<button
|
||||
onClick={() => openEditPart(part)}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
|
||||
title="Editar Peça"
|
||||
aria-label="Editar Peça"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeletePart(part.id)}
|
||||
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
|
||||
title="Excluir Peça"
|
||||
aria-label="Excluir Peça"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 pt-4 border-t border-border/40 grid grid-cols-2 gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase tracking-wider">Peso Total Estimado</span>
|
||||
<p className="text-sm font-bold text-text-main">{(part.weight || 0)} kg</p>
|
||||
</div>
|
||||
<div className="space-y-0.5 text-right">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase tracking-wider">Perdas Estim.(%)</span>
|
||||
<p className="text-sm font-black text-error">{getLossForType(part.type)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-surface-soft/50 rounded-xl flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-text-secondary uppercase">Área Total Superfície</span>
|
||||
<span className="text-sm font-black text-primary">
|
||||
{(part.area || 0).toFixed(2)} m²
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'scheme' && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div className="flex justify-between items-center bg-surface-soft/30 p-4 rounded-2xl border border-border/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<PenTool size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-main tracking-tight mb-0">Esquema de Pintura</h2>
|
||||
<p className="text-xs text-text-muted font-medium">Defina as tintas, etapas e espessuras requeridas</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin() && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => { setIsExchangeMode(true); setIsImportModalOpen(true); }}
|
||||
variant="ghost"
|
||||
className="text-text-muted hover:text-primary"
|
||||
title="Trocar ou Importar Esquema"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5 mr-1" />
|
||||
Trocar
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingScheme(undefined); setIsSchemeModalOpen(true); }} className="shadow-primary/30">
|
||||
<PenTool className="w-5 h-5 mr-2" />
|
||||
Adicionar Demão
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{project.paintingSchemes?.length === 0 ? (
|
||||
<div className="py-20 text-center border-2 border-dashed border-border/40 rounded-3xl">
|
||||
<PenTool className="w-12 h-12 text-text-muted/20 mx-auto mb-4" />
|
||||
<p className="text-text-muted font-medium">Nenhum esquema cadastrado neste projeto.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{project.paintingSchemes?.map((scheme: PaintingScheme) => (
|
||||
<Card key={scheme.id} className="relative group overflow-hidden border-border/20 shadow-sm hover:shadow-xl transition-all duration-500">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<span className="px-2 py-0.5 rounded bg-primary text-white text-[9px] font-black uppercase tracking-widest">{scheme.type}</span>
|
||||
<div className="flex items-center gap-6 mt-2">
|
||||
<h4 className="text-xl font-bold text-text-main tracking-tight">{scheme.name}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase">Redutor:</span>
|
||||
<span className="text-sm font-bold text-text-main">
|
||||
{scheme.thinnerSymbol || (typeof scheme.thinnerId === 'object' ? (scheme.thinnerId as TechnicalDataSheet)?.name : '---')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin() && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => { setCloningScheme(scheme); setIsCloneModalOpen(true); }}
|
||||
className="p-2 text-text-muted hover:text-green-500 hover:bg-green-500/10 rounded-xl transition-all"
|
||||
title="Clonar para outra obra"
|
||||
aria-label="Clonar para outra obra"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditScheme(scheme)}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
|
||||
title="Editar Esquema"
|
||||
aria-label="Editar Esquema"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteScheme(scheme.id)}
|
||||
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
|
||||
title="Excluir Esquema"
|
||||
aria-label="Excluir Esquema"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-3 gap-6">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">EPS Requerida</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-black text-text-main">{scheme.epsMin}-{scheme.epsMax}</span>
|
||||
<span className="text-[10px] font-bold text-text-muted">μm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Sólidos Vol.</span>
|
||||
<p className="text-lg font-black text-text-main">{scheme.solidsVolume}%</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Cor / Cod.</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-lg font-black text-text-main truncate" title={scheme.color || '-'}>{scheme.color || '-'}</p>
|
||||
<ColorBubble colorHex={scheme.colorHex} className="w-10 h-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-border/40 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)] animate-pulse"></div>
|
||||
<span className="text-[10px] font-black text-text-muted uppercase tracking-widest">Ativo no Sistema</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-text-muted italic">Item verificado NBR 12103</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'control' && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div className="flex justify-between items-center bg-surface-soft/30 p-4 rounded-2xl border border-border/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-main tracking-tight mb-0">Controle de Aplicação</h2>
|
||||
<p className="text-xs text-text-muted font-medium">Registro diário de demãos e consumos</p>
|
||||
</div>
|
||||
</div>
|
||||
{isUser() && (
|
||||
<Button size="sm" onClick={() => setIsControlModalOpen(true)}>
|
||||
<Activity className="w-4 h-4 mr-2" /> Novo Registro
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{project.applicationRecords?.length === 0 ? (
|
||||
<div className="py-20 text-center border-2 border-dashed border-border/40 rounded-3xl">
|
||||
<Activity className="w-12 h-12 text-text-muted/20 mx-auto mb-4" />
|
||||
<p className="text-text-muted font-medium">Nenhum registro de aplicação encontrado.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-border/40 overflow-hidden bg-surface shadow-soft">
|
||||
<MobileList<ApplicationRecord>
|
||||
data={project.applicationRecords || []}
|
||||
keyExtractor={(item) => item.id}
|
||||
titleAccessor={(item) => `${item.coatStage} - ${item.pieceDescription}`}
|
||||
subtitleAccessor={(item) => item.date ? format(new Date(item.date), 'dd/MM/yyyy') : '-'}
|
||||
columns={[
|
||||
{ header: 'Data', accessor: (item) => item.date ? format(new Date(item.date), 'dd MMM') : '-' },
|
||||
{
|
||||
header: 'Etapa/Peça', accessor: (item) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-main">{item.coatStage}</span>
|
||||
<span className="text-[10px] text-text-muted font-bold uppercase">{item.pieceDescription}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ header: 'Pintor', accessor: 'operator' },
|
||||
{
|
||||
header: 'EPS Seca (μm)', accessor: (item) => (
|
||||
<span className="px-2 py-1 rounded-lg bg-primary/5 text-primary font-black">
|
||||
{item.dryThicknessCalc || '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
]}
|
||||
actionRender={(item) => canEditItem(item) ? (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); openEditRecord(item); }}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
|
||||
title="Editar Registro"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteRecord(item.id); }}
|
||||
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
|
||||
title="Excluir Registro"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'inspection' && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div className="flex justify-between items-center bg-surface-soft/30 p-4 rounded-2xl border border-border/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<ClipboardCheck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-main tracking-tight mb-0">Relatórios de Inspeção</h2>
|
||||
<p className="text-xs text-text-muted font-medium">Verificações de qualidade e medições de espessura final</p>
|
||||
</div>
|
||||
</div>
|
||||
{isUser() && (
|
||||
<Button size="sm" onClick={() => setIsInspectionModalOpen(true)}>
|
||||
<ClipboardCheck className="w-4 h-4 mr-2" /> Nova Inspeção
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{project.inspections?.length === 0 ? (
|
||||
<div className="py-20 text-center border-2 border-dashed border-border/40 rounded-3xl">
|
||||
<ClipboardCheck className="w-12 h-12 text-text-muted/20 mx-auto mb-4" />
|
||||
<p className="text-text-muted font-medium">Nenhuma inspeção registrada.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{project.inspections?.map((insp: Inspection) => (
|
||||
<Card key={insp.id} className="relative group overflow-hidden border-border/20 shadow-sm hover:shadow-xl transition-all duration-500">
|
||||
<div className="absolute top-0 right-0 p-4">
|
||||
<div className={clsx(
|
||||
"px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border shadow-sm",
|
||||
insp.appearance === 'approved'
|
||||
? 'bg-success/10 text-success border-success/20'
|
||||
: insp.appearance === 'rejected'
|
||||
? 'bg-error/10 text-error border-error/20'
|
||||
: 'bg-warning/10 text-warning border-warning/20'
|
||||
)}>
|
||||
{insp.appearance === 'approved' ? 'Aprovado' : insp.appearance === 'rejected' ? 'Reprovado' : 'Ressalvas'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">
|
||||
{insp.date ? format(new Date(insp.date), 'dd/MM/yyyy') : '-'}
|
||||
</span>
|
||||
<h4 className="text-lg font-bold text-text-main tracking-tight leading-tight pt-1">
|
||||
{insp.pieceDescription}
|
||||
</h4>
|
||||
<p className="text-xs text-text-muted font-medium">Inspector: {insp.inspector}</p>
|
||||
</div>
|
||||
{canEditItem(insp) && (
|
||||
<div className="flex gap-1 mt-8">
|
||||
<button
|
||||
onClick={() => openEditInspection(insp)}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
|
||||
title="Editar Inspeção"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteInspection(insp.id)}
|
||||
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
|
||||
title="Excluir Inspeção"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(insp.stockItemId || insp.treatmentType || (insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0) || (project.weightKg && insp.weightKg)) && (
|
||||
<div className="mt-6 px-1 flex flex-wrap md:flex-nowrap items-center gap-6 md:gap-10 border-t border-border/10 pt-6">
|
||||
{/* Evolution Pie */}
|
||||
{project.weightKg && insp.weightKg && (
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase tracking-[0.2em] block mb-2 whitespace-nowrap">Peso / Evolução</span>
|
||||
<div className="relative w-16 h-16 flex items-center justify-center bg-surface-soft/40 dark:bg-black/10 rounded-full border border-border/10 shadow-sm">
|
||||
<svg viewBox="0 0 36 36" className="w-[85%] h-[85%] -rotate-90">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="transparent" stroke="currentColor" strokeWidth="3" className="text-border/20" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15.9155" fill="transparent" stroke="currentColor" strokeWidth="3"
|
||||
strokeDasharray={`${Math.min(Math.round((insp.weightKg / project.weightKg) * 100), 100)} 100`}
|
||||
className="text-primary transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-base font-black text-text-main">
|
||||
{Math.min(Math.round((insp.weightKg / project.weightKg) * 100), 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-primary mt-1">{insp.weightKg} kg</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 flex-1 h-full items-center md:border-l border-border/20 md:pl-8">
|
||||
{typeof insp.stockItemId === 'object' && insp.stockItemId && (
|
||||
<div className="col-span-1">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest block mb-1">Tinta Utilizada</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary/40"></div>
|
||||
<span className="text-xs font-bold text-text-main truncate max-w-[180px]" title={(insp.stockItemId as any).dataSheetId?.name}>
|
||||
{(insp.stockItemId as any).dataSheetId?.name || 'Item sem nome'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-3.5">
|
||||
<span className="text-[10px] text-text-muted font-black uppercase tracking-wider bg-surface-soft/50 py-0.5 px-1 rounded border border-border/5">
|
||||
Lote: {(insp.stockItemId as any).batchNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(insp.treatmentType || (insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0)) && (
|
||||
<div className="col-span-1 flex gap-8">
|
||||
{insp.treatmentType && (
|
||||
<div>
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest block mb-0.5">Tipo Jato</span>
|
||||
<span className="text-[11px] font-bold text-text-main bg-stone-100 px-1.5 py-0.5 rounded border border-stone-200 shadow-sm">
|
||||
{insp.treatmentType === 'dry_abrasive_blasting' ? 'Seco' :
|
||||
insp.treatmentType === 'water_jetting' ? 'Hidrojato' :
|
||||
insp.treatmentType === 'mechanical_cleaning' ? 'Mecânica' :
|
||||
insp.treatmentType === 'manual_cleaning' ? 'Manual' : 'Outro'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0 && (
|
||||
<div>
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest block mb-0.5">Rug. Média</span>
|
||||
<span className="text-xs font-black text-amber-600">
|
||||
{(insp.roughnessReadings.filter((p): p is number => p !== null).reduce((a, b) => a + b, 0) / insp.roughnessReadings.filter(p => p !== null).length).toFixed(0)} <small className="text-[9px] font-medium opacity-70">μm</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(insp.temperature || insp.relativeHumidity) && (
|
||||
<div className="mt-4 px-1 flex items-center gap-4 text-text-muted border-t border-border/10 pt-3">
|
||||
{insp.temperature && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Thermometer className="w-3 h-3 text-orange-500" />
|
||||
<span className="text-[11px] font-bold text-text-secondary">{insp.temperature}°C</span>
|
||||
</div>
|
||||
)}
|
||||
{insp.relativeHumidity && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Droplets className="w-3 h-3 text-blue-500" />
|
||||
<span className="text-[11px] font-bold text-text-secondary">{insp.relativeHumidity}% UR</span>
|
||||
</div>
|
||||
)}
|
||||
{insp.partTemperature && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Thermometer className="w-3 h-3 text-red-500" />
|
||||
<span className="text-[11px] font-bold text-text-secondary">{insp.partTemperature}°C (Peça)</span>
|
||||
</div>
|
||||
)}
|
||||
{insp.period && (
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<Sun className="w-3 h-3 text-amber-500" />
|
||||
<span className="text-[9px] font-black uppercase tracking-tighter text-text-muted">{insp.period === 'morning' ? 'Manhã' : insp.period === 'afternoon' ? 'Tarde' : 'Noite'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 p-4 bg-surface-soft/50 rounded-2xl flex items-center justify-between border border-border/20">
|
||||
{insp.type === 'painting' ? (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest">EPS Média</span>
|
||||
{insp.epsPoints && insp.epsPoints.filter(p => p !== null).length > 0 ? (
|
||||
<span className="text-xl font-black text-primary">
|
||||
{(insp.epsPoints.filter((p): p is number => p !== null).reduce((a, b) => a + b, 0) / insp.epsPoints.filter(p => p !== null).length).toFixed(0)}
|
||||
<span className="text-xs ml-1">μm</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold text-text-muted">Sem dados</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 border-l border-border/40 pl-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase">Min</span>
|
||||
<span className="text-sm font-bold text-text-main">
|
||||
{insp.epsPoints && insp.epsPoints.filter(p => p !== null).length > 0
|
||||
? Math.min(...insp.epsPoints.filter((p): p is number => p !== null)).toFixed(0)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase">Max</span>
|
||||
<span className="text-sm font-bold text-text-main">
|
||||
{insp.epsPoints && insp.epsPoints.filter(p => p !== null).length > 0
|
||||
? Math.max(...insp.epsPoints.filter((p): p is number => p !== null)).toFixed(0)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest">Rugosidade Média</span>
|
||||
{insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0 ? (
|
||||
<span className="text-xl font-black text-amber-600">
|
||||
{(insp.roughnessReadings.filter((p): p is number => p !== null).reduce((a, b) => a + b, 0) / insp.roughnessReadings.filter(p => p !== null).length).toFixed(0)}
|
||||
<span className="text-xs ml-1">μm</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold text-text-muted">Sem dados</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 border-l border-border/40 pl-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase">Min</span>
|
||||
<span className="text-sm font-bold text-text-main">
|
||||
{insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0
|
||||
? Math.min(...insp.roughnessReadings.filter((p): p is number => p !== null)).toFixed(0)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-bold text-text-muted uppercase">Max</span>
|
||||
<span className="text-sm font-bold text-text-main">
|
||||
{insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0
|
||||
? Math.max(...insp.roughnessReadings.filter((p): p is number => p !== null)).toFixed(0)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analysis' && id && (
|
||||
<AnalysisDashboard projectId={id} />
|
||||
)}
|
||||
|
||||
{id && (
|
||||
<>
|
||||
<CreatePartModal
|
||||
isOpen={isPartModalOpen}
|
||||
onClose={() => {
|
||||
setIsPartModalOpen(false);
|
||||
setEditingPart(undefined);
|
||||
}}
|
||||
onSuccess={fetchProject}
|
||||
projectId={id}
|
||||
initialData={editingPart}
|
||||
/>
|
||||
<CreatePaintingSchemeModal
|
||||
isOpen={isSchemeModalOpen}
|
||||
onClose={() => {
|
||||
setIsSchemeModalOpen(false);
|
||||
setEditingScheme(undefined);
|
||||
}}
|
||||
onSuccess={fetchProject}
|
||||
projectId={id}
|
||||
initialData={editingScheme}
|
||||
/>
|
||||
<CreateControlRecordModal
|
||||
isOpen={isControlModalOpen}
|
||||
onClose={() => {
|
||||
setIsControlModalOpen(false);
|
||||
setEditingRecord(undefined);
|
||||
}}
|
||||
onSuccess={fetchProject}
|
||||
projectId={id}
|
||||
initialData={editingRecord}
|
||||
availableParts={project.parts || []}
|
||||
existingRecords={project.applicationRecords || []}
|
||||
availableBatches={project.inspections || []}
|
||||
/>
|
||||
<CreateInspectionModal
|
||||
isOpen={isInspectionModalOpen}
|
||||
onClose={() => {
|
||||
setIsInspectionModalOpen(false);
|
||||
setEditingInspection(undefined);
|
||||
}}
|
||||
onSuccess={fetchProject}
|
||||
projectId={id}
|
||||
initialData={editingInspection}
|
||||
existingInspections={project.inspections || []}
|
||||
/>
|
||||
<CreateProjectModal
|
||||
isOpen={isEditProjectModalOpen}
|
||||
onClose={() => setIsEditProjectModalOpen(false)}
|
||||
onSuccess={fetchProject}
|
||||
initialData={project}
|
||||
/>
|
||||
<CloneSchemeModal
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => { setIsCloneModalOpen(false); setCloningScheme(undefined); }}
|
||||
onSuccess={fetchProject} // Actually we don't need to fetchProject here as the cloned scheme is in ANOTHER project. But maybe good for hygiene.
|
||||
schemeToClone={cloningScheme}
|
||||
/>
|
||||
<ImportSchemeModal
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={() => setIsImportModalOpen(false)}
|
||||
onSuccess={fetchProject}
|
||||
targetProjectId={project.id}
|
||||
isExchangeMode={isExchangeMode}
|
||||
hasInspections={(project.inspections && project.inspections.length > 0) || (project.applicationRecords && project.applicationRecords.length > 0)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
582
src/client/pages/ProjectList.tsx
Normal file
582
src/client/pages/ProjectList.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import { Button } from '../components/Button';
|
||||
import { Plus, Search, Pencil, Trash2, Activity, Box, CheckCircle2, History, Printer } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { MobileList } from '../components/MobileList';
|
||||
import { CreateProjectModal } from '../components/modals/CreateProjectModal';
|
||||
import { useSystemSettings } from '../context/SystemSettingsContext';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { ColorBubble } from '../components/ColorBubble';
|
||||
|
||||
import type { Project, Inspection } from '../types';
|
||||
import { AnalyticalReport } from '../components/reports/AnalyticalReport';
|
||||
import { GeneralProjectReport } from '../components/reports/GeneralProjectReport';
|
||||
|
||||
export const ProjectList: React.FC = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [inspections, setInspections] = useState<Inspection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | undefined>(undefined);
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'danger' | 'warning' | 'info';
|
||||
onConfirm: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'info',
|
||||
onConfirm: () => { }
|
||||
});
|
||||
|
||||
const [statsModal, setStatsModal] = useState<{ isOpen: boolean; title: string; type: 'alerts' | 'inspections' | null }>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
type: null
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [viewStatus, setViewStatus] = useState<'active' | 'archived'>('active');
|
||||
const [printingProject, setPrintingProject] = useState<Project | null>(null);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const [isPrintingGeneral, setIsPrintingGeneral] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { appUser } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { settings } = useSystemSettings();
|
||||
|
||||
const logoUrl = settings?.appLogoUrl;
|
||||
|
||||
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [projRes, inspRes] = await Promise.all([
|
||||
api.get(`/projects?status=${viewStatus}`),
|
||||
api.get('/inspections')
|
||||
]);
|
||||
setProjects(projRes.data);
|
||||
setInspections(inspRes.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
showToast('Não foi possível carregar os dados.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [viewStatus, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
const handleEdit = (project: Project) => {
|
||||
if (viewStatus === 'archived') return;
|
||||
setSelectedProject(project);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const project = projects.find(p => p.id === id);
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: 'Excluir Projeto',
|
||||
description: `Tem certeza que deseja excluir o projeto "${project?.name}"? Todos os dados vinculados serão perdidos permanentemente.`,
|
||||
type: 'danger',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.delete(`/projects/${id}`);
|
||||
fetchProjects();
|
||||
showToast('O projeto foi removido com sucesso.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
showToast('Não foi possível excluir o projeto.', 'error');
|
||||
}
|
||||
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePrint = async (projectId: string) => {
|
||||
try {
|
||||
setIsPrinting(true);
|
||||
const response = await api.get(`/projects/${projectId}`);
|
||||
setPrintingProject(response.data);
|
||||
|
||||
// Wait for state to update and layout to render
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
setIsPrinting(false);
|
||||
setPrintingProject(null);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error fetching project for print:', error);
|
||||
showToast('Erro ao gerar relatório.', 'error');
|
||||
setIsPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintGeneral = () => {
|
||||
setIsPrintingGeneral(true);
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
setIsPrintingGeneral(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleArchive = async (project: Project) => {
|
||||
const isArchiving = viewStatus === 'active';
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: isArchiving ? 'Concluir Obras' : 'Reativar Projeto',
|
||||
description: `Deseja ${isArchiving ? 'arquivar' : 'reativar'} o projeto "${project.name}"?`,
|
||||
type: 'info',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.patch(`/projects/${project.id}/archive`);
|
||||
showToast(`O projeto ${project.name} foi movido com sucesso.`, 'success');
|
||||
fetchProjects();
|
||||
} catch (error) {
|
||||
console.error('Error archiving project:', error);
|
||||
showToast('Não foi possível alterar o status do projeto.', 'error');
|
||||
}
|
||||
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const filteredProjects = projects.filter(p =>
|
||||
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
p.client.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Obra / Projeto',
|
||||
accessor: (item: Project) => (
|
||||
<div
|
||||
className="flex flex-col cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => navigate(`/project/${item.id}`)}
|
||||
>
|
||||
<span className="text-text-main font-black text-sm uppercase leading-tight line-clamp-1">{item.name}</span>
|
||||
<span className="text-[10px] text-text-muted font-bold uppercase tracking-wider">{item.client}</span>
|
||||
<span className="text-[9px] text-text-secondary font-bold tracking-tight">
|
||||
Gestor: <span className="text-text-main">{item.technician || 'Não informado'}</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
className: 'min-w-[150px]'
|
||||
},
|
||||
{
|
||||
header: 'Evolução',
|
||||
accessor: (item: Project) => {
|
||||
const projectInspections = inspections.filter(i => i.projectId === item.id);
|
||||
const sumWeight = projectInspections.reduce((acc, curr) => acc + (curr.weightKg || 0), 0);
|
||||
const totalWeight = item.weightKg || 0;
|
||||
const percentage = totalWeight > 0 ? Math.min(Math.round((sumWeight / totalWeight) * 100), 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-10 h-10 flex items-center justify-center">
|
||||
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15.9155"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-border/20"
|
||||
/>
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15.9155"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={`${percentage} 100`}
|
||||
className="text-primary transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-black text-text-main">
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: 'hidden sm:table-cell w-[80px]'
|
||||
},
|
||||
{
|
||||
header: 'Cronograma',
|
||||
accessor: (item: Project) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-text-muted font-black uppercase w-10">Início:</span>
|
||||
<span className="bg-orange-500/10 text-orange-500 px-1.5 py-0.5 rounded text-[10px] font-black">
|
||||
{item.startDate ? format(new Date(item.startDate), 'dd/MM/yy') : '--/--/--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-text-muted font-black uppercase w-10">Término:</span>
|
||||
<span className="bg-orange-950/20 text-orange-400 px-1.5 py-0.5 rounded text-[10px] font-black border border-orange-500/10">
|
||||
{item.endDate ? format(new Date(item.endDate), 'dd/MM/yy') : '--/--/--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
className: 'hidden md:table-cell w-[110px]'
|
||||
},
|
||||
{
|
||||
header: 'Peso(kgf)',
|
||||
accessor: (item: Project) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-text-main font-black text-sm">{item.weightKg ? item.weightKg.toLocaleString('pt-BR') : '0'}</span>
|
||||
<span className="text-[8px] text-text-muted font-bold uppercase tracking-tighter">Est. Total</span>
|
||||
</div>
|
||||
),
|
||||
className: 'w-[90px]'
|
||||
},
|
||||
{
|
||||
header: 'Tinta',
|
||||
accessor: (item: Project) => {
|
||||
const schemes = item.paintingSchemes || [];
|
||||
if (schemes.length === 0) return <span className="text-text-muted font-bold text-xs">---</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
{schemes.slice(0, 2).map((scheme, idx) => (
|
||||
<div key={idx} className="flex flex-col max-w-[140px]">
|
||||
<span className="text-text-main font-bold text-[10px] truncate uppercase leading-none" title={scheme.name}>
|
||||
{scheme.name}
|
||||
</span>
|
||||
<span className="text-[7px] text-text-muted font-black uppercase tracking-tighter opacity-70">
|
||||
{scheme.coat || 'Demão'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{schemes.length > 2 && (
|
||||
<span className="text-[7px] text-primary font-black uppercase">+ {schemes.length - 2} demãos</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: 'hidden xl:table-cell w-[130px]'
|
||||
},
|
||||
{
|
||||
header: 'Cor',
|
||||
accessor: (item: Project) => {
|
||||
const schemes = item.paintingSchemes || [];
|
||||
if (schemes.length === 0) return <div className="w-4 h-4 rounded-full border border-border/20 opacity-20 border-dashed" />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
{schemes.slice(0, 2).map((scheme, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 h-[18px]">
|
||||
<span className="text-[9px] text-text-secondary font-bold uppercase truncate max-w-[60px]" title={scheme.color}>
|
||||
{scheme.color || '-'}
|
||||
</span>
|
||||
<ColorBubble colorHex={scheme.colorHex} className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: 'hidden xl:table-cell w-[80px]'
|
||||
},
|
||||
{
|
||||
header: 'Ações',
|
||||
accessor: (item: Project) => (
|
||||
<div className="flex items-center justify-end gap-1.5 min-w-[120px]">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handlePrint(item.id); }}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors flex items-center justify-center"
|
||||
title="Gerar Relatório Analítico"
|
||||
disabled={isPrinting}
|
||||
>
|
||||
<Printer size={18} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleArchive(item); }}
|
||||
className={`p-2 rounded-lg transition-colors ${viewStatus === 'active'
|
||||
? 'text-green-500 hover:bg-green-500/10'
|
||||
: 'text-indigo-500 hover:bg-indigo-500/10'
|
||||
}`}
|
||||
title={viewStatus === 'active' ? 'Concluir e Arquivar' : 'Reativar Projeto'}
|
||||
>
|
||||
{viewStatus === 'active' ? <CheckCircle2 size={16} /> : <History size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isAdmin && viewStatus === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(item.id); }}
|
||||
className="p-2 text-text-muted hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-sm">
|
||||
<Activity className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">
|
||||
{viewStatus === 'active' ? 'Gestão de Obras' : 'Obras Arquivadas'}
|
||||
</h1>
|
||||
<p className="text-sm text-text-muted font-medium tracking-wide">
|
||||
{viewStatus === 'active'
|
||||
? 'Monitoramento e controle de esquemas industriais'
|
||||
: 'Histórico de projetos concluídos e arquivados'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setViewStatus(viewStatus === 'active' ? 'archived' : 'active')}
|
||||
className={`shadow-sm ${viewStatus === 'archived' ? 'bg-indigo-500/10 text-indigo-500 border-indigo-500/20' : ''}`}
|
||||
>
|
||||
{viewStatus === 'active' ? <Box className="w-5 h-5 mr-2" /> : <Activity className="w-5 h-5 mr-2" />}
|
||||
{viewStatus === 'active' ? 'Ver Arquivados' : 'Ver Ativos'}
|
||||
</Button>
|
||||
|
||||
<div className="relative hidden lg:block group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar obras..."
|
||||
className="h-12 w-48 bg-surface-soft border border-border/40 rounded-xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePrintGeneral}
|
||||
className="shadow-sm"
|
||||
disabled={isPrintingGeneral || projects.length === 0}
|
||||
>
|
||||
<Printer className="w-5 h-5 mr-2" />
|
||||
Relatório Geral
|
||||
</Button>
|
||||
|
||||
{isAdmin && viewStatus === 'active' && (
|
||||
<Button onClick={() => { setSelectedProject(undefined); setIsModalOpen(true); }} size="md" className="shadow-primary/30">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Novo Projeto
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
label: viewStatus === 'active' ? 'Projetos Ativos' : 'Projetos Arquivados',
|
||||
value: projects.length,
|
||||
color: viewStatus === 'active' ? 'text-primary' : 'text-indigo-500',
|
||||
bg: 'bg-primary/5',
|
||||
onClick: () => { },
|
||||
interactive: false
|
||||
},
|
||||
{
|
||||
label: 'Inspeções Realizadas',
|
||||
value: inspections.length,
|
||||
color: 'text-success',
|
||||
bg: 'bg-success/5',
|
||||
onClick: () => setStatsModal({ isOpen: true, title: 'Resumo de Inspeções', type: 'inspections' }),
|
||||
interactive: true
|
||||
},
|
||||
{
|
||||
label: 'Conformidade',
|
||||
value: inspections.length > 0
|
||||
? `${((inspections.filter(i => i.appearance !== 'rejected').length / inspections.length) * 100).toFixed(1)}%`
|
||||
: '100%',
|
||||
color: 'text-blue-500',
|
||||
bg: 'bg-blue-500/5',
|
||||
onClick: () => { },
|
||||
interactive: false
|
||||
},
|
||||
{
|
||||
label: 'Alertas Pendentes',
|
||||
value: inspections.filter(i => i.appearance === 'rejected' || i.defects).length,
|
||||
color: 'text-error',
|
||||
bg: 'bg-error/5',
|
||||
onClick: () => setStatsModal({ isOpen: true, title: 'Resumo de Alertas', type: 'alerts' }),
|
||||
interactive: true
|
||||
}
|
||||
].map((stat, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={stat.onClick}
|
||||
className={`p-6 bg-surface border border-border/40 rounded-2xl shadow-sm group transition-all ${stat.interactive ? 'cursor-pointer hover:border-primary/40 hover:shadow-md active:scale-95' : ''}`}
|
||||
>
|
||||
<span className="text-[10px] font-black text-text-muted uppercase tracking-[0.2em]">{stat.label}</span>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className={`text-3xl font-black ${stat.color}`}>{stat.value}</span>
|
||||
<span className="text-[10px] text-text-muted font-bold">TOTAL</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Project Table/List */}
|
||||
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
|
||||
<MobileList
|
||||
data={filteredProjects}
|
||||
columns={columns}
|
||||
keyExtractor={(item) => item.id}
|
||||
onItemClick={(item) => navigate(`/project/${item.id}`)}
|
||||
titleAccessor="name"
|
||||
subtitleAccessor="client"
|
||||
/>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="text-center py-24">
|
||||
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6">
|
||||
{viewStatus === 'active' ? <Search className="w-10 h-10" /> : <Box className="w-10 h-10" />}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-main">
|
||||
{viewStatus === 'active' ? 'Nenhuma obra encontrada' : 'Nenhuma obra arquivada'}
|
||||
</h3>
|
||||
<p className="mt-2 text-text-muted font-medium">
|
||||
{viewStatus === 'active'
|
||||
? 'Inicie o monitoramento criando seu primeiro projeto.'
|
||||
: 'Ainda não existem projetos concluídos no histórico.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateProjectModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedProject(undefined);
|
||||
}}
|
||||
onSuccess={fetchProjects}
|
||||
initialData={selectedProject}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={statsModal.isOpen}
|
||||
onClose={() => setStatsModal({ ...statsModal, isOpen: false })}
|
||||
title={statsModal.title}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{statsModal.type === 'alerts' && (
|
||||
inspections.filter(i => i.appearance === 'rejected' || i.defects).map(insp => {
|
||||
const project = projects.find(p => p.id === insp.projectId);
|
||||
return (
|
||||
<div
|
||||
key={insp.id}
|
||||
className="p-4 rounded-xl bg-error/5 border border-error/20 cursor-pointer hover:bg-error/10 transition-all active:scale-[0.98]"
|
||||
onClick={() => navigate(`/project/${insp.projectId}`, { state: { activeTab: 'inspection' } })}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="font-bold text-text-main text-sm uppercase">{project?.name || 'Projeto Desconhecido'}</span>
|
||||
<span className="text-[10px] font-black text-error border border-error/30 px-1 rounded uppercase">CRÍTICO</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary font-medium italic">"{insp.defects || 'Defeito não especificado'}"</p>
|
||||
<div className="mt-3 flex justify-between items-center text-[10px] text-text-muted font-bold uppercase">
|
||||
<span>Inspetor: {insp.inspector || '--'}</span>
|
||||
<span>Data: {insp.date ? format(new Date(insp.date), 'dd/MM/yy') : '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{statsModal.type === 'inspections' && (
|
||||
inspections.slice(0, 10).map(insp => {
|
||||
const project = projects.find(p => p.id === insp.projectId);
|
||||
return (
|
||||
<div
|
||||
key={insp.id}
|
||||
className="p-4 rounded-xl bg-surface-soft border border-border/40 flex justify-between items-center cursor-pointer hover:bg-surface-hover transition-all active:scale-[0.98]"
|
||||
onClick={() => navigate(`/project/${insp.projectId}`, { state: { activeTab: 'inspection' } })}
|
||||
>
|
||||
<div>
|
||||
<span className="block font-bold text-text-main text-xs uppercase">{project?.name || '---'}</span>
|
||||
<span className="text-[10px] text-text-muted font-medium uppercase">{insp.pieceDescription || 'Geral'}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-[10px] font-black px-2 py-0.5 rounded uppercase ${insp.appearance === 'rejected' ? 'bg-error/10 text-error' : 'bg-success/10 text-success'}`}>
|
||||
{insp.appearance === 'rejected' ? 'REJEITADO' : 'APROVADO'}
|
||||
</span>
|
||||
<span className="block text-[10px] text-text-muted mt-1">{insp.date ? format(new Date(insp.date), 'dd/MM/yy') : '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
isOpen={confirmModal.isOpen}
|
||||
onClose={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
|
||||
onConfirm={confirmModal.onConfirm}
|
||||
title={confirmModal.title}
|
||||
description={confirmModal.description}
|
||||
type={confirmModal.type}
|
||||
/>
|
||||
|
||||
{printingProject && (
|
||||
<AnalyticalReport project={printingProject} logoUrl={logoUrl} />
|
||||
)}
|
||||
|
||||
{isPrintingGeneral && (
|
||||
<GeneralProjectReport
|
||||
projects={filteredProjects}
|
||||
inspections={inspections}
|
||||
logoUrl={logoUrl}
|
||||
title={viewStatus === 'active' ? 'Relatório de Obras Ativas' : 'Relatório de Obras Arquivadas'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
210
src/client/pages/SchemesList.tsx
Normal file
210
src/client/pages/SchemesList.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../services/api';
|
||||
import { Button } from '../components/Button';
|
||||
import { Plus, Pencil, Trash2, Layers, Search, Copy } from 'lucide-react';
|
||||
import { MobileList } from '../components/MobileList';
|
||||
import { CreatePaintingSchemeModal } from '../components/modals/CreatePaintingSchemeModal';
|
||||
import { CloneSchemeModal } from '../components/modals/CloneSchemeModal';
|
||||
import type { PaintingScheme } from '../types';
|
||||
|
||||
import { useAuth } from '../context/useAuth';
|
||||
|
||||
export const SchemesList: React.FC = () => {
|
||||
const [schemes, setSchemes] = useState<PaintingScheme[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editItem, setEditItem] = useState<PaintingScheme | undefined>(undefined);
|
||||
const [cloneItem, setCloneItem] = useState<PaintingScheme | undefined>(undefined);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { appUser } = useAuth();
|
||||
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchemes();
|
||||
}, []);
|
||||
|
||||
const fetchSchemes = async () => {
|
||||
try {
|
||||
const response = await api.get('/painting-schemes');
|
||||
console.log("Fetched schemes (JSON):", JSON.stringify(response.data, null, 2));
|
||||
setSchemes(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching schemes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este esquema?')) return;
|
||||
try {
|
||||
await api.delete(`/painting-schemes/${id}`);
|
||||
fetchSchemes();
|
||||
} catch (error) {
|
||||
console.error('Error deleting scheme', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredSchemes = schemes.filter(s =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(s.type && s.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(s.manufacturer && s.manufacturer.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Esquema / Produto',
|
||||
accessor: (s: PaintingScheme) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-main text-xs uppercase tracking-tight">{s.name}</span>
|
||||
<span className="text-[10px] text-text-muted font-bold uppercase">{s.manufacturer || '---'}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Tipo',
|
||||
accessor: (s: PaintingScheme) => (
|
||||
<span className="text-text-secondary font-medium text-xs uppercase">{s.type || '--'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Demão',
|
||||
accessor: (s: PaintingScheme) => (
|
||||
<span className="text-text-secondary font-medium text-xs uppercase">{s.coat || '--'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Cor',
|
||||
accessor: (s: PaintingScheme) => (
|
||||
<span className="text-text-secondary font-medium text-xs uppercase">{s.color || '--'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'EPS (Min)',
|
||||
accessor: (s: PaintingScheme) => (
|
||||
<span className="text-text-secondary font-medium text-xs">{s.epsMin ? `${s.epsMin} µm` : '--'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'SV%',
|
||||
accessor: (s: PaintingScheme) => (
|
||||
<span className="text-text-secondary font-medium text-xs">{s.solidsVolume ? `${s.solidsVolume}%` : '--'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Rendimento',
|
||||
accessor: (s: PaintingScheme) => (
|
||||
<span className="text-primary font-black text-xs">{s.yieldTheoretical ? `${s.yieldTheoretical} m²/L` : '--'}</span>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-lg shadow-primary/5">
|
||||
<Layers className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-5xl font-black text-text-main tracking-tight mb-0">Esquemas de Pintura</h1>
|
||||
<p className="text-sm text-text-muted font-medium tracking-widest uppercase text-xs">Especificações e rendimentos teóricos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="relative hidden lg:block group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar esquemas..."
|
||||
className="h-14 w-64 bg-surface border border-border/40 rounded-2xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Novo Esquema
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
|
||||
<MobileList
|
||||
data={filteredSchemes}
|
||||
columns={columns}
|
||||
keyExtractor={(item) => item.id}
|
||||
titleAccessor="name"
|
||||
subtitleAccessor={(item) => `${item.manufacturer || ''} ${item.type || ''}`}
|
||||
actionRender={(item) => isAdmin ? (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => { setCloneItem(item); setIsCloneModalOpen(true); }}
|
||||
className="p-2 text-text-muted hover:text-green-500 hover:bg-green-500/10 rounded-xl transition-all"
|
||||
aria-label="Clonar esquema"
|
||||
title="Clonar para outra obra"
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditItem(item); setIsModalOpen(true); }}
|
||||
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
|
||||
aria-label="Editar esquema"
|
||||
title="Editar esquema"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
|
||||
aria-label="Excluir esquema"
|
||||
title="Excluir esquema"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{filteredSchemes.length === 0 && (
|
||||
<div className="text-center py-24">
|
||||
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6 border border-border/20">
|
||||
<Layers className="w-10 h-10" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-main">Nenhum esquema encontrado</h3>
|
||||
<p className="mt-2 text-text-muted font-medium">Cadastre os sistemas de pintura para utilizá-los nas obras.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreatePaintingSchemeModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => { setIsModalOpen(false); setEditItem(undefined); }}
|
||||
onSuccess={fetchSchemes}
|
||||
initialData={editItem}
|
||||
/>
|
||||
<CloneSchemeModal
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => { setIsCloneModalOpen(false); setCloneItem(undefined); }}
|
||||
onSuccess={fetchSchemes}
|
||||
schemeToClone={cloneItem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
440
src/client/pages/StockDashboard.tsx
Normal file
440
src/client/pages/StockDashboard.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Plus, Search, ArrowDown, Edit, Trash2, History, Printer, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { stockService, type StockItem, type StockMovement } from '../services/stockService';
|
||||
import { StockModal } from '../components/modals/StockModal';
|
||||
import { StockOutModal } from '../components/modals/StockOutModal';
|
||||
import { StockHistoryModal } from '../components/modals/StockHistoryModal';
|
||||
import { StockInventoryReport } from '../components/reports/StockInventoryReport';
|
||||
import { DiluentListModal } from '../components/modals/DiluentListModal';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { useSystemSettings } from '../context/SystemSettingsContext';
|
||||
|
||||
export const StockDashboard: React.FC = () => {
|
||||
// ... rest of component
|
||||
const { isAdmin } = useAuth();
|
||||
const { settings } = useSystemSettings();
|
||||
|
||||
const [items, setItems] = useState<StockItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showOutModal, setShowOutModal] = useState(false);
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<StockItem | null>(null);
|
||||
const [isPrintingInventory, setIsPrintingInventory] = useState(false);
|
||||
const [allMovements, setAllMovements] = useState<Map<string, StockMovement[]>>(new Map());
|
||||
const [activeTab, setActiveTab] = useState<'PAINT' | 'THINNER'>('PAINT');
|
||||
const [showDiluentModal, setShowDiluentModal] = useState(false);
|
||||
|
||||
const logoUrl = settings?.appLogoUrl;
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await stockService.getAll();
|
||||
setItems(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching stock:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, []);
|
||||
|
||||
const handleEdit = (item: StockItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleOut = (item: StockItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowOutModal(true);
|
||||
};
|
||||
|
||||
const handleHistory = (item: StockItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowHistoryModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Tem certeza que deseja excluir este lote? Todo o histórico será perdido.')) {
|
||||
try {
|
||||
await stockService.delete(id);
|
||||
fetchItems();
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
alert('Erro ao excluir item.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintInventory = async () => {
|
||||
try {
|
||||
setIsPrintingInventory(true);
|
||||
|
||||
// Buscar movimentações para todos os itens
|
||||
const movementsMap = new Map<string, StockMovement[]>();
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
const movements = await stockService.getMovements(item._id!);
|
||||
movementsMap.set(item._id!, movements);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching movements for ${item._id}:`, error);
|
||||
movementsMap.set(item._id!, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setAllMovements(movementsMap);
|
||||
|
||||
// Aguardar renderização e imprimir
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
setIsPrintingInventory(false);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error generating inventory report:', error);
|
||||
alert('Erro ao gerar relatório de inventário.');
|
||||
setIsPrintingInventory(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const filteredItems = items.filter(item => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
// Handle type checking carefully. If type is missing, assume PAINT.
|
||||
const type = (typeof item.dataSheetId === 'object' ? item.dataSheetId.type : '') || 'PAINT';
|
||||
const isThinner = type === 'THINNER' || type === 'DILUENTE';
|
||||
|
||||
// Tab Filter
|
||||
if (activeTab === 'THINNER' && !isThinner) return false;
|
||||
if (activeTab === 'PAINT' && isThinner) return false;
|
||||
|
||||
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : '';
|
||||
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
|
||||
|
||||
return (
|
||||
item.rrNumber.toLowerCase().includes(searchLower) ||
|
||||
item.batchNumber.toLowerCase().includes(searchLower) ||
|
||||
productName.toLowerCase().includes(searchLower) ||
|
||||
manufacturer.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const groupedItems = React.useMemo(() => {
|
||||
const groups = new Map<string, { items: StockItem[], totalQty: number, minStock: number, unit: string, productName: string, color: string, manufacturer: string }>();
|
||||
|
||||
filteredItems.forEach(item => {
|
||||
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Unknown';
|
||||
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
|
||||
const key = `${item.dataSheetId._id || item.dataSheetId}-${item.color}`;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
items: [],
|
||||
totalQty: 0,
|
||||
minStock: item.minStock || 0,
|
||||
unit: item.unit,
|
||||
productName,
|
||||
color: item.color || '-',
|
||||
manufacturer
|
||||
});
|
||||
}
|
||||
|
||||
const group = groups.get(key)!;
|
||||
group.items.push(item);
|
||||
group.totalQty += item.quantity;
|
||||
// Ensure we take the max minStock found if they differ (though backend enforces consistency)
|
||||
if (item.minStock && item.minStock > group.minStock) {
|
||||
group.minStock = item.minStock;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(groups.values());
|
||||
}, [filteredItems]);
|
||||
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
const newExpanded = new Set(expandedGroups);
|
||||
if (newExpanded.has(key)) {
|
||||
newExpanded.delete(key);
|
||||
} else {
|
||||
newExpanded.add(key);
|
||||
}
|
||||
setExpandedGroups(newExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-text-main flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary/20 flex items-center justify-center">
|
||||
<Package size={24} className="text-primary" />
|
||||
</div>
|
||||
Gestão de Estoque
|
||||
</h1>
|
||||
<p className="text-text-muted mt-2">Controle de Tintas e Diluentes</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handlePrintInventory}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border/40 text-text-main rounded-xl hover:bg-surface-hover transition-colors font-semibold"
|
||||
disabled={isPrintingInventory || items.length === 0}
|
||||
>
|
||||
<Printer size={20} />
|
||||
Inventário
|
||||
</button>
|
||||
{isAdmin() && activeTab === 'THINNER' && (
|
||||
<button
|
||||
onClick={() => setShowDiluentModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border/40 text-text-main rounded-xl hover:bg-surface-hover transition-colors font-semibold"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Cadastrar Diluente
|
||||
</button>
|
||||
)}
|
||||
{isAdmin() && (
|
||||
<button
|
||||
onClick={() => { setSelectedItem(null); setShowAddModal(true); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-semibold"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Nova Entrada
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border/40 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('PAINT')}
|
||||
className={`px-6 py-3 font-medium text-sm transition-colors border-b-2 ${activeTab === 'PAINT'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
Tintas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('THINNER')}
|
||||
className={`px-6 py-3 font-medium text-sm transition-colors border-b-2 ${activeTab === 'THINNER'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
Diluentes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por RR, Lote ou Produto..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-surface rounded-2xl border border-border/40 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-text-muted">Carregando...</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="p-8 text-center text-text-muted">Nenhum item encontrado.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-surface-soft border-b border-border/40">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase w-10"></th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Produto / Grupo</th>
|
||||
{activeTab === 'PAINT' && (
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Cor</th>
|
||||
)}
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Quantidade Total</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Lotes</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-bold text-text-muted uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{groupedItems.map((group, index) => {
|
||||
const groupKey = `${group.productName}-${group.color}-${index}`;
|
||||
const isExpanded = expandedGroups.has(groupKey);
|
||||
const isLowStock = group.minStock > 0 && group.totalQty < group.minStock;
|
||||
|
||||
return (
|
||||
<React.Fragment key={groupKey}>
|
||||
{/* Group Row */}
|
||||
<tr
|
||||
className={`hover:bg-surface-hover transition-colors cursor-pointer ${isExpanded ? 'bg-surface-hover/50' : ''}`}
|
||||
onClick={() => toggleGroup(groupKey)}
|
||||
>
|
||||
<td className="px-6 py-4 text-text-muted">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-main text-base">{group.productName}</span>
|
||||
<span className="text-xs text-text-muted">{group.manufacturer}</span>
|
||||
</div>
|
||||
</td>
|
||||
{activeTab === 'PAINT' && (
|
||||
<td className="px-6 py-4 text-text-secondary">{group.color}</td>
|
||||
)}
|
||||
<td className="px-6 py-4 font-bold text-lg">
|
||||
<span className={isLowStock ? 'text-red-500 animate-blink flex items-center gap-2' : 'text-green-500'}>
|
||||
{isLowStock && <AlertCircle size={16} />}
|
||||
{group.totalQty.toFixed(1)} {group.unit}
|
||||
</span>
|
||||
{group.minStock > 0 && (
|
||||
<span className="block text-[10px] text-text-muted font-normal">
|
||||
Mín: {group.minStock} {group.unit}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-text-secondary">
|
||||
{group.items.length} lote(s)
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{/* Actions if needed for group? */}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded Item Rows */}
|
||||
{isExpanded && group.items.map(item => {
|
||||
const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date();
|
||||
// Check individual item min stock for legacy reasons? No, rely on group.
|
||||
|
||||
return (
|
||||
<tr key={item._id} className="bg-surface-soft/50 hover:bg-surface-hover/80 transition-colors border-l-4 border-l-primary/20">
|
||||
<td className="px-6 py-3"></td> {/* Indentation */}
|
||||
<td className="px-6 py-3 font-mono text-xs text-text-muted">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>RR: <span className="text-text-main font-bold">{item.rrNumber}</span></div>
|
||||
{activeTab === 'THINNER' && (
|
||||
<div className="text-text-secondary">Lote: {item.batchNumber}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{activeTab === 'PAINT' && (
|
||||
<td className="px-6 py-3 text-xs text-text-secondary">
|
||||
Lote: {item.batchNumber}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-6 py-3 font-bold text-sm">
|
||||
{item.quantity} {item.unit}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{activeTab === 'PAINT' ? (
|
||||
item.expirationDate ? (
|
||||
<span className={`text-xs ${isExpired ? 'text-red-500 font-bold' : 'text-text-secondary'}`}>
|
||||
Val: {new Date(item.expirationDate).toLocaleDateString()}
|
||||
</span>
|
||||
) : '-'
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right flex justify-end gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
|
||||
className="p-1.5 text-text-secondary hover:bg-surface-hover rounded-lg"
|
||||
title="Histórico"
|
||||
>
|
||||
<History size={16} />
|
||||
</button>
|
||||
{isAdmin() && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleOut(item); }}
|
||||
className="p-1.5 text-amber-500 hover:bg-amber-500/10 rounded-lg"
|
||||
title="Realizar Baixa"
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
||||
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(item._id!); }}
|
||||
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddModal && (
|
||||
<StockModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setSelectedItem(null); }}
|
||||
onSuccess={() => { fetchItems(); setShowAddModal(false); setSelectedItem(null); }}
|
||||
initialData={selectedItem || undefined}
|
||||
initialType={activeTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOutModal && selectedItem && (
|
||||
<StockOutModal
|
||||
isOpen={showOutModal}
|
||||
onClose={() => { setShowOutModal(false); setSelectedItem(null); }}
|
||||
onSuccess={() => { fetchItems(); setShowOutModal(false); setSelectedItem(null); }}
|
||||
item={selectedItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showHistoryModal && selectedItem && (
|
||||
<StockHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => { setShowHistoryModal(false); setSelectedItem(null); }}
|
||||
item={selectedItem}
|
||||
onUpdate={fetchItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPrintingInventory && (
|
||||
<StockInventoryReport
|
||||
items={filteredItems}
|
||||
movements={allMovements}
|
||||
logoUrl={logoUrl}
|
||||
reportType={activeTab}
|
||||
/>
|
||||
)}
|
||||
{showDiluentModal && (
|
||||
<DiluentListModal
|
||||
isOpen={showDiluentModal}
|
||||
onClose={() => setShowDiluentModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1066
src/client/pages/YieldStudyDashboard.tsx
Normal file
1066
src/client/pages/YieldStudyDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user