feat: Add detail pages for Companies and Tokens

- Created CompanyDetail page with stats, info, and tokens list
- Created TokenDetail page with click history and full tracking info
- Added routes for /companies/:id and /tokens/:id
- Made table rows clickable to navigate to detail pages
- Added edit, delete, and mail resend functionality
- Shows IP addresses, GeoIP location, device and browser info in click logs
This commit is contained in:
salvacybersec
2025-11-10 17:13:05 +03:00
parent 0e5dffb7fc
commit dc16d0c549
4 changed files with 681 additions and 1 deletions

View File

@@ -5,7 +5,9 @@ import Layout from './components/Layout/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Companies from './pages/Companies';
import CompanyDetail from './pages/CompanyDetail';
import Tokens from './pages/Tokens';
import TokenDetail from './pages/TokenDetail';
import Settings from './pages/Settings';
const theme = createTheme({
@@ -51,7 +53,9 @@ function App() {
>
<Route index element={<Dashboard />} />
<Route path="companies" element={<Companies />} />
<Route path="companies/:id" element={<CompanyDetail />} />
<Route path="tokens" element={<Tokens />} />
<Route path="tokens/:id" element={<TokenDetail />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>

View File

@@ -0,0 +1,333 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Button,
Paper,
Typography,
Grid,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
} from '@mui/material';
import {
ArrowBack,
Edit,
Delete,
Token as TokenIcon,
CheckCircle,
TrendingUp,
} from '@mui/icons-material';
import { companyService } from '../services/companyService';
import { format } from 'date-fns';
function CompanyDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [company, setCompany] = useState(null);
const [tokens, setTokens] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [editDialog, setEditDialog] = useState(false);
const [deleteDialog, setDeleteDialog] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
industry: '',
});
useEffect(() => {
loadData();
}, [id]);
const loadData = async () => {
try {
const [companyData, tokensData, statsData] = await Promise.all([
companyService.getById(id),
companyService.getTokens(id),
companyService.getStats(id),
]);
setCompany(companyData.data);
setTokens(tokensData.data);
setStats(statsData.data);
setFormData({
name: companyData.data.name,
description: companyData.data.description || '',
industry: companyData.data.industry || '',
});
} catch (error) {
console.error('Failed to load company:', error);
alert('Şirket yüklenemedi');
navigate('/companies');
} finally {
setLoading(false);
}
};
const handleUpdate = async () => {
try {
await companyService.update(id, formData);
setEditDialog(false);
loadData();
} catch (error) {
console.error('Failed to update company:', error);
alert('Şirket güncellenemedi');
}
};
const handleDelete = async () => {
try {
await companyService.delete(id);
navigate('/companies');
} catch (error) {
console.error('Failed to delete company:', error);
alert('Şirket silinemedi: Önce tokenları silmelisiniz');
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
);
}
return (
<Box>
{/* Header */}
<Box display="flex" alignItems="center" mb={3}>
<IconButton onClick={() => navigate('/companies')} sx={{ mr: 2 }}>
<ArrowBack />
</IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>
{company.name}
</Typography>
<Button
startIcon={<Edit />}
onClick={() => setEditDialog(true)}
sx={{ mr: 1 }}
>
Düzenle
</Button>
<Button
startIcon={<Delete />}
color="error"
onClick={() => setDeleteDialog(true)}
>
Sil
</Button>
</Box>
{/* Stats Cards */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Typography color="textSecondary" variant="body2">
Toplam Token
</Typography>
<Typography variant="h4">{stats.total_tokens}</Typography>
</Box>
<TokenIcon color="primary" sx={{ fontSize: 40 }} />
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Typography color="textSecondary" variant="body2">
Toplam Tıklama
</Typography>
<Typography variant="h4">{stats.total_clicks}</Typography>
</Box>
<CheckCircle color="success" sx={{ fontSize: 40 }} />
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Typography color="textSecondary" variant="body2">
Başarı Oranı
</Typography>
<Typography variant="h4">{stats.click_rate}%</Typography>
</Box>
<TrendingUp
color={stats.click_rate > 30 ? 'error' : 'warning'}
sx={{ fontSize: 40 }}
/>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Company Info */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Şirket Bilgileri
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">
Sektör
</Typography>
<Typography variant="body1">
{company.industry || 'Belirtilmemiş'}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">
Oluşturulma Tarihi
</Typography>
<Typography variant="body1">
{format(new Date(company.created_at), 'dd/MM/yyyy HH:mm')}
</Typography>
</Grid>
{company.description && (
<Grid item xs={12}>
<Typography variant="body2" color="textSecondary">
ıklama
</Typography>
<Typography variant="body1">{company.description}</Typography>
</Grid>
)}
</Grid>
</Paper>
{/* Tokens Table */}
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Tokenlar ({tokens.length})
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Çalışan</TableCell>
<TableCell>Durum</TableCell>
<TableCell align="right">Tıklama</TableCell>
<TableCell>Tarih</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tokens.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography color="textSecondary">
Henüz token oluşturulmamış
</Typography>
</TableCell>
</TableRow>
) : (
tokens.map((token) => (
<TableRow key={token.id} hover>
<TableCell>{token.target_email}</TableCell>
<TableCell>{token.employee_name || '-'}</TableCell>
<TableCell>
<Chip
label={token.clicked ? 'Tıklandı' : 'Bekliyor'}
color={token.clicked ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell align="right">{token.click_count}×</TableCell>
<TableCell>
{format(new Date(token.created_at), 'dd/MM/yyyy HH:mm')}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
{/* Edit Dialog */}
<Dialog open={editDialog} onClose={() => setEditDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Şirket Düzenle</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Şirket Adı"
fullWidth
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<TextField
margin="dense"
label="Açıklama"
fullWidth
multiline
rows={2}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
<TextField
margin="dense"
label="Sektör"
fullWidth
value={formData.industry}
onChange={(e) => setFormData({ ...formData, industry: e.target.value })}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditDialog(false)}>İptal</Button>
<Button onClick={handleUpdate} variant="contained">
Güncelle
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={deleteDialog} onClose={() => setDeleteDialog(false)}>
<DialogTitle>Şirketi Sil?</DialogTitle>
<DialogContent>
<Typography>
<strong>{company.name}</strong> şirketini silmek istediğinizden emin misiniz?
Bu işlem geri alınamaz.
</Typography>
{tokens.length > 0 && (
<Typography color="error" sx={{ mt: 2 }}>
Bu şirkete ait {tokens.length} token var. Önce tokenları silmelisiniz.
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialog(false)}>İptal</Button>
<Button onClick={handleDelete} color="error" variant="contained">
Sil
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default CompanyDetail;

View File

@@ -0,0 +1,336 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Button,
Paper,
Typography,
Grid,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
CircularProgress,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import {
ArrowBack,
Delete,
Send,
LocationOn,
Computer,
AccessTime,
CheckCircle,
Cancel,
} from '@mui/icons-material';
import { tokenService } from '../services/tokenService';
import { format } from 'date-fns';
function TokenDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [token, setToken] = useState(null);
const [clicks, setClicks] = useState([]);
const [loading, setLoading] = useState(true);
const [deleteDialog, setDeleteDialog] = useState(false);
const [sendingMail, setSendingMail] = useState(false);
useEffect(() => {
loadData();
}, [id]);
const loadData = async () => {
try {
const [tokenData, clicksData] = await Promise.all([
tokenService.getById(id),
tokenService.getClicks(id),
]);
setToken(tokenData.data);
setClicks(clicksData.data);
} catch (error) {
console.error('Failed to load token:', error);
alert('Token yüklenemedi');
navigate('/tokens');
} finally {
setLoading(false);
}
};
const handleSendMail = async () => {
setSendingMail(true);
try {
await tokenService.sendMail(id);
alert('Mail başarıyla gönderildi!');
} catch (error) {
console.error('Failed to send mail:', error);
alert('Mail gönderilemedi: ' + (error.response?.data?.error || error.message));
} finally {
setSendingMail(false);
}
};
const handleDelete = async () => {
try {
await tokenService.delete(id);
navigate('/tokens');
} catch (error) {
console.error('Failed to delete token:', error);
alert('Token silinemedi');
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
);
}
return (
<Box>
{/* Header */}
<Box display="flex" alignItems="center" mb={3}>
<IconButton onClick={() => navigate('/tokens')} sx={{ mr: 2 }}>
<ArrowBack />
</IconButton>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h5">{token.target_email}</Typography>
<Typography variant="body2" color="textSecondary">
{token.company?.name} - {token.employee_name || 'İsimsiz'}
</Typography>
</Box>
<Button
startIcon={<Send />}
onClick={handleSendMail}
disabled={sendingMail}
sx={{ mr: 1 }}
>
{sendingMail ? 'Gönderiliyor...' : 'Mail Gönder'}
</Button>
<Button
startIcon={<Delete />}
color="error"
onClick={() => setDeleteDialog(true)}
>
Sil
</Button>
</Box>
{/* Stats Cards */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} sm={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" gap={2}>
{token.clicked ? (
<CheckCircle color="success" sx={{ fontSize: 40 }} />
) : (
<Cancel color="disabled" sx={{ fontSize: 40 }} />
)}
<Box>
<Typography color="textSecondary" variant="body2">
Durum
</Typography>
<Typography variant="h6">
{token.clicked ? 'Tıklandı' : 'Bekliyor'}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" gap={2}>
<AccessTime color="primary" sx={{ fontSize: 40 }} />
<Box>
<Typography color="textSecondary" variant="body2">
Tıklama Sayısı
</Typography>
<Typography variant="h6">{token.click_count}×</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={3}>
<Card>
<CardContent>
<Box>
<Typography color="textSecondary" variant="body2" gutterBottom>
Oluşturulma
</Typography>
<Typography variant="body1">
{format(new Date(token.created_at), 'dd/MM/yyyy')}
</Typography>
<Typography variant="body2" color="textSecondary">
{format(new Date(token.created_at), 'HH:mm')}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={3}>
<Card>
<CardContent>
<Box>
<Typography color="textSecondary" variant="body2" gutterBottom>
İlk Tıklama
</Typography>
{token.first_clicked_at ? (
<>
<Typography variant="body1">
{format(new Date(token.first_clicked_at), 'dd/MM/yyyy')}
</Typography>
<Typography variant="body2" color="textSecondary">
{format(new Date(token.first_clicked_at), 'HH:mm')}
</Typography>
</>
) : (
<Typography variant="body1" color="textSecondary">
Henüz tıklanmadı
</Typography>
)}
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Token Info */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Token Bilgileri
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">
Token
</Typography>
<Typography variant="body1" sx={{ fontFamily: 'monospace' }}>
{token.token}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">
Tracking URL
</Typography>
<Typography
variant="body1"
sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}
>
{`${window.location.origin.replace('5173', '3000')}/t/${token.token}`}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">
Şablon Tipi
</Typography>
<Typography variant="body1">
{token.template_type || 'Belirtilmemiş'}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">
Şirket
</Typography>
<Typography variant="body1">{token.company?.name}</Typography>
</Grid>
</Grid>
</Paper>
{/* Click History */}
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Tıklama Geçmişi ({clicks.length})
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Zaman</TableCell>
<TableCell>IP Adresi</TableCell>
<TableCell>Konum</TableCell>
<TableCell>Cihaz</TableCell>
<TableCell>Tarayıcı</TableCell>
</TableRow>
</TableHead>
<TableBody>
{clicks.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography color="textSecondary">
Henüz tıklama kaydı yok
</Typography>
</TableCell>
</TableRow>
) : (
clicks.map((click) => (
<TableRow key={click.id} hover>
<TableCell>
{format(new Date(click.clicked_at), 'dd/MM/yyyy HH:mm:ss')}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
{click.ip_address}
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={0.5}>
<LocationOn fontSize="small" color="action" />
{click.city && click.country
? `${click.city}, ${click.country}`
: 'Bilinmiyor'}
</Box>
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={0.5}>
<Computer fontSize="small" color="action" />
{click.device || 'Bilinmiyor'}
</Box>
</TableCell>
<TableCell>{click.browser || 'Bilinmiyor'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
{/* Delete Confirmation */}
<Dialog open={deleteDialog} onClose={() => setDeleteDialog(false)}>
<DialogTitle>Token'ı Sil?</DialogTitle>
<DialogContent>
<Typography>
<strong>{token.target_email}</strong> için oluşturulan bu token'ı silmek
istediğinizden emin misiniz? Bu işlem geri alınamaz.
</Typography>
{clicks.length > 0 && (
<Typography color="warning.main" sx={{ mt: 2 }}>
Bu token'a ait {clicks.length} tıklama kaydı da silinecek.
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialog(false)}>İptal</Button>
<Button onClick={handleDelete} color="error" variant="contained">
Sil
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default TokenDetail;

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Button,
@@ -26,6 +27,7 @@ import { templateService } from '../services/templateService';
import { format } from 'date-fns';
function Tokens() {
const navigate = useNavigate();
const [tokens, setTokens] = useState([]);
const [companies, setCompanies] = useState([]);
const [templates, setTemplates] = useState([]);
@@ -107,7 +109,12 @@ function Tokens() {
</TableHead>
<TableBody>
{tokens.map((token) => (
<TableRow key={token.id} hover sx={{ cursor: 'pointer' }}>
<TableRow
key={token.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/tokens/${token.id}`)}
>
<TableCell>{token.target_email}</TableCell>
<TableCell>{token.company?.name}</TableCell>
<TableCell>{token.employee_name || '-'}</TableCell>