944 lines
42 KiB
TypeScript
944 lines
42 KiB
TypeScript
import {
|
||
mdiAccountTie,
|
||
mdiArrowRight,
|
||
mdiBriefcaseOutline,
|
||
mdiChartTimelineVariant,
|
||
mdiCheckCircle,
|
||
mdiClockOutline,
|
||
mdiCurrencyUsd,
|
||
mdiServer,
|
||
mdiStarOutline,
|
||
} from '@mdi/js';
|
||
import axios from 'axios';
|
||
import { Field, Form, Formik } from 'formik';
|
||
import Head from 'next/head';
|
||
import Link from 'next/link';
|
||
import React, { ReactElement, ReactNode, useEffect, useState } from 'react';
|
||
|
||
import BaseButton from '../components/BaseButton';
|
||
import BaseIcon from '../components/BaseIcon';
|
||
import CardBox from '../components/CardBox';
|
||
import FormField from '../components/FormField';
|
||
import SectionMain from '../components/SectionMain';
|
||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||
import { SelectField } from '../components/SelectField';
|
||
import { SelectFieldMany } from '../components/SelectFieldMany';
|
||
import { SwitchField } from '../components/SwitchField';
|
||
import { getPageTitle } from '../config';
|
||
import { hasPermission } from '../helpers/userPermissions';
|
||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||
import { useAppSelector } from '../stores/hooks';
|
||
|
||
type MetricCardProps = {
|
||
accent: string;
|
||
helper: string;
|
||
icon: string;
|
||
label: string;
|
||
value: string | number;
|
||
};
|
||
|
||
type MatchItem = {
|
||
availability?: string;
|
||
experience_level?: string;
|
||
freelancer_name?: string;
|
||
headline?: string;
|
||
hourly_rate?: string | number;
|
||
id: string;
|
||
is_vetted?: boolean;
|
||
matchReasons?: string[];
|
||
matchScore?: number;
|
||
matchedSkills?: string[];
|
||
rate_currency?: string;
|
||
summary?: string;
|
||
verification_status?: string;
|
||
years_experience?: number;
|
||
};
|
||
|
||
type MatchResponse = {
|
||
matchPreview?: {
|
||
matches?: MatchItem[];
|
||
meta?: {
|
||
criteriaCount?: number;
|
||
requiredSkills?: string[];
|
||
};
|
||
project?: {
|
||
id?: string;
|
||
project_title?: string;
|
||
status?: string;
|
||
};
|
||
};
|
||
project?: {
|
||
budget_currency?: string;
|
||
budget_max?: string | number;
|
||
budget_min?: string | number;
|
||
company?: {
|
||
company_name?: string;
|
||
};
|
||
erp_system?: {
|
||
system_name?: string;
|
||
};
|
||
id?: string;
|
||
project_title?: string;
|
||
status?: string;
|
||
};
|
||
};
|
||
|
||
type ProposalSummary = {
|
||
currency?: string;
|
||
freelancer_profile?: {
|
||
headline?: string;
|
||
user?: {
|
||
email?: string;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
};
|
||
};
|
||
id: string;
|
||
project?: {
|
||
project_title?: string;
|
||
};
|
||
proposed_amount?: string | number;
|
||
status?: string;
|
||
};
|
||
|
||
type ProjectSummary = {
|
||
budget_currency?: string;
|
||
budget_max?: string | number;
|
||
budget_min?: string | number;
|
||
company?: {
|
||
company_name?: string;
|
||
};
|
||
createdAt?: string;
|
||
erp_system?: {
|
||
system_name?: string;
|
||
};
|
||
id: string;
|
||
project_title?: string;
|
||
project_type?: string;
|
||
status?: string;
|
||
};
|
||
|
||
type SectionCardProps = {
|
||
children: ReactNode;
|
||
className?: string;
|
||
description?: string;
|
||
title: string;
|
||
};
|
||
|
||
const initialValues = {
|
||
budget_currency: 'USD',
|
||
budget_max: '',
|
||
budget_min: '',
|
||
company: '',
|
||
desired_start_at: '',
|
||
engagement_model: 'fixed_price',
|
||
erp_system: '',
|
||
estimated_hours: '',
|
||
location_preference: '',
|
||
project_description: '',
|
||
project_title: '',
|
||
project_type: 'implementation',
|
||
remote_ok: true,
|
||
skillIds: [],
|
||
};
|
||
|
||
const statusClasses: Record<string, string> = {
|
||
accepted: 'bg-emerald-100 text-emerald-700',
|
||
architect: 'bg-violet-100 text-violet-700',
|
||
available_now: 'bg-emerald-100 text-emerald-700',
|
||
available_soon: 'bg-amber-100 text-amber-700',
|
||
completed: 'bg-emerald-100 text-emerald-700',
|
||
in_progress: 'bg-sky-100 text-sky-700',
|
||
in_review: 'bg-indigo-100 text-indigo-700',
|
||
interview: 'bg-sky-100 text-sky-700',
|
||
not_available: 'bg-slate-100 text-slate-600',
|
||
pending: 'bg-amber-100 text-amber-700',
|
||
published: 'bg-sky-100 text-sky-700',
|
||
rejected: 'bg-rose-100 text-rose-700',
|
||
shortlisted: 'bg-violet-100 text-violet-700',
|
||
submitted: 'bg-sky-100 text-sky-700',
|
||
verified: 'bg-emerald-100 text-emerald-700',
|
||
};
|
||
|
||
const prettifyLabel = (value?: string | null) => {
|
||
if (!value) return 'Not set';
|
||
|
||
return value
|
||
.replace(/_/g, ' ')
|
||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||
};
|
||
|
||
const formatMoneyRange = (
|
||
minimum?: string | number,
|
||
maximum?: string | number,
|
||
currency = 'USD',
|
||
) => {
|
||
if (!minimum && !maximum) return 'Budget on request';
|
||
|
||
const formatter = new Intl.NumberFormat('en-US', {
|
||
currency,
|
||
maximumFractionDigits: 0,
|
||
style: 'currency',
|
||
});
|
||
|
||
if (minimum && maximum) {
|
||
return `${formatter.format(Number(minimum))} – ${formatter.format(Number(maximum))}`;
|
||
}
|
||
|
||
if (maximum) {
|
||
return `Up to ${formatter.format(Number(maximum))}`;
|
||
}
|
||
|
||
return `From ${formatter.format(Number(minimum))}`;
|
||
};
|
||
|
||
const validateForm = (values: typeof initialValues) => {
|
||
const errors: Partial<Record<keyof typeof initialValues, string>> = {};
|
||
|
||
if (!values.project_title.trim()) {
|
||
errors.project_title = 'Project title is required.';
|
||
}
|
||
|
||
if (!values.project_description.trim()) {
|
||
errors.project_description = 'Describe the ERP scope and outcome you need.';
|
||
}
|
||
|
||
if (values.budget_min && Number.isNaN(Number(values.budget_min))) {
|
||
errors.budget_min = 'Budget must be a number.';
|
||
}
|
||
|
||
if (values.budget_max && Number.isNaN(Number(values.budget_max))) {
|
||
errors.budget_max = 'Budget must be a number.';
|
||
}
|
||
|
||
if (
|
||
values.budget_min &&
|
||
values.budget_max &&
|
||
Number(values.budget_min) > Number(values.budget_max)
|
||
) {
|
||
errors.budget_max = 'Budget max must be greater than or equal to budget min.';
|
||
}
|
||
|
||
if (values.estimated_hours && Number.isNaN(Number(values.estimated_hours))) {
|
||
errors.estimated_hours = 'Estimated hours must be a number.';
|
||
}
|
||
|
||
return errors;
|
||
};
|
||
|
||
const MetricCard = ({ accent, helper, icon, label, value }: MetricCardProps) => (
|
||
<CardBox className='h-full border border-slate-200/80'>
|
||
<div className='flex items-start justify-between gap-4'>
|
||
<div>
|
||
<p className='text-sm font-semibold uppercase tracking-[0.16em] text-slate-400'>
|
||
{label}
|
||
</p>
|
||
<p className='mt-3 text-3xl font-semibold tracking-tight text-slate-900'>
|
||
{value}
|
||
</p>
|
||
<p className='mt-2 text-sm leading-6 text-slate-500'>{helper}</p>
|
||
</div>
|
||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl ${accent}`}>
|
||
<BaseIcon path={icon} size={26} className='text-white' />
|
||
</div>
|
||
</div>
|
||
</CardBox>
|
||
);
|
||
|
||
const SectionCard = ({ children, className = '', description, title }: SectionCardProps) => (
|
||
<CardBox className={`border border-slate-200/80 ${className}`}>
|
||
<div className='mb-6'>
|
||
<h2 className='text-xl font-semibold tracking-tight text-slate-900'>{title}</h2>
|
||
{description ? <p className='mt-2 text-sm leading-6 text-slate-500'>{description}</p> : null}
|
||
</div>
|
||
{children}
|
||
</CardBox>
|
||
);
|
||
|
||
const ErpMatchDeskPage = () => {
|
||
const { currentUser } = useAppSelector((state) => state.auth);
|
||
|
||
const [metrics, setMetrics] = useState({
|
||
experts: '—',
|
||
projects: '—',
|
||
proposals: '—',
|
||
});
|
||
const [recentProjects, setRecentProjects] = useState<ProjectSummary[]>([]);
|
||
const [recentProposals, setRecentProposals] = useState<ProposalSummary[]>([]);
|
||
const [loadingWorkspace, setLoadingWorkspace] = useState(true);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [formMessage, setFormMessage] = useState<{ tone: 'error' | 'success'; text: string } | null>(null);
|
||
const [intakeResult, setIntakeResult] = useState<MatchResponse | null>(null);
|
||
|
||
const canReadProjects = hasPermission(currentUser, 'READ_PROJECTS');
|
||
const canReadProposals = hasPermission(currentUser, 'READ_PROPOSALS');
|
||
const canReadExperts = hasPermission(currentUser, 'READ_FREELANCER_PROFILES');
|
||
|
||
const loadWorkspace = async () => {
|
||
if (!currentUser) {
|
||
return;
|
||
}
|
||
|
||
setLoadingWorkspace(true);
|
||
|
||
try {
|
||
const [projectsCount, proposalsCount, expertsCount, projectsResponse, proposalsResponse] = await Promise.all([
|
||
canReadProjects ? axios.get('/projects/count').catch(() => null) : Promise.resolve(null),
|
||
canReadProposals ? axios.get('/proposals/count').catch(() => null) : Promise.resolve(null),
|
||
canReadExperts ? axios.get('/freelancer_profiles/count').catch(() => null) : Promise.resolve(null),
|
||
canReadProjects
|
||
? axios.get('/projects?page=0&limit=4&sort=desc&field=createdAt').catch(() => null)
|
||
: Promise.resolve(null),
|
||
canReadProposals
|
||
? axios.get('/proposals?page=0&limit=4&sort=desc&field=createdAt').catch(() => null)
|
||
: Promise.resolve(null),
|
||
]);
|
||
|
||
setMetrics({
|
||
experts: expertsCount?.data?.count ?? '—',
|
||
projects: projectsCount?.data?.count ?? '—',
|
||
proposals: proposalsCount?.data?.count ?? '—',
|
||
});
|
||
setRecentProjects(Array.isArray(projectsResponse?.data?.rows) ? projectsResponse.data.rows : []);
|
||
setRecentProposals(Array.isArray(proposalsResponse?.data?.rows) ? proposalsResponse.data.rows : []);
|
||
} finally {
|
||
setLoadingWorkspace(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadWorkspace();
|
||
}, [currentUser]);
|
||
|
||
const submitIntake = async (
|
||
values: typeof initialValues,
|
||
resetForm: () => void,
|
||
) => {
|
||
setSubmitting(true);
|
||
setFormMessage(null);
|
||
|
||
try {
|
||
const payload = {
|
||
...values,
|
||
skillIds: values.skillIds,
|
||
};
|
||
|
||
const response = await axios.post('/projects/concierge-intake', payload);
|
||
|
||
setIntakeResult(response.data);
|
||
setFormMessage({
|
||
text: 'Project intake captured. Match preview is ready for your review.',
|
||
tone: 'success',
|
||
});
|
||
resetForm();
|
||
await loadWorkspace();
|
||
} catch (error) {
|
||
const text =
|
||
axios.isAxiosError(error) && typeof error.response?.data === 'string'
|
||
? error.response.data
|
||
: 'We could not create the project intake. Please review the form and try again.';
|
||
|
||
setFormMessage({ text, tone: 'error' });
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const matchCount = intakeResult?.matchPreview?.matches?.length ?? 0;
|
||
|
||
return (
|
||
<>
|
||
<Head>
|
||
<title>{getPageTitle('ERP Match Desk')}</title>
|
||
</Head>
|
||
<SectionMain>
|
||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='ERP Match Desk' main>
|
||
<BaseButton color='info' href='/projects/projects-list' label='Open projects list' />
|
||
</SectionTitleLineWithButton>
|
||
|
||
<div className='mb-6 overflow-hidden rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-900 px-8 py-8 text-white shadow-sm'>
|
||
<div className='grid gap-8 lg:grid-cols-[1.5fr,1fr] lg:items-center'>
|
||
<div>
|
||
<div className='inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-cyan-100'>
|
||
<span className='h-2 w-2 rounded-full bg-emerald-400' />
|
||
First MVP workflow
|
||
</div>
|
||
<h1 className='mt-5 max-w-3xl text-4xl font-semibold tracking-tight md:text-5xl'>
|
||
Post an ERP project and get a qualified expert shortlist in one flow.
|
||
</h1>
|
||
<p className='mt-4 max-w-2xl text-base leading-7 text-slate-200 md:text-lg'>
|
||
This desk turns the seed app into a real marketplace workflow: capture a client brief,
|
||
register required ERP skills, and immediately preview freelancers worth shortlisting.
|
||
</p>
|
||
<div className='mt-8 flex flex-wrap gap-3 text-sm text-slate-100'>
|
||
{['Client intake', 'Skills capture', 'Expert preview', 'Recent pipeline visibility'].map((item) => (
|
||
<span key={item} className='rounded-full border border-white/15 bg-white/10 px-4 py-2'>
|
||
{item}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className='grid gap-4'>
|
||
<div className='rounded-3xl border border-white/10 bg-white/10 p-5 backdrop-blur'>
|
||
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-cyan-100'>
|
||
Guided flow
|
||
</p>
|
||
<div className='mt-4 space-y-4'>
|
||
{[
|
||
{
|
||
body: 'Capture the ERP, budget, location, and engagement model.',
|
||
title: '1. Intake project scope',
|
||
},
|
||
{
|
||
body: 'Attach the important skills so matching becomes meaningful.',
|
||
title: '2. Add skill requirements',
|
||
},
|
||
{
|
||
body: 'Review vetted freelancers and continue into the proposal pipeline.',
|
||
title: '3. Shortlist faster',
|
||
},
|
||
].map((step) => (
|
||
<div key={step.title} className='flex gap-3 rounded-2xl border border-white/10 bg-slate-900/30 p-4'>
|
||
<div className='mt-1 flex h-8 w-8 items-center justify-center rounded-xl bg-white/15 text-sm font-semibold'>
|
||
<BaseIcon path={mdiCheckCircle} size={18} className='text-emerald-300' />
|
||
</div>
|
||
<div>
|
||
<p className='font-semibold'>{step.title}</p>
|
||
<p className='mt-1 text-sm leading-6 text-slate-200'>{step.body}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='mb-6 grid grid-cols-1 gap-6 xl:grid-cols-3'>
|
||
<MetricCard
|
||
accent='bg-slate-900'
|
||
helper='Projects currently tracked inside your ERP delivery pipeline.'
|
||
icon={mdiBriefcaseOutline}
|
||
label='Projects'
|
||
value={loadingWorkspace ? '…' : metrics.projects}
|
||
/>
|
||
<MetricCard
|
||
accent='bg-cyan-700'
|
||
helper='Proposals already moving through review, interview, or shortlist stages.'
|
||
icon={mdiStarOutline}
|
||
label='Proposals'
|
||
value={loadingWorkspace ? '…' : metrics.proposals}
|
||
/>
|
||
<MetricCard
|
||
accent='bg-violet-700'
|
||
helper='Freelancer profiles you can evaluate when building shortlists.'
|
||
icon={mdiAccountTie}
|
||
label='Experts'
|
||
value={loadingWorkspace ? '…' : metrics.experts}
|
||
/>
|
||
</div>
|
||
|
||
<div className='grid gap-6 xl:grid-cols-[1.35fr,0.95fr]'>
|
||
<SectionCard
|
||
title='Client intake'
|
||
description='Capture the minimum information needed to kick off matching. ERP system and skills are optional, but they make the shortlist much sharper.'
|
||
>
|
||
{formMessage ? (
|
||
<div
|
||
className={`mb-6 rounded-2xl border px-4 py-3 text-sm leading-6 ${
|
||
formMessage.tone === 'success'
|
||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||
: 'border-rose-200 bg-rose-50 text-rose-700'
|
||
}`}
|
||
>
|
||
{formMessage.text}
|
||
</div>
|
||
) : null}
|
||
|
||
<Formik
|
||
initialValues={initialValues}
|
||
onSubmit={(values, { resetForm }) => submitIntake(values, resetForm)}
|
||
validate={validateForm}
|
||
validateOnBlur={false}
|
||
validateOnChange={false}
|
||
>
|
||
{({ errors }) => (
|
||
<Form>
|
||
<div className='grid gap-6 md:grid-cols-2'>
|
||
<div className='md:col-span-2'>
|
||
<FormField
|
||
label='Project title'
|
||
help='Lead with the ERP and business outcome to attract the right specialists.'
|
||
>
|
||
<Field name='project_title' placeholder='Ex: SAP S/4HANA finance migration for EMEA rollout' />
|
||
</FormField>
|
||
{errors.project_title ? (
|
||
<div className='-mt-4 mb-4 text-sm text-rose-600'>{errors.project_title}</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<FormField label='ERP system' help='Optional. Search your ERP stack such as Odoo, SAP, Oracle, or Zoho.'>
|
||
<Field component={SelectField} id='erp_system' itemRef='erp_systems' name='erp_system' options={[]} />
|
||
</FormField>
|
||
|
||
<FormField label='Client company' help='Optional. Link the brief to an existing company record.'>
|
||
<Field component={SelectField} id='company' itemRef='companies' name='company' options={[]} />
|
||
</FormField>
|
||
|
||
<FormField label='Project type'>
|
||
<Field as='select' name='project_type'>
|
||
<option value='implementation'>Implementation</option>
|
||
<option value='support'>Support</option>
|
||
<option value='migration'>Migration</option>
|
||
<option value='integration'>Integration</option>
|
||
<option value='customization'>Customization</option>
|
||
<option value='training'>Training</option>
|
||
<option value='audit'>Audit</option>
|
||
<option value='other'>Other</option>
|
||
</Field>
|
||
</FormField>
|
||
|
||
<FormField label='Engagement model'>
|
||
<Field as='select' name='engagement_model'>
|
||
<option value='fixed_price'>Fixed price</option>
|
||
<option value='hourly'>Hourly</option>
|
||
<option value='retainer'>Retainer</option>
|
||
</Field>
|
||
</FormField>
|
||
|
||
<FormField label='Budget min'>
|
||
<Field min='0' name='budget_min' placeholder='4500' type='number' />
|
||
</FormField>
|
||
|
||
<FormField label='Budget max'>
|
||
<Field min='0' name='budget_max' placeholder='12000' type='number' />
|
||
</FormField>
|
||
|
||
<FormField label='Currency'>
|
||
<Field as='select' name='budget_currency'>
|
||
<option value='USD'>USD</option>
|
||
<option value='EUR'>EUR</option>
|
||
<option value='GBP'>GBP</option>
|
||
<option value='AUD'>AUD</option>
|
||
</Field>
|
||
</FormField>
|
||
|
||
<FormField label='Estimated hours'>
|
||
<Field min='0' name='estimated_hours' placeholder='160' type='number' />
|
||
</FormField>
|
||
|
||
<div className='md:col-span-2'>
|
||
<FormField
|
||
label='Required skills'
|
||
help='Pick the highest-signal skills. The shortlist will score freelancers using these overlaps.'
|
||
>
|
||
<Field component={SelectFieldMany} id='skillIds' itemRef='skills' name='skillIds' options={[]} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<FormField label='Preferred location'>
|
||
<Field name='location_preference' placeholder='Ex: CET overlap, onsite in Berlin, or fully remote' />
|
||
</FormField>
|
||
|
||
<FormField label='Desired start date'>
|
||
<Field name='desired_start_at' type='date' />
|
||
</FormField>
|
||
|
||
<div className='md:col-span-2'>
|
||
<FormField label='Project description' help='Mention scope, modules, timeline, and what success looks like.'>
|
||
<Field as='textarea' name='project_description' rows={6} />
|
||
</FormField>
|
||
{errors.project_description ? (
|
||
<div className='-mt-4 mb-4 text-sm text-rose-600'>{errors.project_description}</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className='mt-2 grid gap-6 border-t border-slate-200 pt-6 md:grid-cols-[1fr,auto] md:items-center'>
|
||
<div>
|
||
<p className='text-sm font-semibold text-slate-900'>Remote-friendly project</p>
|
||
<p className='mt-1 text-sm leading-6 text-slate-500'>
|
||
Keep this enabled to surface freelancers that can jump in quickly from any geography.
|
||
</p>
|
||
</div>
|
||
<div className='justify-self-start md:justify-self-end'>
|
||
<Field component={SwitchField} name='remote_ok' />
|
||
</div>
|
||
</div>
|
||
|
||
{errors.budget_min || errors.budget_max || errors.estimated_hours ? (
|
||
<div className='mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700'>
|
||
{errors.budget_min || errors.budget_max || errors.estimated_hours}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className='mt-6 flex flex-wrap gap-3'>
|
||
<BaseButton
|
||
color='info'
|
||
icon={mdiArrowRight}
|
||
iconClassName='ml-1'
|
||
label={submitting ? 'Creating intake…' : 'Create project intake'}
|
||
type='submit'
|
||
disabled={submitting}
|
||
/>
|
||
<BaseButton color='white' href='/projects/projects-new' label='Use full project form instead' />
|
||
</div>
|
||
</Form>
|
||
)}
|
||
</Formik>
|
||
</SectionCard>
|
||
|
||
<div className='grid gap-6'>
|
||
<SectionCard
|
||
title='Why this slice matters'
|
||
description='Instead of dropping users into CRUD, this flow gives clients a clear next action and a meaningful outcome.'
|
||
>
|
||
<div className='space-y-4'>
|
||
{[
|
||
{
|
||
icon: mdiServer,
|
||
text: 'ERP-first intake fields capture the stack, scope, and commercial envelope.',
|
||
},
|
||
{
|
||
icon: mdiAccountTie,
|
||
text: 'Matching uses freelancer availability, verification, ERP overlap, and skill overlap.',
|
||
},
|
||
{
|
||
icon: mdiClockOutline,
|
||
text: 'Recent activity panels keep project managers close to delivery status without leaving the workflow.',
|
||
},
|
||
].map((item) => (
|
||
<div key={item.text} className='flex gap-3 rounded-2xl border border-slate-200 bg-slate-50 p-4'>
|
||
<div className='flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-900'>
|
||
<BaseIcon path={item.icon} size={20} className='text-white' />
|
||
</div>
|
||
<p className='text-sm leading-6 text-slate-600'>{item.text}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
title='Operator shortcuts'
|
||
description='Continue with the rest of the marketplace using the existing admin screens.'
|
||
>
|
||
<div className='grid gap-3'>
|
||
<Link className='group rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-slate-300 hover:bg-slate-50' href='/projects/projects-list'>
|
||
<div className='flex items-center justify-between gap-4'>
|
||
<div>
|
||
<p className='font-semibold text-slate-900'>Project pipeline</p>
|
||
<p className='mt-1 text-sm text-slate-500'>Review every brief, status, and project detail.</p>
|
||
</div>
|
||
<BaseIcon path={mdiArrowRight} size={20} className='text-slate-400 transition group-hover:text-slate-900' />
|
||
</div>
|
||
</Link>
|
||
<Link className='group rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-slate-300 hover:bg-slate-50' href='/proposals/proposals-list'>
|
||
<div className='flex items-center justify-between gap-4'>
|
||
<div>
|
||
<p className='font-semibold text-slate-900'>Proposal review queue</p>
|
||
<p className='mt-1 text-sm text-slate-500'>Move accepted candidates into shortlist and interview stages.</p>
|
||
</div>
|
||
<BaseIcon path={mdiArrowRight} size={20} className='text-slate-400 transition group-hover:text-slate-900' />
|
||
</div>
|
||
</Link>
|
||
<Link className='group rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-slate-300 hover:bg-slate-50' href='/freelancer_profiles/freelancer_profiles-list'>
|
||
<div className='flex items-center justify-between gap-4'>
|
||
<div>
|
||
<p className='font-semibold text-slate-900'>Expert bench</p>
|
||
<p className='mt-1 text-sm text-slate-500'>Verify specialist profiles and grow the marketplace supply side.</p>
|
||
</div>
|
||
<BaseIcon path={mdiArrowRight} size={20} className='text-slate-400 transition group-hover:text-slate-900' />
|
||
</div>
|
||
</Link>
|
||
</div>
|
||
</SectionCard>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='mt-6 grid gap-6 xl:grid-cols-[1.1fr,0.9fr]'>
|
||
<SectionCard
|
||
title='Match preview'
|
||
description='Freshly created projects surface the most promising freelancer profiles first.'
|
||
>
|
||
{intakeResult?.project?.id ? (
|
||
<div className='space-y-6'>
|
||
<div className='rounded-3xl border border-emerald-200 bg-emerald-50/70 p-5'>
|
||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||
<div>
|
||
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-emerald-700'>
|
||
Project created
|
||
</p>
|
||
<h3 className='mt-2 text-2xl font-semibold tracking-tight text-slate-900'>
|
||
{intakeResult.project.project_title}
|
||
</h3>
|
||
<div className='mt-3 flex flex-wrap gap-2'>
|
||
{intakeResult.project.erp_system?.system_name ? (
|
||
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600'>
|
||
{intakeResult.project.erp_system.system_name}
|
||
</span>
|
||
) : null}
|
||
{intakeResult.project.company?.company_name ? (
|
||
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600'>
|
||
{intakeResult.project.company.company_name}
|
||
</span>
|
||
) : null}
|
||
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600'>
|
||
{formatMoneyRange(
|
||
intakeResult.project.budget_min,
|
||
intakeResult.project.budget_max,
|
||
intakeResult.project.budget_currency,
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className='flex flex-wrap gap-3'>
|
||
<BaseButton
|
||
color='success'
|
||
href={`/projects/projects-view/?id=${intakeResult.project.id}`}
|
||
label='View project detail'
|
||
/>
|
||
<BaseButton color='white' href='/proposals/proposals-list' label='Open proposals' />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-sm font-semibold text-slate-900'>Suggested freelancers</p>
|
||
<p className='text-sm text-slate-500'>
|
||
{matchCount > 0
|
||
? `${matchCount} experts surfaced using ERP, availability, verification, and skill overlap.`
|
||
: 'No exact matches yet. Add more freelancer profiles or broaden the brief criteria.'}
|
||
</p>
|
||
</div>
|
||
{intakeResult.matchPreview?.meta?.criteriaCount ? (
|
||
<span className='rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500'>
|
||
{intakeResult.matchPreview.meta.criteriaCount} matching signals
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{matchCount > 0 ? (
|
||
<div className='grid gap-4'>
|
||
{intakeResult.matchPreview?.matches?.map((match) => (
|
||
<div key={match.id} className='rounded-3xl border border-slate-200 bg-white p-5 shadow-sm'>
|
||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||
<div>
|
||
<div className='flex flex-wrap items-center gap-2'>
|
||
<h3 className='text-lg font-semibold text-slate-900'>
|
||
{match.freelancer_name || 'Freelancer'}
|
||
</h3>
|
||
<span className='rounded-full bg-slate-900 px-2.5 py-1 text-xs font-semibold text-white'>
|
||
Match score {match.matchScore ?? 0}
|
||
</span>
|
||
</div>
|
||
<p className='mt-1 text-sm font-medium text-slate-600'>{match.headline || 'ERP specialist profile'}</p>
|
||
<p className='mt-3 max-w-2xl text-sm leading-6 text-slate-500'>
|
||
{match.summary || 'No summary added yet.'}
|
||
</p>
|
||
</div>
|
||
<BaseButton
|
||
color='info'
|
||
href={`/freelancer_profiles/freelancer_profiles-view/?id=${match.id}`}
|
||
label='Open profile'
|
||
/>
|
||
</div>
|
||
<div className='mt-4 flex flex-wrap gap-2'>
|
||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusClasses[match.availability || ''] || 'bg-slate-100 text-slate-600'}`}>
|
||
{prettifyLabel(match.availability)}
|
||
</span>
|
||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusClasses[match.verification_status || ''] || 'bg-slate-100 text-slate-600'}`}>
|
||
{match.is_vetted ? 'Vetted' : prettifyLabel(match.verification_status)}
|
||
</span>
|
||
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600'>
|
||
{prettifyLabel(match.experience_level)}
|
||
</span>
|
||
{match.years_experience ? (
|
||
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600'>
|
||
{match.years_experience}+ years
|
||
</span>
|
||
) : null}
|
||
{match.hourly_rate ? (
|
||
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600'>
|
||
{match.rate_currency || 'USD'} {match.hourly_rate}/hr
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className='mt-4 grid gap-3 md:grid-cols-2'>
|
||
<div className='rounded-2xl bg-slate-50 p-4'>
|
||
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>
|
||
Why this expert fits
|
||
</p>
|
||
<div className='mt-3 flex flex-wrap gap-2'>
|
||
{(match.matchReasons || []).map((reason) => (
|
||
<span key={reason} className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600'>
|
||
{reason}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className='rounded-2xl bg-slate-50 p-4'>
|
||
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>
|
||
Overlapping skills
|
||
</p>
|
||
<div className='mt-3 flex flex-wrap gap-2'>
|
||
{(match.matchedSkills || []).length ? (
|
||
match.matchedSkills?.map((skill) => (
|
||
<span key={skill} className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600'>
|
||
{skill}
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-500'>
|
||
General vetted fit only
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className='rounded-3xl border border-dashed border-slate-300 bg-slate-50 p-6 text-sm leading-7 text-slate-500'>
|
||
We saved the project successfully, but the current dataset does not contain a strong match yet.
|
||
Add freelancer profiles, ERP systems, or skills and try the intake again for a richer shortlist.
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className='rounded-3xl border border-dashed border-slate-300 bg-slate-50 p-6 text-sm leading-7 text-slate-500'>
|
||
Create a project intake to see the first shortlist here. The page will return the project record,
|
||
confidence signals, and quick links into the existing project and proposal screens.
|
||
</div>
|
||
)}
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
title='Recent marketplace activity'
|
||
description='Keep the latest projects and proposals visible so operations teams can act without context switching.'
|
||
>
|
||
<div className='grid gap-6'>
|
||
<div>
|
||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||
<h3 className='text-base font-semibold text-slate-900'>Recent projects</h3>
|
||
<Link className='text-sm font-semibold text-sky-700 hover:text-sky-900' href='/projects/projects-list'>
|
||
View all
|
||
</Link>
|
||
</div>
|
||
{recentProjects.length ? (
|
||
<div className='grid gap-3'>
|
||
{recentProjects.map((project) => (
|
||
<Link
|
||
key={project.id}
|
||
className='rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-slate-300 hover:bg-slate-50'
|
||
href={`/projects/projects-view/?id=${project.id}`}
|
||
>
|
||
<div className='flex items-start justify-between gap-4'>
|
||
<div>
|
||
<p className='font-semibold text-slate-900'>{project.project_title || 'Untitled project'}</p>
|
||
<div className='mt-2 flex flex-wrap gap-2'>
|
||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${statusClasses[project.status || ''] || 'bg-slate-100 text-slate-600'}`}>
|
||
{prettifyLabel(project.status)}
|
||
</span>
|
||
{project.erp_system?.system_name ? (
|
||
<span className='rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600'>
|
||
{project.erp_system.system_name}
|
||
</span>
|
||
) : null}
|
||
{project.company?.company_name ? (
|
||
<span className='rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600'>
|
||
{project.company.company_name}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className='text-right text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>
|
||
{prettifyLabel(project.project_type)}
|
||
</div>
|
||
</div>
|
||
<div className='mt-4 flex flex-wrap items-center gap-4 text-sm text-slate-500'>
|
||
<div className='inline-flex items-center gap-2'>
|
||
<BaseIcon path={mdiCurrencyUsd} size={16} className='text-slate-400' />
|
||
{formatMoneyRange(project.budget_min, project.budget_max, project.budget_currency)}
|
||
</div>
|
||
{project.createdAt ? (
|
||
<div className='inline-flex items-center gap-2'>
|
||
<BaseIcon path={mdiClockOutline} size={16} className='text-slate-400' />
|
||
{new Date(project.createdAt).toLocaleDateString()}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className='rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
|
||
No projects yet. Use the intake form to create the first client brief.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||
<h3 className='text-base font-semibold text-slate-900'>Recent proposals</h3>
|
||
<Link className='text-sm font-semibold text-sky-700 hover:text-sky-900' href='/proposals/proposals-list'>
|
||
View all
|
||
</Link>
|
||
</div>
|
||
{recentProposals.length ? (
|
||
<div className='grid gap-3'>
|
||
{recentProposals.map((proposal) => (
|
||
<Link
|
||
key={proposal.id}
|
||
className='rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-slate-300 hover:bg-slate-50'
|
||
href={`/proposals/proposals-view/?id=${proposal.id}`}
|
||
>
|
||
<div className='flex items-start justify-between gap-4'>
|
||
<div>
|
||
<p className='font-semibold text-slate-900'>
|
||
{proposal.project?.project_title || 'Proposal'}
|
||
</p>
|
||
<p className='mt-1 text-sm text-slate-500'>
|
||
{proposal.freelancer_profile?.headline ||
|
||
[
|
||
proposal.freelancer_profile?.user?.firstName,
|
||
proposal.freelancer_profile?.user?.lastName,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ') || 'Freelancer profile'}
|
||
</p>
|
||
</div>
|
||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${statusClasses[proposal.status || ''] || 'bg-slate-100 text-slate-600'}`}>
|
||
{prettifyLabel(proposal.status)}
|
||
</span>
|
||
</div>
|
||
<div className='mt-4 inline-flex items-center gap-2 text-sm text-slate-500'>
|
||
<BaseIcon path={mdiCurrencyUsd} size={16} className='text-slate-400' />
|
||
{proposal.proposed_amount
|
||
? `${proposal.currency || 'USD'} ${proposal.proposed_amount}`
|
||
: 'Amount not specified'}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className='rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
|
||
No proposals have been submitted yet. Once freelancers apply, they will appear here.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
</div>
|
||
</SectionMain>
|
||
</>
|
||
);
|
||
};
|
||
|
||
ErpMatchDeskPage.getLayout = function getLayout(page: ReactElement) {
|
||
return <LayoutAuthenticated permission='READ_PROJECTS'>{page}</LayoutAuthenticated>;
|
||
};
|
||
|
||
export default ErpMatchDeskPage;
|