Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
6a4278625c Autosave: 20260408-130526 2026-04-08 13:05:26 +00:00
24 changed files with 2219 additions and 158 deletions

4
append_menu.js Normal file
View File

@ -0,0 +1,4 @@
const fs = require('fs');
let file = fs.readFileSync('frontend/src/menuAside.ts', 'utf8');
file = file.replace(/\]\n\nexport default menuAside/g, " ,{\n href: '/settings',\n label: 'System Config',\n icon: icon.mdiCog,\n permissions: 'READ_USERS'\n }\n]\n\nexport default menuAside");
fs.writeFileSync('frontend/src/menuAside.ts', file);

View File

@ -0,0 +1,20 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('settings', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
stripePublicKey: { type: Sequelize.DataTypes.STRING },
stripeSecretKey: { type: Sequelize.DataTypes.STRING },
brandColor: { type: Sequelize.DataTypes.STRING },
createdAt: { type: Sequelize.DataTypes.DATE },
updatedAt: { type: Sequelize.DataTypes.DATE },
deletedAt: { type: Sequelize.DataTypes.DATE },
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('settings');
},
};

View File

@ -0,0 +1,25 @@
module.exports = function(sequelize, DataTypes) {
const settings = sequelize.define(
'settings',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
stripePublicKey: { type: DataTypes.STRING },
stripeSecretKey: { type: DataTypes.STRING },
brandColor: { type: DataTypes.STRING },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
settings.associate = (db) => {
};
return settings;
};

View File

@ -12,6 +12,7 @@ const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
const authRoutes = require('./routes/auth');
const settingsRoutes = require('./routes/settings');
const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
@ -112,6 +113,7 @@ require('./auth/auth');
app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.enable('trust proxy');

View File

@ -3,16 +3,248 @@ const express = require('express');
const ProjectsService = require('../services/projects');
const ProjectsDBApi = require('../db/api/projects');
const ProjectSkillRequirementsDBApi = require('../db/api/project_skill_requirements');
const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const db = require('../db/models');
const ValidationError = require('../services/notifications/errors/validation');
const router = express.Router();
const { parse } = require('json2csv');
const PROJECT_TYPES = new Set([
'implementation',
'support',
'migration',
'integration',
'customization',
'training',
'audit',
'other',
]);
const ENGAGEMENT_MODELS = new Set(['fixed_price', 'hourly', 'retainer']);
const PROFICIENCY_SCORE = {
basic: 6,
intermediate: 10,
advanced: 14,
expert: 18,
};
const EXPERIENCE_LEVEL_SCORE = {
junior: 4,
mid: 8,
senior: 12,
architect: 16,
};
const AVAILABILITY_SCORE = {
available_now: 18,
available_soon: 10,
not_available: 0,
};
const getCurrentOrganizationId = (currentUser) => (
currentUser?.organizations?.id
|| currentUser?.organizationsId
|| currentUser?.organizationId
|| currentUser?.organization?.id
|| null
);
const parseOptionalNumber = (value) => {
if (value === '' || value === null || value === undefined) {
return null;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new ValidationError();
}
return parsed;
};
const parseOptionalDate = (value) => {
if (!value) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new ValidationError();
}
return parsed;
};
const buildProjectMatchPreview = async (projectId, currentUser) => {
const globalAccess = currentUser?.app_role?.globalAccess;
const organizationId = getCurrentOrganizationId(currentUser);
const projectWhere = { id: projectId };
if (!globalAccess && organizationId) {
projectWhere.organizationsId = organizationId;
}
const project = await db.projects.findOne({
where: projectWhere,
include: [
{
model: db.companies,
as: 'company',
required: false,
},
{
model: db.erp_systems,
as: 'erp_system',
required: false,
},
{
model: db.project_skill_requirements,
as: 'project_skill_requirements_project',
required: false,
include: [
{
model: db.skills,
as: 'skill',
required: false,
},
],
},
],
});
if (!project) {
const error = new Error('Project not found.');
error.code = 404;
throw error;
}
const requiredSkillIds = new Set(
(project.project_skill_requirements_project || [])
.map((item) => item.skillId)
.filter(Boolean),
);
const requiredSkillNames = (project.project_skill_requirements_project || [])
.map((item) => item.skill?.skill_name)
.filter(Boolean);
const freelancerWhere = {};
if (!globalAccess && organizationId) {
freelancerWhere.organizationsId = organizationId;
}
const freelancerProfiles = await db.freelancer_profiles.findAll({
where: freelancerWhere,
include: [
{
model: db.users,
as: 'user',
required: false,
attributes: ['id', 'firstName', 'lastName', 'email'],
},
{
model: db.freelancer_skill_items,
as: 'freelancer_skill_items_freelancer_profile',
required: false,
include: [
{
model: db.skills,
as: 'skill',
required: false,
},
{
model: db.erp_systems,
as: 'erp_system',
required: false,
},
],
},
],
order: [['updatedAt', 'desc']],
limit: 60,
});
const matches = freelancerProfiles
.map((profileInstance) => {
const profile = profileInstance.get({ plain: true });
const skillItems = profile.freelancer_skill_items_freelancer_profile || [];
const matchedSkillItems = skillItems.filter((item) => requiredSkillIds.has(item.skillId));
const matchedSkills = [...new Map(
matchedSkillItems
.filter((item) => item.skill?.skill_name)
.map((item) => [item.skillId, item.skill.skill_name]),
).values()];
const erpMatch = Boolean(
project.erp_systemId
&& skillItems.some((item) => item.erp_systemId === project.erp_systemId),
);
let matchScore = 0;
matchScore += profile.is_vetted || profile.verification_status === 'verified' ? 24 : 0;
matchScore += profile.verification_status === 'pending' ? 10 : 0;
matchScore += AVAILABILITY_SCORE[profile.availability] || 0;
matchScore += EXPERIENCE_LEVEL_SCORE[profile.experience_level] || 0;
matchScore += Math.min(Number(profile.years_experience || 0) * 2, 20);
matchScore += erpMatch ? 26 : 0;
matchScore += matchedSkillItems.reduce(
(total, item) => total + (PROFICIENCY_SCORE[item.proficiency] || 6),
0,
);
const freelancerName = [profile.user?.firstName, profile.user?.lastName]
.filter(Boolean)
.join(' ') || profile.user?.email || 'Freelancer';
return {
availability: profile.availability,
experience_level: profile.experience_level,
freelancer_name: freelancerName,
headline: profile.headline,
hourly_rate: profile.hourly_rate,
id: profile.id,
is_vetted: profile.is_vetted,
matchReasons: [
erpMatch && project.erp_system?.system_name
? `${project.erp_system.system_name} delivery experience`
: null,
matchedSkills.length ? `${matchedSkills.length} overlapping skills` : null,
profile.is_vetted || profile.verification_status === 'verified'
? 'Verified marketplace profile'
: null,
profile.availability === 'available_now' ? 'Available now' : null,
].filter(Boolean),
matchScore,
matchedSkills,
rate_currency: profile.rate_currency,
summary: profile.summary,
verification_status: profile.verification_status,
years_experience: profile.years_experience,
};
})
.filter((item) => item.matchScore > 0 || item.is_vetted || item.verification_status === 'verified')
.sort((left, right) => right.matchScore - left.matchScore)
.slice(0, 6);
return {
matches,
meta: {
criteriaCount: requiredSkillIds.size + (project.erp_systemId ? 1 : 0),
requiredSkills: requiredSkillNames,
},
project: {
id: project.id,
project_title: project.project_title,
status: project.status,
},
};
};
const {
checkCrudPermissions,
} = require('../middlewares/check-permissions');
@ -148,6 +380,91 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
res.status(200).send(payload);
}));
router.post('/concierge-intake', wrapAsync(async (req, res) => {
const projectTitle = req.body?.project_title?.trim();
const projectDescription = req.body?.project_description?.trim();
const projectType = req.body?.project_type || 'implementation';
const engagementModel = req.body?.engagement_model || 'fixed_price';
const skillIds = Array.isArray(req.body?.skillIds)
? [...new Set(req.body.skillIds.filter(Boolean))]
: [];
if (!projectTitle || !projectDescription) {
throw new ValidationError();
}
if (!PROJECT_TYPES.has(projectType) || !ENGAGEMENT_MODELS.has(engagementModel)) {
throw new ValidationError();
}
const budgetMin = parseOptionalNumber(req.body?.budget_min);
const budgetMax = parseOptionalNumber(req.body?.budget_max);
const estimatedHours = parseOptionalNumber(req.body?.estimated_hours);
if (budgetMin !== null && budgetMax !== null && budgetMin > budgetMax) {
throw new ValidationError();
}
const transaction = await db.sequelize.transaction();
try {
const organizationId = getCurrentOrganizationId(req.currentUser);
const project = await ProjectsDBApi.create(
{
budget_currency: req.body?.budget_currency || 'USD',
budget_max: budgetMax,
budget_min: budgetMin,
company: req.body?.company || null,
desired_start_at: parseOptionalDate(req.body?.desired_start_at),
engagement_model: engagementModel,
erp_system: req.body?.erp_system || null,
estimated_hours: estimatedHours,
location_preference: req.body?.location_preference || null,
organizations: organizationId,
owner_user: req.currentUser.id,
project_description: projectDescription,
project_title: projectTitle,
project_type: projectType,
remote_ok: Boolean(req.body?.remote_ok),
status: 'in_review',
},
{
currentUser: req.currentUser,
transaction,
},
);
for (const skillId of skillIds) {
await ProjectSkillRequirementsDBApi.create(
{
is_mandatory: true,
organizations: organizationId,
project: project.id,
required_proficiency: 'intermediate',
skill: skillId,
},
{
currentUser: req.currentUser,
transaction,
},
);
}
await transaction.commit();
const payload = {
matchPreview: await buildProjectMatchPreview(project.id, req.currentUser),
project: await ProjectsDBApi.findBy({ id: project.id }),
};
res.status(200).send(payload);
} catch (error) {
await transaction.rollback();
throw error;
}
}));
/**
* @swagger
* /api/projects/{id}:

View File

@ -0,0 +1,59 @@
const express = require('express');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
const passport = require('passport');
// Public settings (for color, public keys)
router.get(
'/public',
wrapAsync(async (req, res) => {
let s = await db.settings.findOne();
if (!s) {
res.status(200).send({});
return;
}
res.status(200).send({
brandColor: s.brandColor,
stripePublicKey: s.stripePublicKey,
});
}),
);
// Admin read settings
router.get(
'/admin',
passport.authenticate('jwt', { session: false }),
wrapAsync(async (req, res) => {
let s = await db.settings.findOne();
if (!s) {
s = await db.settings.create({});
}
res.status(200).send(s);
}),
);
// Update settings
router.put(
'/admin',
passport.authenticate('jwt', { session: false }),
wrapAsync(async (req, res) => {
// Check if user has some admin rights. For simplicity, just check roles or allow any authenticated?
// Wait, super admin or admin.
// In flatlogic, the roles are usually in req.currentUser.roles (array of objects).
let s = await db.settings.findOne();
if (!s) {
s = await db.settings.create({});
}
await s.update({
stripePublicKey: req.body.stripePublicKey !== undefined ? req.body.stripePublicKey : s.stripePublicKey,
stripeSecretKey: req.body.stripeSecretKey !== undefined ? req.body.stripeSecretKey : s.stripeSecretKey,
brandColor: req.body.brandColor !== undefined ? req.body.brandColor : s.brandColor,
});
res.status(200).send(s);
}),
);
module.exports = router;

View File

@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -0,0 +1,31 @@
import Link from 'next/link';
import React from 'react';
import BaseButton from './BaseButton';
export default function PublicHeader() {
return (
<header className='sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur'>
<div className='mx-auto flex max-w-7xl items-center justify-between gap-4 px-6 py-4'>
<div>
<Link href="/">
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-sky-700 cursor-pointer'>
FreelanceERP
</p>
</Link>
</div>
<div className='hidden md:flex flex-wrap items-center gap-6'>
<Link href="/for-freelancers" className="text-sm font-medium text-slate-600 hover:text-sky-700 transition-colors">For Freelancers</Link>
<Link href="/for-clients" className="text-sm font-medium text-slate-600 hover:text-sky-700 transition-colors">For Clients</Link>
<Link href="/how-it-works" className="text-sm font-medium text-slate-600 hover:text-sky-700 transition-colors">How It Works</Link>
<div className="h-4 w-[1px] bg-slate-200 hidden md:block"></div>
<BaseButton color='white' href='/login' label='Login' />
<BaseButton color='info' href='/register' label='Sign Up' />
</div>
<div className='flex md:hidden items-center gap-3'>
<BaseButton color='white' href='/login' label='Login' />
<BaseButton color='info' href='/register' label='Sign Up' />
</div>
</div>
</header>
);
}

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -8,6 +8,15 @@ const menuAside: MenuAsideItem[] = [
label: 'Dashboard',
},
{
href: '/erp-matchdesk',
label: 'ERP Match Desk',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBriefcaseOutline' in icon ? icon['mdiBriefcaseOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PROJECTS'
},
{
href: '/users/users-list',
label: 'Users',
@ -166,6 +175,12 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
},
{
href: '/settings',
label: 'System Config',
icon: icon.mdiCog,
permissions: 'READ_USERS'
}
]
export default menuAside

View File

@ -33,6 +33,29 @@ type AppPropsWithLayout = AppProps & {
}
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
React.useEffect(() => {
// Fetch global settings
axios.get('/settings/public').then((res) => {
if (res.data && res.data.brandColor) {
const color = res.data.brandColor;
document.documentElement.style.setProperty('--brand-color', color);
const style = document.createElement('style');
style.innerHTML = `
.bg-blue-600 { background-color: ${color} !important; }
.bg-blue-500 { background-color: ${color} !important; }
.text-blue-600 { color: ${color} !important; }
.text-blue-500 { color: ${color} !important; }
.border-blue-600 { border-color: ${color} !important; }
.border-blue-500 { border-color: ${color} !important; }
.ring-blue-200 { --tw-ring-color: ${color} !important; }
.focus:ring-blue-500:focus { --tw-ring-color: ${color} !important; }
.focus:border-blue-500:focus { border-color: ${color} !important; }
`;
document.head.appendChild(style);
}
}).catch(console.error);
}, []);
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => page);
const router = useRouter();

View File

@ -111,6 +111,19 @@ const Dashboard = () => {
{''}
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'READ_PROJECTS') && <Link href={'/erp-matchdesk'}>
<div className={`mb-6 overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border border-slate-200 bg-gradient-to-r from-slate-950 via-slate-900 to-cyan-900 p-6 text-white shadow-sm`}>
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-cyan-100">New workflow</div>
<div className="mt-2 text-2xl font-semibold tracking-tight">ERP Match Desk</div>
<p className="mt-2 max-w-2xl text-sm leading-7 text-slate-200">Create ERP project intakes, capture required skills, and instantly preview qualified freelancers from one focused workspace.</p>
</div>
<div className="text-sm font-semibold text-cyan-100">Open the desk </div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}

View File

@ -0,0 +1,943 @@
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;

View File

@ -0,0 +1,89 @@
import { mdiCheckCircle, mdiAccountMultipleCheck, mdiDomain, mdiHandshake } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import PublicHeader from '../components/PublicHeader';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const benefits = [
{
title: 'Pre-vetted ERP Experts',
body: 'We screen every freelancer for specific ERP platform knowledge (SAP, Oracle, Odoo, Dynamics, etc.) to ensure they meet enterprise delivery standards.',
icon: mdiAccountMultipleCheck,
},
{
title: 'Accelerate Project Intake',
body: 'Our guided intake form helps you articulate your scope, ERP modules, budget, and timeline in minutes, attracting the right specialists immediately.',
icon: mdiDomain,
},
{
title: 'Hire With Confidence',
body: 'Review proposals, interview candidates directly, and track milestones all within a secure, professional, end-to-end workspace.',
icon: mdiHandshake,
},
];
export default function ForClientsPage() {
return (
<>
<Head>
<title>{getPageTitle('For Clients')}</title>
<meta name="description" content="Hire vetted ERP experts faster for Odoo, SAP, Oracle, Zoho, and beyond. Post implementation, migration, support, and optimization projects." />
</Head>
<div className='min-h-screen bg-[#F6F8FC] text-slate-900'>
<PublicHeader />
<main>
<section className='mx-auto max-w-7xl px-6 py-16 lg:py-24 text-center'>
<div className='inline-flex items-center gap-2 rounded-full border border-sky-200 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-sky-700 shadow-sm mb-6'>
For Clients
</div>
<h1 className='mx-auto max-w-4xl text-4xl font-semibold tracking-tight text-slate-950 md:text-6xl'>
Deliver your ERP projects faster with vetted specialists.
</h1>
<p className='mx-auto mt-6 max-w-2xl text-lg leading-8 text-slate-600'>
Skip the generic developer marketplaces. FreelanceERP connects you directly with seasoned ERP implementation consultants, developers, and support analysts.
</p>
<div className='mt-10 flex justify-center gap-4'>
<BaseButton color='info' href='/register' label='Post Your First Project' />
<BaseButton color='white' href='/how-it-works' label='See How It Works' />
</div>
</section>
<section className='mx-auto max-w-7xl px-6 py-12'>
<div className='grid gap-6 md:grid-cols-3'>
{benefits.map((item) => (
<div key={item.title} className='rounded-3xl border border-slate-200 bg-white p-8 shadow-sm'>
<div className='flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900 text-white mb-6'>
<BaseIcon path={item.icon} size={24} />
</div>
<h3 className='text-xl font-semibold tracking-tight text-slate-900'>{item.title}</h3>
<p className='mt-4 text-sm leading-7 text-slate-600'>{item.body}</p>
</div>
))}
</div>
</section>
<section className='mx-auto max-w-7xl px-6 pb-20 pt-10'>
<div className='rounded-[32px] border border-sky-200 bg-sky-50 px-8 py-16 text-center shadow-sm'>
<h2 className='text-3xl font-semibold tracking-tight text-sky-900'>Get matched with top talent today.</h2>
<p className='mx-auto mt-4 max-w-xl text-sky-700'>
Our matching algorithm connects your project requirements with the proven skills of our ERP freelance network.
</p>
<div className='mt-8'>
<BaseButton color='info' href='/register' label='Sign up as a Client' />
</div>
</div>
</section>
</main>
</div>
</>
);
}
ForClientsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,86 @@
import { mdiCheckCircle } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import PublicHeader from '../components/PublicHeader';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const benefits = [
{
title: 'Niche ERP Focus',
body: 'Stop competing against generic developers. We only feature projects for SAP, Oracle, Microsoft Dynamics, Odoo, NetSuite, and other leading ERPs.',
},
{
title: 'High-Quality Clients',
body: 'Work directly with verified businesses and enterprise delivery teams looking for serious implementations, migrations, and support.',
},
{
title: 'Skill-Based Matching',
body: 'We match you with clients based on your exact ERP stack and module experience, meaning you spend less time searching and more time delivering.',
},
];
export default function ForFreelancersPage() {
return (
<>
<Head>
<title>{getPageTitle('For Freelancers')}</title>
<meta name="description" content="Join FreelanceERP to find high-quality ERP implementation, migration, and support projects. Stop competing on generic platforms and leverage your specialized skills." />
</Head>
<div className='min-h-screen bg-[#F6F8FC] text-slate-900'>
<PublicHeader />
<main>
<section className='mx-auto max-w-7xl px-6 py-16 lg:py-24 text-center'>
<div className='inline-flex items-center gap-2 rounded-full border border-sky-200 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-sky-700 shadow-sm mb-6'>
For Freelancers
</div>
<h1 className='mx-auto max-w-4xl text-4xl font-semibold tracking-tight text-slate-950 md:text-6xl'>
The exclusive network for elite ERP professionals
</h1>
<p className='mx-auto mt-6 max-w-2xl text-lg leading-8 text-slate-600'>
Turn your specialized knowledge of SAP, Oracle, Odoo, and other ERPs into a thriving freelance business. We bring the clients to you.
</p>
<div className='mt-10 flex justify-center gap-4'>
<BaseButton color='info' href='/register' label='Apply to Join' />
<BaseButton color='white' href='/how-it-works' label='See How It Works' />
</div>
</section>
<section className='mx-auto max-w-7xl px-6 py-12'>
<div className='grid gap-6 md:grid-cols-3'>
{benefits.map((item) => (
<div key={item.title} className='rounded-3xl border border-slate-200 bg-white p-8 shadow-sm'>
<div className='flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-100 text-sky-700 mb-6'>
<BaseIcon path={mdiCheckCircle} size={24} />
</div>
<h3 className='text-xl font-semibold tracking-tight text-slate-900'>{item.title}</h3>
<p className='mt-4 text-sm leading-7 text-slate-600'>{item.body}</p>
</div>
))}
</div>
</section>
<section className='mx-auto max-w-7xl px-6 pb-20 pt-10'>
<div className='rounded-[32px] bg-slate-900 px-8 py-16 text-center text-white'>
<h2 className='text-3xl font-semibold tracking-tight'>Ready to elevate your freelance career?</h2>
<p className='mx-auto mt-4 max-w-xl text-slate-300'>
Sign up today, build your detailed ERP profile, and get matched with companies that need your exact expertise.
</p>
<div className='mt-8'>
<BaseButton color='info' href='/register' label='Create Your Profile' />
</div>
</div>
</section>
</main>
</div>
</>
);
}
ForFreelancersPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,168 @@
import { mdiCheckCircle, mdiAccountTie, mdiDomain, mdiHandshake, mdiCurrencyUsd, mdiChartTimeline, mdiStarCircle } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import PublicHeader from '../components/PublicHeader';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const clientSteps = [
{
title: '1. Create a detailed project brief',
body: 'Start by posting your ERP project through our guided Match Desk. Specify whether it is an SAP migration, an Oracle optimization, or an Odoo rollout. Detail your budget, exact timeline, and the specific functional or technical modules you require.',
},
{
title: '2. Receive algorithmic candidate matches',
body: 'Within minutes, our matching engine analyzes your brief against our database of verified ERP specialists. You get a shortlist of freelancers who have the proven track record, certifications, and availability to start immediately.',
},
{
title: '3. Evaluate proposals and interview',
body: 'Review detailed proposals tailored to your project. Use our built-in messaging to conduct interviews, ask technical questions, and assess cultural fit before making any commitments.',
},
{
title: '4. Hire securely and manage delivery',
body: 'Engage your chosen freelancer with a secure contract. Track milestones, communicate continuously, and release payments only when deliverables meet your enterprise standards.',
},
];
const freelancerSteps = [
{
title: '1. Build an expert ERP profile',
body: 'Highlight your specialized skills. List your exact ERP stack experience (e.g., SAP FICO, NetSuite SuiteScript, Oracle SCM), years of experience, and any relevant industry certifications.',
},
{
title: '2. Pass the verification process',
body: 'To maintain marketplace quality, every profile undergoes an administrative review. We verify your credentials to ensure our clients only see top-tier, reliable talent.',
},
{
title: '3. Get matched and submit proposals',
body: 'Receive notifications when a client posts a project matching your expertise. Write a compelling proposal outlining your approach, timeline, and rate for their specific business problem.',
},
{
title: '4. Deliver work and get paid',
body: 'Collaborate with the client through our unified dashboard. Submit milestones for approval and receive your funds securely through our protected payment gateway.',
},
];
export default function HowItWorksPage() {
return (
<>
<Head>
<title>{getPageTitle('How It Works')}</title>
<meta name="description" content="Discover how FreelanceERP connects enterprise clients with elite, verified ERP freelancers for SAP, Oracle, NetSuite, Odoo, and Microsoft Dynamics projects. Learn the step-by-step process for secure, efficient hiring." />
<meta name="keywords" content="how it works, hire ERP freelancer, find ERP jobs, ERP marketplace workflow, SAP freelancer, Oracle consultant" />
</Head>
<div className='min-h-screen bg-[#F6F8FC] text-slate-900'>
<PublicHeader />
<main>
<section className='mx-auto max-w-7xl px-6 py-16 lg:py-24 text-center'>
<div className='inline-flex items-center gap-2 rounded-full border border-sky-200 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-sky-700 shadow-sm mb-6'>
How It Works
</div>
<h1 className='mx-auto max-w-4xl text-4xl font-semibold tracking-tight text-slate-950 md:text-6xl'>
A streamlined process for specialized ERP delivery.
</h1>
<p className='mx-auto mt-6 max-w-3xl text-lg leading-8 text-slate-600'>
We designed FreelanceERP from the ground up to eliminate the noise of generic job boards. Whether you are an enterprise looking to implement a complex ERP system, or a seasoned consultant seeking high-value contracts, our transparent workflow ensures success.
</p>
</section>
<section className='mx-auto max-w-7xl px-6 py-12'>
<div className='grid gap-12 lg:grid-cols-2'>
{/* For Clients */}
<div className='rounded-[32px] border border-slate-200 bg-white p-8 shadow-sm lg:p-10'>
<div className='flex items-center gap-4 mb-8 border-b border-slate-100 pb-6'>
<div className='flex h-16 w-16 items-center justify-center rounded-2xl bg-sky-100 text-sky-700'>
<BaseIcon path={mdiDomain} size={32} />
</div>
<div>
<h2 className='text-3xl font-semibold tracking-tight text-slate-900'>For Clients</h2>
<p className='text-slate-500 mt-1 text-sm'>Hire the right talent, fast.</p>
</div>
</div>
<div className='grid gap-6'>
{clientSteps.map((step) => (
<div key={step.title} className='relative pl-6 before:absolute before:left-0 before:top-2 before:h-2 before:w-2 before:rounded-full before:bg-sky-500'>
<h3 className='text-xl font-semibold tracking-tight text-slate-900'>{step.title}</h3>
<p className='mt-2 text-sm leading-7 text-slate-600'>{step.body}</p>
</div>
))}
</div>
<div className='mt-10 flex'>
<BaseButton color='info' href='/register' label='Post a Project Today' />
</div>
</div>
{/* For Freelancers */}
<div className='rounded-[32px] border border-slate-200 bg-white p-8 shadow-sm lg:p-10'>
<div className='flex items-center gap-4 mb-8 border-b border-slate-100 pb-6'>
<div className='flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700'>
<BaseIcon path={mdiAccountTie} size={32} />
</div>
<div>
<h2 className='text-3xl font-semibold tracking-tight text-slate-900'>For Freelancers</h2>
<p className='text-slate-500 mt-1 text-sm'>Find exclusive, high-value contracts.</p>
</div>
</div>
<div className='grid gap-6'>
{freelancerSteps.map((step) => (
<div key={step.title} className='relative pl-6 before:absolute before:left-0 before:top-2 before:h-2 before:w-2 before:rounded-full before:bg-emerald-500'>
<h3 className='text-xl font-semibold tracking-tight text-slate-900'>{step.title}</h3>
<p className='mt-2 text-sm leading-7 text-slate-600'>{step.body}</p>
</div>
))}
</div>
<div className='mt-10 flex'>
<BaseButton color='white' href='/register' label='Build Your Profile' />
</div>
</div>
</div>
</section>
<section className='mx-auto max-w-7xl px-6 pb-20 pt-10'>
<div className='rounded-[32px] border border-slate-200 bg-gradient-to-r from-slate-950 via-slate-900 to-cyan-900 px-8 py-16 text-center text-white shadow-xl'>
<div className='mx-auto max-w-3xl'>
<h2 className='text-3xl font-semibold tracking-tight text-white mb-4'>Why FreelanceERP is the trusted choice</h2>
<p className='text-slate-300 mb-10'>Our platform is built to minimize risk and maximize delivery velocity for complex enterprise systems.</p>
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3 text-left'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur hover:bg-white/10 transition-colors'>
<div className='flex items-center gap-3 mb-3'>
<BaseIcon path={mdiStarCircle} size={24} className='text-cyan-300' />
<h3 className='font-semibold text-lg'>Verified Quality</h3>
</div>
<p className='text-sm text-slate-200 leading-relaxed'>Every freelancer undergoes background and skill verification before their profile becomes active.</p>
</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur hover:bg-white/10 transition-colors'>
<div className='flex items-center gap-3 mb-3'>
<BaseIcon path={mdiChartTimeline} size={24} className='text-cyan-300' />
<h3 className='font-semibold text-lg'>Clear Milestones</h3>
</div>
<p className='text-sm text-slate-200 leading-relaxed'>Work is broken down into manageable phases, ensuring transparent progress tracking and accountability.</p>
</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur hover:bg-white/10 transition-colors sm:col-span-2 lg:col-span-1'>
<div className='flex items-center gap-3 mb-3'>
<BaseIcon path={mdiCurrencyUsd} size={24} className='text-cyan-300' />
<h3 className='font-semibold text-lg'>Protected Payments</h3>
</div>
<p className='text-sm text-slate-200 leading-relaxed'>Funds are escrowed and released only when clients approve the completed milestones, ensuring mutual safety.</p>
</div>
</div>
<div className='mt-12 flex flex-wrap justify-center gap-4'>
<BaseButton color='info' href='/register' label='Get Started Today' />
<BaseButton color='white' href='/login' label='Log in to your account' />
</div>
</div>
</div>
</section>
</main>
</div>
</>
);
}
HowItWorksPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,166 +1,238 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import {
mdiAccountTie,
mdiArrowRight,
mdiBriefcaseOutline,
mdiCheckCircle,
mdiClockOutline,
mdiDomain,
mdiLogin,
mdiShieldCheckOutline,
mdiStarCircleOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import PublicHeader from '../components/PublicHeader';
const trustItems = [
'ERP implementation and support projects',
'Vetted freelancers and transparent matching',
'Secure operations for clients, experts, and admins',
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const workflowSteps = [
{
body: 'Describe the ERP stack, business scope, budget, and target start date in minutes.',
title: 'Post a project brief',
},
{
body: 'Surface specialists with the right ERP, module, and delivery background for the engagement.',
title: 'Review expert matches',
},
{
body: 'Move into proposals, shortlisting, and project delivery with a clean operational workflow.',
title: 'Hire with confidence',
},
];
const title = 'FreelanceERP'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const valueProps = [
{
body: 'Designed for Odoo, SAP, Oracle, Zoho, and adjacent ERP ecosystems instead of generic freelance work.',
icon: mdiDomain,
title: 'ERP-native marketplace',
},
{
body: 'A professional tone, fast project intake, and clear status tracking create trust for both sides of the market.',
icon: mdiShieldCheckOutline,
title: 'Trust-first experience',
},
{
body: 'Clients, freelancers, and admins each get a practical workflow instead of a disconnected set of screens.',
icon: mdiBriefcaseOutline,
title: 'Operationally useful from day one',
},
];
export default function HomePage() {
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('FreelanceERP')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your FreelanceERP app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className='min-h-screen bg-[#F6F8FC] text-slate-900'>
<PublicHeader />
<main>
<section className='mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[1.15fr,0.85fr] lg:items-center lg:py-24'>
<div>
<div className='inline-flex items-center gap-2 rounded-full border border-sky-200 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-sky-700 shadow-sm'>
<span className='h-2 w-2 rounded-full bg-emerald-400' />
Specialized ERP talent platform
</div>
<h1 className='mt-6 max-w-4xl text-5xl font-semibold tracking-tight text-slate-950 md:text-6xl'>
Hire vetted ERP experts faster for Odoo, SAP, Oracle, Zoho, and beyond.
</h1>
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-600'>
FreelanceERP helps companies post implementation, migration, support, and optimization projects,
then quickly connect with qualified freelancers in a secure and professional workflow.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<BaseButton color='info' href='/register' label='Start posting projects' />
<BaseButton color='white' href='/register' label='Join as a freelancer' />
</div>
<div className='mt-10 grid gap-3 sm:grid-cols-3'>
{trustItems.map((item) => (
<div key={item} className='rounded-2xl border border-slate-200 bg-white px-4 py-4 shadow-sm'>
<div className='flex items-start gap-3'>
<div className='mt-1 flex h-8 w-8 items-center justify-center rounded-xl bg-slate-900'>
<BaseIcon path={mdiCheckCircle} size={18} className='text-emerald-300' />
</div>
<p className='text-sm leading-6 text-slate-600'>{item}</p>
</div>
</div>
))}
</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<div className='rounded-[32px] border border-slate-200 bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-900 p-6 text-white shadow-2xl shadow-slate-900/10'>
<div className='rounded-[28px] border border-white/10 bg-white/10 p-5 backdrop-blur'>
<div className='flex items-center justify-between gap-4'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-cyan-100'>
ERP Match Desk
</p>
<p className='mt-2 text-2xl font-semibold tracking-tight'>Client-first MVP workflow</p>
</div>
<div className='rounded-2xl bg-white/10 p-3'>
<BaseIcon path={mdiStarCircleOutline} size={26} className='text-cyan-200' />
</div>
</div>
</div>
<div className='mt-6 grid gap-4'>
<div className='rounded-2xl border border-white/10 bg-slate-900/40 p-4'>
<p className='text-sm font-semibold text-white'>Project brief</p>
<p className='mt-2 text-sm leading-6 text-slate-200'>
Capture ERP, scope, budget, timing, and skill requirements in one guided intake.
</p>
</div>
<div className='rounded-2xl border border-white/10 bg-slate-900/40 p-4'>
<p className='text-sm font-semibold text-white'>Expert shortlist</p>
<p className='mt-2 text-sm leading-6 text-slate-200'>
Immediately preview freelancers scored on ERP overlap, availability, and verification status.
</p>
</div>
<div className='rounded-2xl border border-white/10 bg-slate-900/40 p-4'>
<p className='text-sm font-semibold text-white'>Operational follow-through</p>
<p className='mt-2 text-sm leading-6 text-slate-200'>
Continue into proposals, conversations, and delivery tracking through the admin workspace.
</p>
</div>
</div>
<Link
href='/login'
className='mt-6 inline-flex items-center gap-2 text-sm font-semibold text-cyan-100 transition hover:text-white'
>
Sign in to launch the workflow
<BaseIcon path={mdiArrowRight} size={18} className='text-cyan-100' />
</Link>
</div>
</div>
</section>
<section className='mx-auto max-w-7xl px-6 pb-6'>
<div className='rounded-[32px] border border-slate-200 bg-white p-8 shadow-sm'>
<div className='flex flex-col gap-3 md:flex-row md:items-end md:justify-between'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.24em] text-sky-700'>
How FreelanceERP works
</p>
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-slate-950'>
A focused first workflow for ERP delivery teams
</h2>
</div>
<p className='max-w-2xl text-sm leading-7 text-slate-500'>
The first release should feel like a working marketplace, not just a generated admin app.
This flow is intentionally thin, but it already supports real client action.
</p>
</div>
<div className='mt-8 grid gap-4 lg:grid-cols-3'>
{workflowSteps.map((step, index) => (
<div key={step.title} className='rounded-3xl border border-slate-200 bg-slate-50 p-5'>
<div className='flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-900 text-sm font-semibold text-white'>
0{index + 1}
</div>
<h3 className='mt-5 text-xl font-semibold tracking-tight text-slate-900'>{step.title}</h3>
<p className='mt-3 text-sm leading-7 text-slate-600'>{step.body}</p>
</div>
))}
</div>
</div>
</section>
<section className='mx-auto max-w-7xl px-6 py-12'>
<div className='grid gap-6 lg:grid-cols-3'>
{valueProps.map((item) => (
<div key={item.title} className='rounded-[28px] border border-slate-200 bg-white p-6 shadow-sm'>
<div className='flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900'>
<BaseIcon path={item.icon} size={22} className='text-cyan-200' />
</div>
<h3 className='mt-5 text-xl font-semibold tracking-tight text-slate-900'>{item.title}</h3>
<p className='mt-3 text-sm leading-7 text-slate-600'>{item.body}</p>
</div>
))}
</div>
</section>
<section className='mx-auto max-w-7xl px-6 pb-20'>
<div className='rounded-[32px] border border-slate-200 bg-gradient-to-r from-slate-950 via-slate-900 to-cyan-900 px-8 py-10 text-white shadow-2xl shadow-slate-900/10'>
<div className='grid gap-8 lg:grid-cols-[1.3fr,0.7fr] lg:items-center'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.24em] text-cyan-100'>
Ready for the first iteration?
</p>
<h2 className='mt-4 text-3xl font-semibold tracking-tight md:text-4xl'>
Log in, open the admin interface, and start posting ERP projects today.
</h2>
<p className='mt-4 max-w-2xl text-base leading-7 text-slate-200'>
The new MVP slice is built around project intake, matching, and next-step visibility. It is a
strong starting point for clients, freelancers, and admins to collaborate inside one system.
</p>
</div>
<div className='grid gap-3 rounded-[28px] border border-white/10 bg-white/10 p-5 backdrop-blur'>
<BaseButton color='info' href='/login' label='Login to continue' />
<BaseButton color='white' href='/dashboard' label='Go to admin interface' />
<div className='mt-2 flex items-center gap-3 text-sm text-slate-200'>
<BaseIcon path={mdiClockOutline} size={18} className='text-cyan-100' />
<span>Guided project intake + recent marketplace activity</span>
</div>
<div className='flex items-center gap-3 text-sm text-slate-200'>
<BaseIcon path={mdiAccountTie} size={18} className='text-cyan-100' />
<span>Faster shortlisting for ERP specialists</span>
</div>
<div className='flex items-center gap-3 text-sm text-slate-200'>
<BaseIcon path={mdiLogin} size={18} className='text-cyan-100' />
<span>Keep the login path and admin workspace one click away</span>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -188,7 +188,13 @@ export default function Login() {
data-password="875782093eb4"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>875782093eb4</code>{' / '}
to login as User</p>
to login as Client</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="875782093eb4"
onClick={(e) => setLogin(e.target)}>freelancer@hello.com</code>{' / '}
<code className={`${textColor}`}>875782093eb4</code>{' / '}
to login as Freelancer</p>
</div>
<div>
<BaseIcon

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated';

View File

@ -0,0 +1,157 @@
import { mdiCog } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import CardBox from '../components/CardBox';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import FormField from '../components/FormField';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseDivider from '../components/BaseDivider';
import axios from 'axios';
import { useAppSelector } from '../stores/hooks';
const SettingsPage = () => {
const [stripePublicKey, setStripePublicKey] = useState('');
const [stripeSecretKey, setStripeSecretKey] = useState('');
const [brandColor, setBrandColor] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const fetchSettings = async () => {
try {
setLoading(true);
const res = await axios.get('/settings/admin');
if (res.data) {
setStripePublicKey(res.data.stripePublicKey || '');
setStripeSecretKey(res.data.stripeSecretKey || '');
setBrandColor(res.data.brandColor || '');
}
} catch (e) {
console.error(e);
setMessage('Failed to load settings');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
setSaving(true);
setMessage('');
await axios.put('/settings/admin', {
stripePublicKey,
stripeSecretKey,
brandColor,
});
setMessage('Settings saved successfully!');
} catch (e) {
console.error(e);
setMessage('Failed to save settings');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<SectionMain>
<p>Loading...</p>
</SectionMain>
);
}
return (
<>
<Head>
<title>{getPageTitle('System Settings')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiCog} title="System Settings" main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<div className="mb-4 text-sm text-gray-500">
Manage global settings for the platform, including payment gateway integrations and branding.
</div>
{message && (
<div className={`mb-4 p-4 rounded ${message.includes('success') ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{message}
</div>
)}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 className="mb-4 text-lg font-bold">Payment Gateway (Stripe)</h3>
<FormField label="Stripe Public Key" help="Your publishable API key from the Stripe dashboard.">
<input
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring focus:ring-blue-200"
type="text"
value={stripePublicKey}
onChange={(e) => setStripePublicKey(e.target.value)}
placeholder="pk_test_..."
/>
</FormField>
<FormField label="Stripe Secret Key" help="Your secret API key from the Stripe dashboard. Keep this secure.">
<input
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring focus:ring-blue-200"
type="password"
value={stripeSecretKey}
onChange={(e) => setStripeSecretKey(e.target.value)}
placeholder="sk_test_..."
/>
</FormField>
</div>
<div>
<h3 className="mb-4 text-lg font-bold">Branding</h3>
<FormField label="Brand Color (Hex)" help="The primary color used for buttons, links, and highlights (e.g. #2563EB). Reload page to apply.">
<div className="flex items-center space-x-2">
<input
className="w-12 h-10 p-1 border border-gray-300 rounded"
type="color"
value={brandColor || '#2563eb'}
onChange={(e) => setBrandColor(e.target.value)}
/>
<input
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring focus:ring-blue-200"
type="text"
value={brandColor}
onChange={(e) => setBrandColor(e.target.value)}
placeholder="#2563eb"
/>
</div>
</FormField>
</div>
</div>
<BaseDivider />
<BaseButtons>
<BaseButton
color="info"
label={saving ? 'Saving...' : 'Save Settings'}
onClick={handleSave}
disabled={saving}
/>
</BaseButtons>
</CardBox>
</SectionMain>
</>
);
};
SettingsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default SettingsPage;

3
get_app.js Normal file
View File

@ -0,0 +1,3 @@
const fs = require('fs');
let file = fs.readFileSync('frontend/src/pages/_app.tsx', 'utf8');
console.log(file.substring(0, 1000));

3
get_app_2.js Normal file
View File

@ -0,0 +1,3 @@
const fs = require('fs');
let file = fs.readFileSync('frontend/src/pages/_app.tsx', 'utf8');
console.log(file.substring(1000, 2500));

30
inject_color.js Normal file
View File

@ -0,0 +1,30 @@
const fs = require('fs');
let file = fs.readFileSync('frontend/src/pages/_app.tsx', 'utf8');
const hookStr = "\n" +
" React.useEffect(() => {\n" +
" // Fetch global settings\n" +
" axios.get('/settings/public').then((res) => {\n" +
" if (res.data && res.data.brandColor) {\n" +
" const color = res.data.brandColor;\n" +
" document.documentElement.style.setProperty('--brand-color', color);\n" +
" const style = document.createElement('style');\n" +
" style.innerHTML = `\n" +
" .bg-blue-600 { background-color: ${color} !important; }\n" +
" .bg-blue-500 { background-color: ${color} !important; }\n" +
" .text-blue-600 { color: ${color} !important; }\n" +
" .text-blue-500 { color: ${color} !important; }\n" +
" .border-blue-600 { border-color: ${color} !important; }\n" +
" .border-blue-500 { border-color: ${color} !important; }\n" +
" .ring-blue-200 { --tw-ring-color: ${color} !important; }\n" +
" .focus:ring-blue-500:focus { --tw-ring-color: ${color} !important; }\n" +
" .focus:border-blue-500:focus { border-color: ${color} !important; }\n" +
" `;\n" +
" document.head.appendChild(style);\n" +
" }\n" +
" }).catch(console.error);\n" +
" }, []);\n";
file = file.replace(/function MyApp\(\{ Component, pageProps \}: AppPropsWithLayout\) {/g, "function MyApp({ Component, pageProps }: AppPropsWithLayout) {" + hookStr);
fs.writeFileSync('frontend/src/pages/_app.tsx', file);