39512-vm/frontend/src/pages/erp-matchdesk.tsx
2026-04-08 13:05:26 +00:00

944 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;