refactor dashboard feature boundaries and add test setup
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
||||
import {getActiveOrderCount, getOrderItemQuantity, getProfileCompletion} from '../lib/orderSummary';
|
||||
|
||||
const completeProfile: CustomerProfile = {
|
||||
type: 'individual',
|
||||
phone: '09123456789',
|
||||
firstName: 'Sara',
|
||||
lastName: 'Ahmadi',
|
||||
email: 'sara@example.com',
|
||||
province: 'Razavi Khorasan',
|
||||
city: 'Mashhad',
|
||||
address: 'Main street',
|
||||
postalCode: '1234567890',
|
||||
preferredCallTime: '',
|
||||
companyName: '',
|
||||
nationalId: '',
|
||||
economicCode: '',
|
||||
};
|
||||
|
||||
const makeOrder = (status: CustomerOrder['status'], quantities: number[] = [1]): CustomerOrder => ({
|
||||
id: status,
|
||||
number: `RM-${status}`,
|
||||
createdAt: '1405/06/12',
|
||||
updatedAt: '1405/06/15',
|
||||
status,
|
||||
paymentStatus: 'not_payable',
|
||||
customer: completeProfile,
|
||||
items: quantities.map((quantity, index) => ({
|
||||
id: `item-${index}`,
|
||||
name: 'Robot Market vending machine',
|
||||
model: 'RM-48',
|
||||
image: '/images/machine-48-base.webp',
|
||||
quantity,
|
||||
unitPrice: 100,
|
||||
modules: [],
|
||||
})),
|
||||
events: [],
|
||||
});
|
||||
|
||||
describe('dashboard order summary helpers', () => {
|
||||
it('calculates required profile completion percentage', () => {
|
||||
expect(getProfileCompletion(completeProfile)).toBe(100);
|
||||
expect(getProfileCompletion({...completeProfile, city: '', postalCode: ''})).toBe(67);
|
||||
});
|
||||
|
||||
it('counts active orders and excludes terminal statuses', () => {
|
||||
const orders = [makeOrder('under_review'), makeOrder('delivered'), makeOrder('cancelled')];
|
||||
|
||||
expect(getActiveOrderCount(orders)).toBe(1);
|
||||
});
|
||||
|
||||
it('totals item quantities for an order card', () => {
|
||||
expect(getOrderItemQuantity(makeOrder('registered', [1, 2, 4]))).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {CheckCircleOutlined, ClockCircleOutlined, FileTextOutlined} from '@ant-design/icons';
|
||||
import {Progress, Skeleton} from 'antd';
|
||||
import {useAuth} from '@/app/context/AuthContext';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
import OrderCard from '@/components/dashboard/OrderCard';
|
||||
import {accountCopy} from '@/data/account';
|
||||
import {useCustomerOrders} from '../hooks/useCustomerOrders';
|
||||
import {getActiveOrderCount, getProfileCompletion} from '../lib/orderSummary';
|
||||
|
||||
export default function DashboardOverview() {
|
||||
const auth = useAuth();
|
||||
const {locale} = useLanguage();
|
||||
const copy = accountCopy[locale];
|
||||
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
|
||||
const profile = auth.session?.profile;
|
||||
const orders = useCustomerOrders(profile);
|
||||
|
||||
if (!profile || !orders) return <Skeleton active />;
|
||||
|
||||
const completion = getProfileCompletion(profile);
|
||||
const activeOrders = getActiveOrderCount(orders);
|
||||
const cards = [
|
||||
{
|
||||
label: copy.profileCompletion,
|
||||
value: `${completion.toLocaleString(numberLocale)}٪`,
|
||||
icon: <CheckCircleOutlined />,
|
||||
extra: <Progress percent={completion} showInfo={false} size="small" />,
|
||||
},
|
||||
{
|
||||
label: copy.activeOrders,
|
||||
value: activeOrders.toLocaleString(numberLocale),
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
{
|
||||
label: copy.totalOrders,
|
||||
value: orders.length.toLocaleString(numberLocale),
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-3xl font-extrabold text-primary">
|
||||
{copy.welcome} {profile.firstName}
|
||||
</h1>
|
||||
<p className="text-secondary mt-3">{copy.dashboardDescription}</p>
|
||||
<div className="grid sm:grid-cols-3 gap-4 mt-8">
|
||||
{cards.map(card => (
|
||||
<div key={card.label} className="rounded-2xl border border-subtle bg-elevated p-5">
|
||||
<div className="text-brand-400 text-2xl">{card.icon}</div>
|
||||
<p className="text-secondary text-sm mt-4">{card.label}</p>
|
||||
<strong className="text-2xl text-primary block mt-1">{card.value}</strong>
|
||||
{card.extra && <div className="mt-3">{card.extra}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 mt-10 mb-5">
|
||||
<h2 className="text-xl font-bold text-primary">{copy.recentOrders}</h2>
|
||||
<Link href="/dashboard/orders" className="text-brand-400">
|
||||
{copy.viewAll}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{orders.slice(0, 2).map(order => (
|
||||
<OrderCard key={order.id} order={order} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import {Empty, Skeleton} from 'antd';
|
||||
import {useAuth} from '@/app/context/AuthContext';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
import OrderCard from '@/components/dashboard/OrderCard';
|
||||
import {accountCopy} from '@/data/account';
|
||||
import {useCustomerOrders} from '../hooks/useCustomerOrders';
|
||||
|
||||
export default function OrdersList() {
|
||||
const auth = useAuth();
|
||||
const {locale} = useLanguage();
|
||||
const copy = accountCopy[locale];
|
||||
const profile = auth.session?.profile;
|
||||
const orders = useCustomerOrders(profile);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-3xl font-extrabold text-primary">{copy.orders}</h1>
|
||||
<p className="text-secondary mt-3 mb-7">{copy.dashboardDescription}</p>
|
||||
{!orders ? (
|
||||
<Skeleton active />
|
||||
) : orders.length ? (
|
||||
<div className="space-y-4">
|
||||
{orders.map(order => (
|
||||
<OrderCard key={order.id} order={order} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={copy.emptyOrders} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {getOrders} from '@/services/orders';
|
||||
import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
||||
|
||||
/**
|
||||
* Loads the signed-in customer's orders after the browser auth profile is available.
|
||||
*/
|
||||
export function useCustomerOrders(profile: CustomerProfile | undefined) {
|
||||
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
|
||||
let active = true;
|
||||
|
||||
getOrders(profile).then(nextOrders => {
|
||||
if (active) setOrders(nextOrders);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [profile]);
|
||||
|
||||
return orders;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
||||
|
||||
const COMPLETE_PROFILE_FIELDS: Array<keyof CustomerProfile> = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'province',
|
||||
'city',
|
||||
'address',
|
||||
'postalCode',
|
||||
];
|
||||
|
||||
const INACTIVE_ORDER_STATUSES = new Set<CustomerOrder['status']>(['delivered', 'cancelled']);
|
||||
|
||||
/**
|
||||
* Calculates how much of the required buyer profile is ready for order processing.
|
||||
*/
|
||||
export function getProfileCompletion(profile: CustomerProfile): number {
|
||||
const filledFields = COMPLETE_PROFILE_FIELDS.filter(field => Boolean(profile[field]));
|
||||
|
||||
return Math.round((filledFields.length / COMPLETE_PROFILE_FIELDS.length) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts orders that still need operational follow-up from the customer or sales team.
|
||||
*/
|
||||
export function getActiveOrderCount(orders: CustomerOrder[]): number {
|
||||
return orders.filter(order => !INACTIVE_ORDER_STATUSES.has(order.status)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts all units across an order, regardless of how many product lines it contains.
|
||||
*/
|
||||
export function getOrderItemQuantity(order: CustomerOrder): number {
|
||||
return order.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user