build: b0d8399f-81d7-4ccc-8711-0f8b1506676e
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonIcon,
|
||||
} from '@ionic/react';
|
||||
import { documentTextOutline, addOutline, searchOutline } from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
import { useProfile } from '../contexts/ProfileContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import DashboardMetricCard from '../components/DashboardMetricCard';
|
||||
import RecentActivityCard from '../components/RecentActivityCard';
|
||||
import { Database } from '../database.types';
|
||||
|
||||
type RfqRow = Database['public']['Tables']['rfqs']['Row'];
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const { profile } = useProfile();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [rfqSummary, setRfqSummary] = useState({
|
||||
open: 0,
|
||||
quoted: 0,
|
||||
closed: 0,
|
||||
});
|
||||
const [supplierSummary, setSupplierSummary] = useState({
|
||||
assignedOpen: 0,
|
||||
myQuotes: 0,
|
||||
accepted: 0,
|
||||
});
|
||||
const [recentItems, setRecentItems] = useState<any[]>([]);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
loadDashboardData();
|
||||
});
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
if (!user || !profile) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (profile.role === 'buyer') {
|
||||
// Aggregate counts
|
||||
const { data: allRfqs, error: countErr } = await supabase
|
||||
.from('rfqs')
|
||||
.select('id, status')
|
||||
.eq('buyer_id', user.id);
|
||||
|
||||
if (countErr) throw countErr;
|
||||
|
||||
let open = 0,
|
||||
quoted = 0,
|
||||
closed = 0;
|
||||
allRfqs?.forEach((r) => {
|
||||
if (r.status === 'open') open++;
|
||||
else if (r.status === 'quoted') quoted++;
|
||||
else if (r.status === 'closed') closed++;
|
||||
});
|
||||
setRfqSummary({ open, quoted, closed });
|
||||
|
||||
// Recent items
|
||||
const { data: recent, error: recentErr } = await supabase
|
||||
.from('rfqs')
|
||||
.select('*')
|
||||
.eq('buyer_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
if (recentErr) throw recentErr;
|
||||
setRecentItems(recent || []);
|
||||
} else {
|
||||
// Supplier
|
||||
const { data: allRecipients, error: recErr } = await supabase
|
||||
.from('rfq_recipients')
|
||||
.select('id, rfqs!inner(id, status)')
|
||||
.eq('supplier_id', user.id);
|
||||
if (recErr) throw recErr;
|
||||
|
||||
let assignedOpen = 0;
|
||||
allRecipients?.forEach((r: any) => {
|
||||
if (r.rfqs && r.rfqs.status === 'open') assignedOpen++;
|
||||
});
|
||||
|
||||
const { data: myQuotes, error: qErr } = await supabase
|
||||
.from('quotations')
|
||||
.select('id, status')
|
||||
.eq('supplier_id', user.id);
|
||||
if (qErr) throw qErr;
|
||||
|
||||
let accepted = 0;
|
||||
myQuotes?.forEach((q) => {
|
||||
if (q.status === 'accepted') accepted++;
|
||||
});
|
||||
setSupplierSummary({
|
||||
assignedOpen,
|
||||
myQuotes: myQuotes?.length || 0,
|
||||
accepted,
|
||||
});
|
||||
|
||||
const { data: recent, error: recentErr } = await supabase
|
||||
.from('rfq_recipients')
|
||||
.select('id, rfqs!inner(*)')
|
||||
.eq('supplier_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
if (recentErr) throw recentErr;
|
||||
|
||||
// Map joined data
|
||||
const mapped = (recent || []).map((r: any) => r.rfqs);
|
||||
setRecentItems(mapped);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError('Failed to load dashboard data.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (profile?.role === 'buyer') {
|
||||
history.push('/rfq/new');
|
||||
} else {
|
||||
history.push('/requests');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecentItemTap = (item: RfqRow) => {
|
||||
history.push(`/rfq/${item.id}`);
|
||||
};
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: 'var(--color-bg)' }}>
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': 'var(--color-bg-gradient)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': 'calc(96px + var(--ion-safe-area-bottom))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 'calc(16px + var(--ion-safe-area-top, 0px)) 20px 16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
QuoteLink
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
Procurement made simpler
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => history.push('/profile')}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--color-brand-medium)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-brand)',
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{profile.full_name.charAt(0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px 18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '5px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '26px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: '1.08',
|
||||
}}
|
||||
>
|
||||
Good to see you,{' '}
|
||||
<span style={{ fontWeight: '700' }}>
|
||||
{profile.full_name.split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{profile.role === 'buyer'
|
||||
? 'Track requests, review supplier pricing, and keep decisions moving.'
|
||||
: 'Review assigned RFQs, send pricing fast, and stay on top of wins.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px 12px',
|
||||
background: 'var(--color-danger-soft)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--color-danger-text)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-brand-strong)',
|
||||
borderRadius: '24px',
|
||||
padding: '22px',
|
||||
margin: '0 20px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={handlePrimaryAction}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{profile.role === 'buyer' ? 'Active Requests' : 'Assigned RFQs'}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '34px',
|
||||
fontWeight: '400',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
{profile.role === 'buyer'
|
||||
? rfqSummary.open + rfqSummary.quoted
|
||||
: supplierSummary.assignedOpen}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '14px',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={profile.role === 'buyer' ? addOutline : searchOutline}
|
||||
style={{ color: '#ffffff', fontSize: '24px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
{profile.role === 'buyer'
|
||||
? 'Create new request →'
|
||||
: 'Browse requests →'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', margin: '0 20px 24px' }}>
|
||||
{profile.role === 'buyer' ? (
|
||||
<>
|
||||
<DashboardMetricCard
|
||||
label="Received Quotes"
|
||||
value={rfqSummary.quoted.toString()}
|
||||
hint="Awaiting decision"
|
||||
icon={documentTextOutline}
|
||||
/>
|
||||
<DashboardMetricCard
|
||||
label="Completed"
|
||||
value={rfqSummary.closed.toString()}
|
||||
hint="Past requests"
|
||||
icon={documentTextOutline}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DashboardMetricCard
|
||||
label="My Quotes"
|
||||
value={supplierSummary.myQuotes.toString()}
|
||||
hint="Submitted"
|
||||
icon={documentTextOutline}
|
||||
/>
|
||||
<DashboardMetricCard
|
||||
label="Accepted"
|
||||
value={supplierSummary.accepted.toString()}
|
||||
hint="Won bids"
|
||||
icon={documentTextOutline}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
margin: '16px 20px 8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Recent Activity
|
||||
</div>
|
||||
<div
|
||||
onClick={() => history.push('/requests')}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-brand)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
See all
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{ color: 'var(--color-brand)' }}
|
||||
/>
|
||||
</div>
|
||||
) : recentItems.length > 0 ? (
|
||||
recentItems.map((item) => (
|
||||
<RecentActivityCard
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
subtitle={new Date(item.created_at).toLocaleDateString()}
|
||||
meta={item.status}
|
||||
onClick={() => handleRecentItemTap(item)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={documentTextOutline}
|
||||
style={{ fontSize: '48px', color: 'var(--color-text-tertiary)' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No recent activity yet.
|
||||
</div>
|
||||
{profile.role === 'buyer' && (
|
||||
<button
|
||||
onClick={handlePrimaryAction}
|
||||
style={{
|
||||
background: 'var(--color-brand)',
|
||||
color: '#ffffff',
|
||||
borderRadius: '999px',
|
||||
padding: '10px 20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
Create Request
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
Reference in New Issue
Block a user