Add core architecture, API endpoints, and UI components for NEXUS
Introduces NEXUS Core architecture documentation, new API endpoints for escrow, payroll, talent profiles, time logs, and UI components for financial dashboards and compliance tracking. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: e82c1588-4c11-4961-b289-6ab581ed9691 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/aPpJgbb Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
60cb39be3c
commit
e60e71476f
18 changed files with 2448 additions and 0 deletions
124
api/corp/escrow.ts
Normal file
124
api/corp/escrow.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
|
||||
if (authError || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const { contract_id } = req.query;
|
||||
|
||||
let query = supabase
|
||||
.from('nexus_escrow_ledger')
|
||||
.select('*')
|
||||
.or(`client_id.eq.${user.id},creator_id.eq.${user.id}`);
|
||||
|
||||
if (contract_id) {
|
||||
query = query.eq('contract_id', contract_id);
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const { contract_id, amount } = req.body;
|
||||
|
||||
if (!contract_id || !amount) {
|
||||
return res.status(400).json({ error: 'contract_id and amount required' });
|
||||
}
|
||||
|
||||
const { data: contract } = await supabase
|
||||
.from('nexus_contracts')
|
||||
.select('id, client_id, creator_id, status')
|
||||
.eq('id', contract_id)
|
||||
.single();
|
||||
|
||||
if (!contract) {
|
||||
return res.status(404).json({ error: 'Contract not found' });
|
||||
}
|
||||
|
||||
if (contract.client_id !== user.id) {
|
||||
return res.status(403).json({ error: 'Only the client can fund escrow' });
|
||||
}
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('nexus_escrow_ledger')
|
||||
.select('id, escrow_balance, funds_deposited')
|
||||
.eq('contract_id', contract_id)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_escrow_ledger')
|
||||
.update({
|
||||
escrow_balance: existing.escrow_balance + amount,
|
||||
funds_deposited: existing.funds_deposited + amount,
|
||||
status: 'funded',
|
||||
funded_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', existing.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_escrow_ledger')
|
||||
.insert({
|
||||
contract_id: contract_id,
|
||||
client_id: contract.client_id,
|
||||
creator_id: contract.creator_id,
|
||||
escrow_balance: amount,
|
||||
funds_deposited: amount,
|
||||
status: 'funded',
|
||||
funded_at: new Date().toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
await supabase.from('nexus_compliance_events').insert({
|
||||
entity_type: 'escrow',
|
||||
entity_id: data.id,
|
||||
event_type: 'escrow_funded',
|
||||
event_category: 'financial',
|
||||
actor_id: user.id,
|
||||
actor_role: 'client',
|
||||
realm_context: 'corp',
|
||||
description: `Escrow funded with $${amount}`,
|
||||
payload: { contract_id, amount },
|
||||
financial_amount: amount,
|
||||
legal_entity: 'for_profit'
|
||||
});
|
||||
|
||||
return res.status(201).json({ data });
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
143
api/corp/payroll.ts
Normal file
143
api/corp/payroll.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
|
||||
if (authError || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const { data: userProfile } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('user_type')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userProfile?.user_type !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const { status, start_date, end_date, tax_year } = req.query;
|
||||
|
||||
let query = supabase
|
||||
.from('nexus_payouts')
|
||||
.select(`
|
||||
*,
|
||||
nexus_talent_profiles!inner(
|
||||
user_id,
|
||||
legal_first_name,
|
||||
legal_last_name,
|
||||
tax_classification,
|
||||
residency_state
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (status) query = query.eq('status', status);
|
||||
if (tax_year) query = query.eq('tax_year', tax_year);
|
||||
if (start_date) query = query.gte('scheduled_date', start_date);
|
||||
if (end_date) query = query.lte('scheduled_date', end_date);
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
const totalPending = data?.filter(p => p.status === 'pending').reduce((sum, p) => sum + Number(p.net_amount), 0) || 0;
|
||||
const totalProcessed = data?.filter(p => p.status === 'completed').reduce((sum, p) => sum + Number(p.net_amount), 0) || 0;
|
||||
|
||||
return res.status(200).json({
|
||||
data,
|
||||
summary: {
|
||||
total_payouts: data?.length || 0,
|
||||
pending_amount: totalPending,
|
||||
processed_amount: totalProcessed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.query.action === 'process') {
|
||||
const { payout_ids } = req.body;
|
||||
|
||||
if (!payout_ids || !Array.isArray(payout_ids)) {
|
||||
return res.status(400).json({ error: 'payout_ids array required' });
|
||||
}
|
||||
|
||||
const { data: payouts } = await supabase
|
||||
.from('nexus_payouts')
|
||||
.select('*')
|
||||
.in('id', payout_ids)
|
||||
.eq('status', 'pending');
|
||||
|
||||
if (!payouts || payouts.length === 0) {
|
||||
return res.status(400).json({ error: 'No pending payouts found' });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_payouts')
|
||||
.update({
|
||||
status: 'processing',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.in('id', payout_ids)
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
await supabase.from('nexus_compliance_events').insert({
|
||||
entity_type: 'payroll',
|
||||
entity_id: user.id,
|
||||
event_type: 'payroll_batch_processing',
|
||||
event_category: 'financial',
|
||||
actor_id: user.id,
|
||||
actor_role: 'admin',
|
||||
realm_context: 'corp',
|
||||
description: `Processing ${data?.length} payouts`,
|
||||
payload: { payout_ids, total_amount: data?.reduce((sum, p) => sum + Number(p.net_amount), 0) },
|
||||
legal_entity: 'for_profit'
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
data,
|
||||
processed_count: data?.length || 0
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.query.action === 'summary') {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const { data: yearPayouts } = await supabase
|
||||
.from('nexus_payouts')
|
||||
.select('net_amount, status, tax_year')
|
||||
.eq('tax_year', currentYear);
|
||||
|
||||
const { data: azHours } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.select('az_eligible_hours')
|
||||
.eq('submission_status', 'approved')
|
||||
.gte('log_date', `${currentYear}-01-01`);
|
||||
|
||||
return res.status(200).json({
|
||||
tax_year: currentYear,
|
||||
total_payouts: yearPayouts?.filter(p => p.status === 'completed').reduce((sum, p) => sum + Number(p.net_amount), 0) || 0,
|
||||
pending_payouts: yearPayouts?.filter(p => p.status === 'pending').reduce((sum, p) => sum + Number(p.net_amount), 0) || 0,
|
||||
total_az_hours: azHours?.reduce((sum, h) => sum + Number(h.az_eligible_hours), 0) || 0,
|
||||
payout_count: yearPayouts?.length || 0
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
78
api/foundation/gig-radar.ts
Normal file
78
api/foundation/gig-radar.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
|
||||
if (authError || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const { category, skills, experience, limit = 20, offset = 0 } = req.query;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('foundation_gig_radar')
|
||||
.select('*')
|
||||
.order('published_at', { ascending: false })
|
||||
.range(Number(offset), Number(offset) + Number(limit) - 1);
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
let filteredData = data || [];
|
||||
|
||||
if (category) {
|
||||
filteredData = filteredData.filter(d => d.category === category);
|
||||
}
|
||||
|
||||
if (skills) {
|
||||
const skillsArray = (skills as string).split(',');
|
||||
filteredData = filteredData.filter(d =>
|
||||
d.required_skills.some((s: string) => skillsArray.includes(s))
|
||||
);
|
||||
}
|
||||
|
||||
if (experience) {
|
||||
filteredData = filteredData.filter(d => d.required_experience === experience);
|
||||
}
|
||||
|
||||
await supabase.from('nexus_compliance_events').insert({
|
||||
entity_type: 'gig_radar',
|
||||
entity_id: user.id,
|
||||
event_type: 'gig_radar_accessed',
|
||||
event_category: 'access',
|
||||
actor_id: user.id,
|
||||
actor_role: 'user',
|
||||
realm_context: 'foundation',
|
||||
description: 'Foundation user accessed Gig Radar',
|
||||
payload: {
|
||||
filters: { category, skills, experience },
|
||||
results_count: filteredData.length
|
||||
},
|
||||
sensitive_data_accessed: false,
|
||||
cross_entity_access: true,
|
||||
legal_entity: 'non_profit'
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
data: filteredData,
|
||||
meta: {
|
||||
total: filteredData.length,
|
||||
limit: Number(limit),
|
||||
offset: Number(offset)
|
||||
}
|
||||
});
|
||||
}
|
||||
84
api/nexus-core/talent-profiles.ts
Normal file
84
api/nexus-core/talent-profiles.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
|
||||
if (authError || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_talent_profiles')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = req.body;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_talent_profiles')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
legal_first_name: body.legal_first_name,
|
||||
legal_last_name: body.legal_last_name,
|
||||
tax_classification: body.tax_classification,
|
||||
residency_state: body.residency_state,
|
||||
residency_country: body.residency_country || 'US',
|
||||
address_city: body.address_city,
|
||||
address_state: body.address_state,
|
||||
address_zip: body.address_zip,
|
||||
updated_at: new Date().toISOString()
|
||||
}, { onConflict: 'user_id' })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
await supabase.from('nexus_compliance_events').insert({
|
||||
entity_type: 'talent',
|
||||
entity_id: data.id,
|
||||
event_type: 'profile_updated',
|
||||
event_category: 'data_change',
|
||||
actor_id: user.id,
|
||||
actor_role: 'talent',
|
||||
realm_context: 'nexus',
|
||||
description: 'Talent profile updated',
|
||||
payload: { fields_updated: Object.keys(body) }
|
||||
});
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.query.action === 'compliance-summary') {
|
||||
const { data, error } = await supabase
|
||||
.rpc('get_talent_compliance_summary', { p_user_id: user.id });
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
102
api/nexus-core/time-logs-approve.ts
Normal file
102
api/nexus-core/time-logs-approve.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
|
||||
if (authError || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const { data: userProfile } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('user_type')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const { time_log_id, decision, notes } = req.body;
|
||||
|
||||
if (!time_log_id || !decision) {
|
||||
return res.status(400).json({ error: 'time_log_id and decision required' });
|
||||
}
|
||||
|
||||
if (!['approved', 'rejected', 'needs_correction'].includes(decision)) {
|
||||
return res.status(400).json({ error: 'Invalid decision. Must be: approved, rejected, or needs_correction' });
|
||||
}
|
||||
|
||||
const { data: timeLog } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.select('*, nexus_contracts!inner(client_id)')
|
||||
.eq('id', time_log_id)
|
||||
.single();
|
||||
|
||||
if (!timeLog) {
|
||||
return res.status(404).json({ error: 'Time log not found' });
|
||||
}
|
||||
|
||||
const isClient = timeLog.nexus_contracts?.client_id === user.id;
|
||||
const isAdmin = userProfile?.user_type === 'admin';
|
||||
|
||||
if (!isClient && !isAdmin) {
|
||||
return res.status(403).json({ error: 'Only the contract client or admin can approve time logs' });
|
||||
}
|
||||
|
||||
if (timeLog.submission_status !== 'submitted') {
|
||||
return res.status(400).json({ error: 'Time log must be in submitted status to approve/reject' });
|
||||
}
|
||||
|
||||
const newStatus = decision === 'approved' ? 'approved' :
|
||||
decision === 'rejected' ? 'rejected' : 'rejected';
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.update({
|
||||
submission_status: newStatus,
|
||||
approved_at: decision === 'approved' ? new Date().toISOString() : null,
|
||||
approved_by: decision === 'approved' ? user.id : null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', time_log_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
await supabase.from('nexus_time_log_audits').insert({
|
||||
time_log_id: time_log_id,
|
||||
reviewer_id: user.id,
|
||||
audit_type: decision === 'approved' ? 'approval' : 'rejection',
|
||||
decision: decision,
|
||||
notes: notes,
|
||||
ip_address: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress,
|
||||
user_agent: req.headers['user-agent']
|
||||
});
|
||||
|
||||
await supabase.from('nexus_compliance_events').insert({
|
||||
entity_type: 'time_log',
|
||||
entity_id: time_log_id,
|
||||
event_type: `time_log_${decision}`,
|
||||
event_category: 'compliance',
|
||||
actor_id: user.id,
|
||||
actor_role: isAdmin ? 'admin' : 'client',
|
||||
realm_context: 'nexus',
|
||||
description: `Time log ${decision} by ${isAdmin ? 'admin' : 'client'}`,
|
||||
payload: { decision, notes }
|
||||
});
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
104
api/nexus-core/time-logs-submit.ts
Normal file
104
api/nexus-core/time-logs-submit.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
|
||||
if (authError || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const { data: talentProfile } = await supabase
|
||||
.from('nexus_talent_profiles')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (!talentProfile) {
|
||||
return res.status(400).json({ error: 'Talent profile not found' });
|
||||
}
|
||||
|
||||
const { time_log_ids } = req.body;
|
||||
|
||||
if (!time_log_ids || !Array.isArray(time_log_ids) || time_log_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'time_log_ids array required' });
|
||||
}
|
||||
|
||||
const { data: logs, error: fetchError } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.select('id, submission_status')
|
||||
.in('id', time_log_ids)
|
||||
.eq('talent_profile_id', talentProfile.id);
|
||||
|
||||
if (fetchError) {
|
||||
return res.status(500).json({ error: fetchError.message });
|
||||
}
|
||||
|
||||
const invalidLogs = logs?.filter(l => l.submission_status !== 'draft' && l.submission_status !== 'rejected');
|
||||
if (invalidLogs && invalidLogs.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Some time logs cannot be submitted',
|
||||
invalid_ids: invalidLogs.map(l => l.id)
|
||||
});
|
||||
}
|
||||
|
||||
const validIds = logs?.map(l => l.id) || [];
|
||||
if (validIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid time logs found' });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.update({
|
||||
submission_status: 'submitted',
|
||||
submitted_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.in('id', validIds)
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
for (const log of data || []) {
|
||||
await supabase.from('nexus_time_log_audits').insert({
|
||||
time_log_id: log.id,
|
||||
reviewer_id: null,
|
||||
audit_type: 'review',
|
||||
decision: 'submitted',
|
||||
notes: 'Time log submitted for review',
|
||||
ip_address: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress,
|
||||
user_agent: req.headers['user-agent']
|
||||
});
|
||||
}
|
||||
|
||||
await supabase.from('nexus_compliance_events').insert({
|
||||
entity_type: 'time_log',
|
||||
entity_id: talentProfile.id,
|
||||
event_type: 'batch_submitted',
|
||||
event_category: 'compliance',
|
||||
actor_id: user.id,
|
||||
actor_role: 'talent',
|
||||
realm_context: 'nexus',
|
||||
description: `Submitted ${data?.length} time logs for review`,
|
||||
payload: { time_log_ids: validIds }
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
data,
|
||||
submitted_count: data?.length || 0
|
||||
});
|
||||
}
|
||||
170
api/nexus-core/time-logs.ts
Normal file
170
api/nexus-core/time-logs.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
|
||||
if (authError || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const { data: talentProfile } = await supabase
|
||||
.from('nexus_talent_profiles')
|
||||
.select('id, az_eligible')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (!talentProfile) {
|
||||
return res.status(400).json({ error: 'Talent profile not found. Create one first.' });
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const { contract_id, start_date, end_date, status } = req.query;
|
||||
|
||||
let query = supabase
|
||||
.from('nexus_time_logs')
|
||||
.select('*')
|
||||
.eq('talent_profile_id', talentProfile.id)
|
||||
.order('log_date', { ascending: false });
|
||||
|
||||
if (contract_id) query = query.eq('contract_id', contract_id);
|
||||
if (start_date) query = query.gte('log_date', start_date);
|
||||
if (end_date) query = query.lte('log_date', end_date);
|
||||
if (status) query = query.eq('submission_status', status);
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = req.body;
|
||||
|
||||
const azEligibleHours = body.location_state === 'AZ' && talentProfile.az_eligible
|
||||
? body.hours_worked
|
||||
: 0;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.insert({
|
||||
talent_profile_id: talentProfile.id,
|
||||
contract_id: body.contract_id,
|
||||
milestone_id: body.milestone_id,
|
||||
log_date: body.log_date,
|
||||
start_time: body.start_time,
|
||||
end_time: body.end_time,
|
||||
hours_worked: body.hours_worked,
|
||||
description: body.description,
|
||||
task_category: body.task_category,
|
||||
location_type: body.location_type || 'remote',
|
||||
location_state: body.location_state,
|
||||
location_city: body.location_city,
|
||||
location_latitude: body.location_latitude,
|
||||
location_longitude: body.location_longitude,
|
||||
az_eligible_hours: azEligibleHours,
|
||||
billable: body.billable !== false,
|
||||
submission_status: 'draft'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(201).json({ data });
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const { id } = req.query;
|
||||
const body = req.body;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Time log ID required' });
|
||||
}
|
||||
|
||||
const { data: existingLog } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('talent_profile_id', talentProfile.id)
|
||||
.single();
|
||||
|
||||
if (!existingLog) {
|
||||
return res.status(404).json({ error: 'Time log not found' });
|
||||
}
|
||||
|
||||
if (existingLog.submission_status !== 'draft' && existingLog.submission_status !== 'rejected') {
|
||||
return res.status(400).json({ error: 'Can only edit draft or rejected time logs' });
|
||||
}
|
||||
|
||||
const azEligibleHours = (body.location_state || existingLog.location_state) === 'AZ' && talentProfile.az_eligible
|
||||
? (body.hours_worked || existingLog.hours_worked)
|
||||
: 0;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.update({
|
||||
...body,
|
||||
az_eligible_hours: azEligibleHours,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const { id } = req.query;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Time log ID required' });
|
||||
}
|
||||
|
||||
const { data: existingLog } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.select('submission_status')
|
||||
.eq('id', id)
|
||||
.eq('talent_profile_id', talentProfile.id)
|
||||
.single();
|
||||
|
||||
if (!existingLog) {
|
||||
return res.status(404).json({ error: 'Time log not found' });
|
||||
}
|
||||
|
||||
if (existingLog.submission_status !== 'draft') {
|
||||
return res.status(400).json({ error: 'Can only delete draft time logs' });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
86
api/studio/contracts.ts
Normal file
86
api/studio/contracts.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
const STUDIO_API_KEY = process.env.STUDIO_API_KEY;
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const apiKey = req.headers['x-studio-api-key'] || req.headers['authorization']?.replace('Bearer ', '');
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
let userId: string | null = null;
|
||||
let isServiceAuth = false;
|
||||
|
||||
if (apiKey === STUDIO_API_KEY && STUDIO_API_KEY) {
|
||||
isServiceAuth = true;
|
||||
} else if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error } = await supabase.auth.getUser(token);
|
||||
if (error || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
userId = user.id;
|
||||
} else {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { id } = req.query;
|
||||
|
||||
if (id) {
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_contracts')
|
||||
.select(`
|
||||
id, title, status, contract_type,
|
||||
start_date, end_date,
|
||||
creator_id, client_id
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(error.code === 'PGRST116' ? 404 : 500).json({ error: error.message });
|
||||
}
|
||||
|
||||
if (!isServiceAuth && data.creator_id !== userId && data.client_id !== userId) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
data: {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
status: data.status,
|
||||
contract_type: data.contract_type,
|
||||
start_date: data.start_date,
|
||||
end_date: data.end_date,
|
||||
is_creator: data.creator_id === userId,
|
||||
is_client: data.client_id === userId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!userId && !isServiceAuth) {
|
||||
return res.status(400).json({ error: 'user_id required for listing contracts' });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_contracts')
|
||||
.select(`
|
||||
id, title, status, contract_type,
|
||||
start_date, end_date
|
||||
`)
|
||||
.or(`creator_id.eq.${userId},client_id.eq.${userId}`)
|
||||
.in('status', ['active', 'pending'])
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
142
api/studio/time-logs.ts
Normal file
142
api/studio/time-logs.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||
import { getAdminClient } from "../_supabase";
|
||||
|
||||
const STUDIO_API_KEY = process.env.STUDIO_API_KEY;
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const supabase = getAdminClient();
|
||||
|
||||
const apiKey = req.headers['x-studio-api-key'] || req.headers['authorization']?.replace('Bearer ', '');
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
let userId: string | null = null;
|
||||
let isServiceAuth = false;
|
||||
|
||||
if (apiKey === STUDIO_API_KEY && STUDIO_API_KEY) {
|
||||
isServiceAuth = true;
|
||||
} else if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const { data: { user }, error } = await supabase.auth.getUser(token);
|
||||
if (error || !user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
userId = user.id;
|
||||
} else {
|
||||
return res.status(401).json({ error: 'Unauthorized - requires Bearer token or X-Studio-API-Key' });
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = req.body;
|
||||
|
||||
if (!body.user_id && !userId) {
|
||||
return res.status(400).json({ error: 'user_id required for service auth' });
|
||||
}
|
||||
|
||||
const targetUserId = body.user_id || userId;
|
||||
|
||||
const { data: talentProfile } = await supabase
|
||||
.from('nexus_talent_profiles')
|
||||
.select('id, az_eligible')
|
||||
.eq('user_id', targetUserId)
|
||||
.single();
|
||||
|
||||
if (!talentProfile) {
|
||||
return res.status(400).json({ error: 'Talent profile not found for user' });
|
||||
}
|
||||
|
||||
const azEligibleHours = body.location_state === 'AZ' && talentProfile.az_eligible
|
||||
? body.hours_worked
|
||||
: 0;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('nexus_time_logs')
|
||||
.insert({
|
||||
talent_profile_id: talentProfile.id,
|
||||
contract_id: body.contract_id,
|
||||
log_date: body.log_date,
|
||||
start_time: body.start_time,
|
||||
end_time: body.end_time,
|
||||
hours_worked: body.hours_worked,
|
||||
description: body.description,
|
||||
task_category: body.task_category,
|
||||
location_type: body.location_type || 'remote',
|
||||
location_state: body.location_state,
|
||||
location_city: body.location_city,
|
||||
location_latitude: body.location_latitude,
|
||||
location_longitude: body.location_longitude,
|
||||
location_verified: !!body.location_latitude && !!body.location_longitude,
|
||||
az_eligible_hours: azEligibleHours,
|
||||
billable: body.billable !== false,
|
||||
submission_status: 'submitted',
|
||||
submitted_at: new Date().toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
await supabase.from('nexus_compliance_events').insert({
|
||||
entity_type: 'time_log',
|
||||
entity_id: data.id,
|
||||
event_type: 'studio_time_log_created',
|
||||
event_category: 'compliance',
|
||||
actor_id: isServiceAuth ? null : userId,
|
||||
actor_role: isServiceAuth ? 'api' : 'talent',
|
||||
realm_context: 'studio',
|
||||
description: 'Time log submitted via Studio API',
|
||||
payload: {
|
||||
source: 'studio_api',
|
||||
location_verified: data.location_verified,
|
||||
az_eligible_hours: azEligibleHours
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json({ data });
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const { contract_id, start_date, end_date, user_id: queryUserId } = req.query;
|
||||
|
||||
const targetUserId = queryUserId || userId;
|
||||
|
||||
if (!targetUserId && !isServiceAuth) {
|
||||
return res.status(400).json({ error: 'user_id required' });
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('nexus_time_logs')
|
||||
.select(`
|
||||
id, log_date, start_time, end_time, hours_worked,
|
||||
location_state, az_eligible_hours, submission_status,
|
||||
contract_id
|
||||
`);
|
||||
|
||||
if (targetUserId) {
|
||||
const { data: talentProfile } = await supabase
|
||||
.from('nexus_talent_profiles')
|
||||
.select('id')
|
||||
.eq('user_id', targetUserId)
|
||||
.single();
|
||||
|
||||
if (talentProfile) {
|
||||
query = query.eq('talent_profile_id', talentProfile.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (contract_id) query = query.eq('contract_id', contract_id);
|
||||
if (start_date) query = query.gte('log_date', start_date);
|
||||
if (end_date) query = query.lte('log_date', end_date);
|
||||
|
||||
const { data, error } = await query.order('log_date', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.status(200).json({ data });
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
135
client/components/nexus-core/ComplianceSummaryCard.tsx
Normal file
135
client/components/nexus-core/ComplianceSummaryCard.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Shield,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
CreditCard,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import type { TalentComplianceSummary, ComplianceStatus } from "@/lib/nexus-core-types";
|
||||
|
||||
interface ComplianceSummaryCardProps {
|
||||
summary: TalentComplianceSummary;
|
||||
onCompleteProfile?: () => void;
|
||||
onSubmitW9?: () => void;
|
||||
onConnectBank?: () => void;
|
||||
}
|
||||
|
||||
const statusConfig: Record<ComplianceStatus, { color: string; icon: React.ReactNode; label: string }> = {
|
||||
pending: { color: "bg-amber-500/20 text-amber-300 border-amber-500/30", icon: <Clock className="w-4 h-4" />, label: "Pending Review" },
|
||||
verified: { color: "bg-green-500/20 text-green-300 border-green-500/30", icon: <CheckCircle className="w-4 h-4" />, label: "Verified" },
|
||||
expired: { color: "bg-red-500/20 text-red-300 border-red-500/30", icon: <AlertCircle className="w-4 h-4" />, label: "Expired" },
|
||||
rejected: { color: "bg-red-500/20 text-red-300 border-red-500/30", icon: <AlertCircle className="w-4 h-4" />, label: "Rejected" },
|
||||
review_needed: { color: "bg-amber-500/20 text-amber-300 border-amber-500/30", icon: <AlertCircle className="w-4 h-4" />, label: "Review Needed" },
|
||||
};
|
||||
|
||||
export function ComplianceSummaryCard({
|
||||
summary,
|
||||
onCompleteProfile,
|
||||
onSubmitW9,
|
||||
onConnectBank,
|
||||
}: ComplianceSummaryCardProps) {
|
||||
const status = statusConfig[summary.compliance_status];
|
||||
|
||||
const completionSteps = [
|
||||
{ done: summary.profile_complete, label: "Profile Complete", action: onCompleteProfile },
|
||||
{ done: summary.w9_submitted, label: "W-9 Submitted", action: onSubmitW9 },
|
||||
{ done: summary.bank_connected, label: "Bank Connected", action: onConnectBank },
|
||||
];
|
||||
|
||||
const completedCount = completionSteps.filter((s) => s.done).length;
|
||||
const completionPercent = (completedCount / completionSteps.length) * 100;
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-slate-900/80 to-slate-800/60 border-slate-700/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-cyan-400" />
|
||||
Compliance Status
|
||||
</CardTitle>
|
||||
<Badge className={`${status.color} border`}>
|
||||
<span className="flex items-center gap-1">
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Setup Progress</span>
|
||||
<span>{completedCount}/{completionSteps.length} complete</span>
|
||||
</div>
|
||||
<Progress value={completionPercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{completionSteps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 rounded-lg ${
|
||||
step.done ? "bg-green-500/10" : "bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{step.done ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-amber-400" />
|
||||
)}
|
||||
<span className={step.done ? "text-slate-300" : "text-slate-400"}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{!step.done && step.action && (
|
||||
<Button size="sm" variant="ghost" onClick={step.action} className="h-7 text-xs">
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-700/30">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-muted-foreground text-sm">
|
||||
<Clock className="w-3 h-3" />
|
||||
Hours This Month
|
||||
</div>
|
||||
<p className="text-xl font-bold">{summary.total_hours_this_month}h</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-muted-foreground text-sm">
|
||||
<MapPin className="w-3 h-3" />
|
||||
AZ Hours
|
||||
</div>
|
||||
<p className="text-xl font-bold text-emerald-400">{summary.az_hours_this_month}h</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.pending_time_logs > 0 && (
|
||||
<div className="flex items-center gap-2 p-2 bg-amber-500/10 rounded-lg border border-amber-500/20">
|
||||
<FileText className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm text-amber-300">
|
||||
{summary.pending_time_logs} time log(s) pending review
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.az_eligible && (
|
||||
<Badge className="w-full justify-center bg-emerald-500/20 text-emerald-300 border-emerald-500/30 py-1">
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
AZ Tax Credit Eligible
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
157
client/components/nexus-core/ContractOverviewCard.tsx
Normal file
157
client/components/nexus-core/ContractOverviewCard.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
FileText,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
User,
|
||||
Building,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ContractOverviewCardProps {
|
||||
contract: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
contract_type: string;
|
||||
total_amount: number;
|
||||
creator_payout_amount: number;
|
||||
aethex_commission_amount: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
milestone_count: number;
|
||||
completed_milestones?: number;
|
||||
};
|
||||
role: "creator" | "client";
|
||||
onViewDetails?: () => void;
|
||||
onViewTimeLogs?: () => void;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-amber-500/20 text-amber-300 border-amber-500/30",
|
||||
active: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
completed: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
disputed: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
cancelled: "bg-gray-500/20 text-gray-300 border-gray-500/30",
|
||||
};
|
||||
|
||||
export function ContractOverviewCard({
|
||||
contract,
|
||||
role,
|
||||
onViewDetails,
|
||||
onViewTimeLogs,
|
||||
}: ContractOverviewCardProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const completedMilestones = contract.completed_milestones || 0;
|
||||
const milestoneProgress = contract.milestone_count > 0
|
||||
? (completedMilestones / contract.milestone_count) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-slate-900/80 to-slate-800/60 border-slate-700/50 hover:border-slate-600/50 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base font-medium truncate">
|
||||
{contract.title}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{contract.contract_type.replace("_", " ")}
|
||||
</Badge>
|
||||
<Badge className={`${statusColors[contract.status]} border text-xs`}>
|
||||
{contract.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-emerald-400">
|
||||
{formatCurrency(role === "creator" ? contract.creator_payout_amount : contract.total_amount)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{role === "creator" ? "Your Payout" : "Contract Value"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{contract.milestone_count > 1 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Milestones</span>
|
||||
<span>{completedMilestones}/{contract.milestone_count}</span>
|
||||
</div>
|
||||
<Progress value={milestoneProgress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{contract.start_date && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Start: {formatDate(contract.start_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
{contract.end_date && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>End: {formatDate(contract.end_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{role === "client" && (
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-slate-700/30">
|
||||
<span className="text-muted-foreground">Platform Fee</span>
|
||||
<span className="text-slate-400">
|
||||
{formatCurrency(contract.aethex_commission_amount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{onViewDetails && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewDetails}
|
||||
className="flex-1"
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Details
|
||||
</Button>
|
||||
)}
|
||||
{onViewTimeLogs && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewTimeLogs}
|
||||
className="flex-1"
|
||||
>
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
Time Logs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
127
client/components/nexus-core/EscrowStatusCard.tsx
Normal file
127
client/components/nexus-core/EscrowStatusCard.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Shield, DollarSign, Lock, Unlock, AlertTriangle } from "lucide-react";
|
||||
import type { NexusEscrowLedger } from "@/lib/nexus-core-types";
|
||||
|
||||
interface EscrowStatusCardProps {
|
||||
escrow: NexusEscrowLedger;
|
||||
isClient?: boolean;
|
||||
onFundEscrow?: () => void;
|
||||
onReleaseRequest?: () => void;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
unfunded: "bg-gray-500/20 text-gray-300 border-gray-500/30",
|
||||
funded: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
partially_funded: "bg-amber-500/20 text-amber-300 border-amber-500/30",
|
||||
released: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
disputed: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
refunded: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||
};
|
||||
|
||||
const statusIcons: Record<string, React.ReactNode> = {
|
||||
unfunded: <Lock className="w-4 h-4" />,
|
||||
funded: <Shield className="w-4 h-4" />,
|
||||
partially_funded: <AlertTriangle className="w-4 h-4" />,
|
||||
released: <Unlock className="w-4 h-4" />,
|
||||
disputed: <AlertTriangle className="w-4 h-4" />,
|
||||
refunded: <DollarSign className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
export function EscrowStatusCard({
|
||||
escrow,
|
||||
isClient = false,
|
||||
onFundEscrow,
|
||||
onReleaseRequest,
|
||||
}: EscrowStatusCardProps) {
|
||||
const releasePercent = escrow.funds_deposited > 0
|
||||
? (escrow.funds_released / escrow.funds_deposited) * 100
|
||||
: 0;
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-slate-900/80 to-slate-800/60 border-slate-700/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-emerald-400" />
|
||||
Escrow Account
|
||||
</CardTitle>
|
||||
<Badge className={`${statusColors[escrow.status]} border`}>
|
||||
<span className="flex items-center gap-1">
|
||||
{statusIcons[escrow.status]}
|
||||
{escrow.status.replace("_", " ")}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Balance</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{formatCurrency(escrow.escrow_balance)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Total Deposited</p>
|
||||
<p className="text-xl font-semibold text-slate-300">
|
||||
{formatCurrency(escrow.funds_deposited)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Released</span>
|
||||
<span>{formatCurrency(escrow.funds_released)} ({releasePercent.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<Progress value={releasePercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
{escrow.aethex_fees > 0 && (
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-slate-700/50">
|
||||
<span className="text-muted-foreground">Platform Fees</span>
|
||||
<span className="text-slate-400">{formatCurrency(escrow.aethex_fees)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{escrow.funds_refunded > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Refunded</span>
|
||||
<span className="text-amber-400">{formatCurrency(escrow.funds_refunded)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isClient && escrow.status === "unfunded" && onFundEscrow && (
|
||||
<Button onClick={onFundEscrow} className="flex-1 bg-emerald-600 hover:bg-emerald-700">
|
||||
<DollarSign className="w-4 h-4 mr-2" />
|
||||
Fund Escrow
|
||||
</Button>
|
||||
)}
|
||||
{!isClient && escrow.status === "funded" && onReleaseRequest && (
|
||||
<Button onClick={onReleaseRequest} variant="outline" className="flex-1">
|
||||
<Unlock className="w-4 h-4 mr-2" />
|
||||
Request Release
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{escrow.funded_at && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Funded on {new Date(escrow.funded_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
236
client/components/nexus-core/PayrollRunTable.tsx
Normal file
236
client/components/nexus-core/PayrollRunTable.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DollarSign, Play, FileText, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import type { NexusPayout } from "@/lib/nexus-core-types";
|
||||
|
||||
interface PayrollRunTableProps {
|
||||
payouts: NexusPayout[];
|
||||
onProcessSelected?: (payoutIds: string[]) => void;
|
||||
onViewDetails?: (payout: NexusPayout) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-amber-500/20 text-amber-300 border-amber-500/30",
|
||||
processing: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
completed: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
failed: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
cancelled: "bg-gray-500/20 text-gray-300 border-gray-500/30",
|
||||
};
|
||||
|
||||
const statusIcons: Record<string, React.ReactNode> = {
|
||||
pending: <Clock className="w-3 h-3" />,
|
||||
processing: <Play className="w-3 h-3 animate-pulse" />,
|
||||
completed: <CheckCircle className="w-3 h-3" />,
|
||||
failed: <AlertCircle className="w-3 h-3" />,
|
||||
cancelled: <AlertCircle className="w-3 h-3" />,
|
||||
};
|
||||
|
||||
export function PayrollRunTable({
|
||||
payouts,
|
||||
onProcessSelected,
|
||||
onViewDetails,
|
||||
loading = false,
|
||||
}: PayrollRunTableProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const pendingPayouts = payouts.filter((p) => p.status === "pending");
|
||||
const totalPending = pendingPayouts.reduce((sum, p) => sum + p.net_amount, 0);
|
||||
const selectedPayouts = payouts.filter((p) => selectedIds.has(p.id));
|
||||
const selectedTotal = selectedPayouts.reduce((sum, p) => sum + p.net_amount, 0);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id);
|
||||
} else {
|
||||
newSelected.add(id);
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
};
|
||||
|
||||
const selectAllPending = () => {
|
||||
setSelectedIds(new Set(pendingPayouts.map((p) => p.id)));
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
if (onProcessSelected && selectedIds.size > 0) {
|
||||
onProcessSelected(Array.from(selectedIds));
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-slate-900/80 to-slate-800/60 border-slate-700/50">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5 text-emerald-400" />
|
||||
Payroll Queue
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-amber-500/10">
|
||||
{pendingPayouts.length} pending • {formatCurrency(totalPending)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center justify-between p-3 bg-emerald-500/10 rounded-lg border border-emerald-500/30">
|
||||
<span className="text-sm">
|
||||
<strong>{selectedIds.size}</strong> selected • Total: {formatCurrency(selectedTotal)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={clearSelection}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleProcess}
|
||||
disabled={loading}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Process Selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingPayouts.length > 0 && selectedIds.size === 0 && (
|
||||
<Button size="sm" variant="outline" onClick={selectAllPending}>
|
||||
Select All Pending ({pendingPayouts.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-slate-700/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-800/50 hover:bg-slate-800/50">
|
||||
<TableHead className="w-10"></TableHead>
|
||||
<TableHead>Talent</TableHead>
|
||||
<TableHead>Gross</TableHead>
|
||||
<TableHead>Fees</TableHead>
|
||||
<TableHead>Net</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payouts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
No payouts in queue
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
payouts.map((payout) => (
|
||||
<TableRow
|
||||
key={payout.id}
|
||||
className={selectedIds.has(payout.id) ? "bg-emerald-500/5" : ""}
|
||||
>
|
||||
<TableCell>
|
||||
{payout.status === "pending" && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(payout.id)}
|
||||
onCheckedChange={() => toggleSelect(payout.id)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">
|
||||
Talent #{payout.talent_profile_id.slice(0, 8)}
|
||||
</div>
|
||||
{payout.contract_id && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Contract: {payout.contract_id.slice(0, 8)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(payout.gross_amount)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
-{formatCurrency(payout.platform_fee + payout.processing_fee)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-emerald-400">
|
||||
{formatCurrency(payout.net_amount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{payout.payout_method}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusColors[payout.status]} border`}>
|
||||
<span className="flex items-center gap-1">
|
||||
{statusIcons[payout.status]}
|
||||
{payout.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onViewDetails?.(payout)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{payouts.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t border-slate-700/30">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{formatCurrency(payouts.filter(p => p.status === "completed").reduce((s, p) => s + p.net_amount, 0))}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{formatCurrency(totalPending)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{formatCurrency(payouts.filter(p => p.status === "processing").reduce((s, p) => s + p.net_amount, 0))}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Processing</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
138
client/components/nexus-core/TimeLogCard.tsx
Normal file
138
client/components/nexus-core/TimeLogCard.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Clock, MapPin, CheckCircle, XCircle, Send, Edit, Trash2 } from "lucide-react";
|
||||
import type { NexusTimeLog } from "@/lib/nexus-core-types";
|
||||
|
||||
interface TimeLogCardProps {
|
||||
timeLog: NexusTimeLog;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onSubmit?: () => void;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: "bg-gray-500/20 text-gray-300 border-gray-500/30",
|
||||
submitted: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
approved: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
rejected: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
exported: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||
};
|
||||
|
||||
const statusIcons: Record<string, React.ReactNode> = {
|
||||
draft: <Edit className="w-3 h-3" />,
|
||||
submitted: <Send className="w-3 h-3" />,
|
||||
approved: <CheckCircle className="w-3 h-3" />,
|
||||
rejected: <XCircle className="w-3 h-3" />,
|
||||
exported: <CheckCircle className="w-3 h-3" />,
|
||||
};
|
||||
|
||||
export function TimeLogCard({
|
||||
timeLog,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSubmit,
|
||||
showActions = true,
|
||||
}: TimeLogCardProps) {
|
||||
const formatTime = (time: string) => {
|
||||
return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString([], {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
});
|
||||
};
|
||||
|
||||
const canEdit = timeLog.submission_status === "draft" || timeLog.submission_status === "rejected";
|
||||
const canDelete = timeLog.submission_status === "draft";
|
||||
const canSubmit = timeLog.submission_status === "draft" || timeLog.submission_status === "rejected";
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-slate-900/60 to-slate-800/40 border-slate-700/30 hover:border-slate-600/50 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-cyan-400" />
|
||||
<span className="font-medium">{timeLog.hours_worked}h</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{formatTime(timeLog.start_time)} - {formatTime(timeLog.end_time)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
• {formatDate(timeLog.log_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{timeLog.description && (
|
||||
<p className="text-sm text-slate-300 line-clamp-2">{timeLog.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{timeLog.location_state && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{timeLog.location_city && `${timeLog.location_city}, `}
|
||||
{timeLog.location_state}
|
||||
</span>
|
||||
)}
|
||||
{timeLog.task_category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{timeLog.task_category}
|
||||
</Badge>
|
||||
)}
|
||||
{timeLog.az_eligible_hours > 0 && (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-300 border-emerald-500/30 text-xs">
|
||||
AZ Eligible: {timeLog.az_eligible_hours}h
|
||||
</Badge>
|
||||
)}
|
||||
{timeLog.location_verified && (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs">
|
||||
GPS Verified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge className={`${statusColors[timeLog.submission_status]} border`}>
|
||||
<span className="flex items-center gap-1">
|
||||
{statusIcons[timeLog.submission_status]}
|
||||
{timeLog.submission_status}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-1">
|
||||
{canSubmit && onSubmit && (
|
||||
<Button size="sm" variant="ghost" onClick={onSubmit} className="h-7 px-2">
|
||||
<Send className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && onEdit && (
|
||||
<Button size="sm" variant="ghost" onClick={onEdit} className="h-7 px-2">
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && onDelete && (
|
||||
<Button size="sm" variant="ghost" onClick={onDelete} className="h-7 px-2 text-red-400 hover:text-red-300">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeLog.billable && !timeLog.billed && (
|
||||
<div className="mt-2 pt-2 border-t border-slate-700/30">
|
||||
<span className="text-xs text-amber-400">Billable • Not yet invoiced</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
5
client/components/nexus-core/index.ts
Normal file
5
client/components/nexus-core/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { EscrowStatusCard } from "./EscrowStatusCard";
|
||||
export { TimeLogCard } from "./TimeLogCard";
|
||||
export { PayrollRunTable } from "./PayrollRunTable";
|
||||
export { ComplianceSummaryCard } from "./ComplianceSummaryCard";
|
||||
export { ContractOverviewCard } from "./ContractOverviewCard";
|
||||
226
client/lib/nexus-core-types.ts
Normal file
226
client/lib/nexus-core-types.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
export type TaxClassification = 'w2_employee' | '1099_contractor' | 'corp_entity' | 'foreign';
|
||||
export type ComplianceStatus = 'pending' | 'verified' | 'expired' | 'rejected' | 'review_needed';
|
||||
export type PayoutMethod = 'stripe' | 'ach' | 'check' | 'paypal';
|
||||
export type LocationType = 'remote' | 'onsite' | 'hybrid';
|
||||
export type SubmissionStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'exported';
|
||||
export type AuditType = 'review' | 'approval' | 'rejection' | 'az_submission' | 'correction' | 'dispute';
|
||||
export type AuditDecision = 'approved' | 'rejected' | 'needs_correction' | 'submitted' | 'acknowledged';
|
||||
export type AzSubmissionStatus = 'pending' | 'accepted' | 'rejected' | 'error';
|
||||
export type EventCategory = 'compliance' | 'financial' | 'access' | 'data_change' | 'tax_reporting' | 'legal';
|
||||
export type EscrowStatus = 'unfunded' | 'funded' | 'partially_funded' | 'released' | 'disputed' | 'refunded';
|
||||
export type PayoutStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface NexusTalentProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
legal_first_name?: string;
|
||||
legal_last_name?: string;
|
||||
tax_id_last_four?: string;
|
||||
tax_classification?: TaxClassification;
|
||||
residency_state?: string;
|
||||
residency_country: string;
|
||||
address_city?: string;
|
||||
address_state?: string;
|
||||
address_zip?: string;
|
||||
compliance_status: ComplianceStatus;
|
||||
compliance_verified_at?: string;
|
||||
compliance_expires_at?: string;
|
||||
az_eligible: boolean;
|
||||
w9_submitted: boolean;
|
||||
w9_submitted_at?: string;
|
||||
bank_account_connected: boolean;
|
||||
stripe_connect_account_id?: string;
|
||||
payout_method?: PayoutMethod;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NexusTimeLog {
|
||||
id: string;
|
||||
talent_profile_id: string;
|
||||
contract_id?: string;
|
||||
milestone_id?: string;
|
||||
log_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
hours_worked: number;
|
||||
description?: string;
|
||||
task_category?: string;
|
||||
location_type: LocationType;
|
||||
location_state?: string;
|
||||
location_city?: string;
|
||||
location_latitude?: number;
|
||||
location_longitude?: number;
|
||||
location_verified: boolean;
|
||||
az_eligible_hours: number;
|
||||
billable: boolean;
|
||||
billed: boolean;
|
||||
billed_at?: string;
|
||||
invoice_id?: string;
|
||||
submission_status: SubmissionStatus;
|
||||
submitted_at?: string;
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
tax_period?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NexusTimeLogAudit {
|
||||
id: string;
|
||||
time_log_id: string;
|
||||
reviewer_id?: string;
|
||||
audit_type: AuditType;
|
||||
decision?: AuditDecision;
|
||||
notes?: string;
|
||||
corrections_made?: Record<string, { old: unknown; new: unknown }>;
|
||||
az_submission_id?: string;
|
||||
az_submission_status?: AzSubmissionStatus;
|
||||
az_submission_response?: Record<string, unknown>;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NexusComplianceEvent {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
event_type: string;
|
||||
event_category: EventCategory;
|
||||
actor_id?: string;
|
||||
actor_role?: string;
|
||||
realm_context?: string;
|
||||
description?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
sensitive_data_accessed: boolean;
|
||||
financial_amount?: number;
|
||||
legal_entity?: string;
|
||||
cross_entity_access: boolean;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NexusEscrowLedger {
|
||||
id: string;
|
||||
contract_id: string;
|
||||
client_id: string;
|
||||
creator_id: string;
|
||||
escrow_balance: number;
|
||||
funds_deposited: number;
|
||||
funds_released: number;
|
||||
funds_refunded: number;
|
||||
aethex_fees: number;
|
||||
stripe_customer_id?: string;
|
||||
stripe_escrow_intent_id?: string;
|
||||
status: EscrowStatus;
|
||||
funded_at?: string;
|
||||
released_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NexusPayout {
|
||||
id: string;
|
||||
talent_profile_id: string;
|
||||
contract_id?: string;
|
||||
payment_id?: string;
|
||||
gross_amount: number;
|
||||
platform_fee: number;
|
||||
processing_fee: number;
|
||||
tax_withholding: number;
|
||||
net_amount: number;
|
||||
payout_method: PayoutMethod;
|
||||
stripe_payout_id?: string;
|
||||
ach_trace_number?: string;
|
||||
check_number?: string;
|
||||
status: PayoutStatus;
|
||||
scheduled_date?: string;
|
||||
processed_at?: string;
|
||||
failure_reason?: string;
|
||||
tax_year: number;
|
||||
tax_form_type?: string;
|
||||
tax_form_generated: boolean;
|
||||
tax_form_file_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FoundationGigRadarItem {
|
||||
opportunity_id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
required_skills: string[];
|
||||
timeline_type: string;
|
||||
location_requirement: string;
|
||||
required_experience: string;
|
||||
status: string;
|
||||
published_at: string;
|
||||
availability_status: 'available' | 'in_progress' | 'filled';
|
||||
applicant_count: number;
|
||||
compensation_type: 'hourly' | 'project';
|
||||
}
|
||||
|
||||
export interface TalentComplianceSummary {
|
||||
profile_complete: boolean;
|
||||
compliance_status: ComplianceStatus;
|
||||
az_eligible: boolean;
|
||||
w9_submitted: boolean;
|
||||
bank_connected: boolean;
|
||||
pending_time_logs: number;
|
||||
total_hours_this_month: number;
|
||||
az_hours_this_month: number;
|
||||
}
|
||||
|
||||
export interface TimeLogCreateRequest {
|
||||
contract_id?: string;
|
||||
milestone_id?: string;
|
||||
log_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
hours_worked: number;
|
||||
description?: string;
|
||||
task_category?: string;
|
||||
location_type: LocationType;
|
||||
location_state?: string;
|
||||
location_city?: string;
|
||||
location_latitude?: number;
|
||||
location_longitude?: number;
|
||||
}
|
||||
|
||||
export interface TimeLogSubmitRequest {
|
||||
time_log_ids: string[];
|
||||
}
|
||||
|
||||
export interface TimeLogApproveRequest {
|
||||
time_log_id: string;
|
||||
decision: 'approved' | 'rejected' | 'needs_correction';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface TalentProfileUpdateRequest {
|
||||
legal_first_name?: string;
|
||||
legal_last_name?: string;
|
||||
tax_classification?: TaxClassification;
|
||||
residency_state?: string;
|
||||
residency_country?: string;
|
||||
address_city?: string;
|
||||
address_state?: string;
|
||||
address_zip?: string;
|
||||
}
|
||||
|
||||
export interface AzExportRequest {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
talent_ids?: string[];
|
||||
}
|
||||
|
||||
export interface AzExportResponse {
|
||||
export_id: string;
|
||||
total_hours: number;
|
||||
az_eligible_hours: number;
|
||||
talent_count: number;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
download_url?: string;
|
||||
}
|
||||
36
replit.md
36
replit.md
|
|
@ -47,6 +47,42 @@ The monolith (`aethex.dev`) implements split routing to enforce legal separation
|
|||
|
||||
This ensures the Foundation's user-facing URLs display `aethex.foundation` in the browser, demonstrating operational independence per the Axiom Model.
|
||||
|
||||
## NEXUS Core Architecture (Universal Data Layer)
|
||||
The NEXUS Core serves as the Single Source of Truth for all talent/contract metadata, supporting AZ Tax Commission reporting and legal entity separation.
|
||||
|
||||
### Layer Architecture
|
||||
| Layer | Function | Legal Significance |
|
||||
|-------|----------|-------------------|
|
||||
| **NEXUS Core** (Universal Data Layer) | Single Source of Truth for all talent/contract metadata | Submits verifiable Time Logs to AZ Tax Commission |
|
||||
| **aethex.dev** (FULL Access UI) | Manages Client Billing, Escrow, and Payroll | The Financial Nexus that claims the Tax Credit and pays the Corp employees |
|
||||
| **aethex.foundation** (READ-ONLY UI) | Displays "Gig Radar" for student placement | The Legal Firewall—prevents the Non-Profit from seeing Corp client financials |
|
||||
| **.studio** (API Calls Only) | Secure, direct reporting of employee/contractor hours | Proof of AZ Activity required for the AIC Grant and Tax Credit eligibility |
|
||||
|
||||
### Database Schema (NEXUS Core Tables)
|
||||
- `nexus_talent_profiles` - Legal/tax info, encrypted PII, compliance status, AZ eligibility
|
||||
- `nexus_time_logs` - Hour tracking with location, AZ-eligible hours calculation
|
||||
- `nexus_time_log_audits` - Review decisions, AZ Tax Commission submission tracking
|
||||
- `nexus_compliance_events` - Cross-entity audit trail for legal separation
|
||||
- `nexus_escrow_ledger` - Escrow account tracking per contract
|
||||
- `nexus_payouts` - Payout records with tax form tracking
|
||||
- `foundation_gig_radar` - Read-only view for Foundation (no financial data exposed)
|
||||
|
||||
### API Endpoints
|
||||
| Endpoint | Layer | Purpose |
|
||||
|----------|-------|---------|
|
||||
| `/api/nexus-core/talent-profiles` | NEXUS Core | Talent legal/tax profile management |
|
||||
| `/api/nexus-core/time-logs` | NEXUS Core | Time log CRUD operations |
|
||||
| `/api/nexus-core/time-logs-submit` | NEXUS Core | Batch submit time logs for review |
|
||||
| `/api/nexus-core/time-logs-approve` | NEXUS Core | Client/admin approval workflow |
|
||||
| `/api/foundation/gig-radar` | Foundation | Read-only opportunity listings |
|
||||
| `/api/studio/time-logs` | Studio | Service-to-service hour reporting |
|
||||
| `/api/studio/contracts` | Studio | Contract status lookup |
|
||||
| `/api/corp/escrow` | Corp | Escrow funding and management |
|
||||
| `/api/corp/payroll` | Corp | Payroll processing (admin only) |
|
||||
|
||||
### TypeScript Types
|
||||
See `client/lib/nexus-core-types.ts` for all NEXUS Core type definitions.
|
||||
|
||||
## Recent Changes (December 2025)
|
||||
- **XP & Leveling System**: Complete XP earning logic with 12 event types (daily_login, profile_complete, create_post, earn_badge, etc.). 1000 XP per level, automatic level-up notifications. Integrated with daily login streak (25 XP + 10 per streak day), profile completion (100 XP), badge earning (200 XP), and post creation (20 XP). Services: `aethexXPService` in database adapter, `useXP` React hook.
|
||||
- **Unified Role/Tier System**: Combines paid subscriptions (Stripe) with earned badges for AI persona access. Tiers: Free/Pro ($9/mo)/Council ($29/mo). Badges unlock specific AI personas.
|
||||
|
|
|
|||
355
supabase/migrations/20251213_add_nexus_core_schema.sql
Normal file
355
supabase/migrations/20251213_add_nexus_core_schema.sql
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
-- NEXUS Core: Universal Data Layer
|
||||
-- Single Source of Truth for talent/contract metadata
|
||||
-- Supports AZ Tax Commission reporting, time logs, and compliance tracking
|
||||
|
||||
create extension if not exists "pgcrypto";
|
||||
|
||||
-- ============================================================================
|
||||
-- TALENT PROFILES (Legal/Tax Layer)
|
||||
-- ============================================================================
|
||||
|
||||
create table if not exists public.nexus_talent_profiles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null unique references public.user_profiles(id) on delete cascade,
|
||||
legal_first_name text,
|
||||
legal_last_name text,
|
||||
legal_name_encrypted bytea, -- pgcrypto encrypted full legal name
|
||||
tax_id_encrypted bytea, -- SSN/EIN encrypted
|
||||
tax_id_last_four text, -- last 4 digits for display
|
||||
tax_classification text check (tax_classification in ('w2_employee', '1099_contractor', 'corp_entity', 'foreign')),
|
||||
residency_state text, -- US state code (e.g., 'AZ', 'CA')
|
||||
residency_country text not null default 'US',
|
||||
address_line1_encrypted bytea,
|
||||
address_city text,
|
||||
address_state text,
|
||||
address_zip text,
|
||||
compliance_status text not null default 'pending' check (compliance_status in ('pending', 'verified', 'expired', 'rejected', 'review_needed')),
|
||||
compliance_verified_at timestamptz,
|
||||
compliance_expires_at timestamptz,
|
||||
az_eligible boolean not null default false, -- Eligible for AZ Tax Credit
|
||||
w9_submitted boolean not null default false,
|
||||
w9_submitted_at timestamptz,
|
||||
bank_account_connected boolean not null default false,
|
||||
stripe_connect_account_id text,
|
||||
payout_method text default 'stripe' check (payout_method in ('stripe', 'ach', 'check', 'paypal')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists nexus_talent_profiles_user_idx on public.nexus_talent_profiles (user_id);
|
||||
create index if not exists nexus_talent_profiles_compliance_idx on public.nexus_talent_profiles (compliance_status);
|
||||
create index if not exists nexus_talent_profiles_az_eligible_idx on public.nexus_talent_profiles (az_eligible);
|
||||
create index if not exists nexus_talent_profiles_state_idx on public.nexus_talent_profiles (residency_state);
|
||||
|
||||
-- ============================================================================
|
||||
-- TIME LOGS (Hour Tracking with AZ Compliance)
|
||||
-- ============================================================================
|
||||
|
||||
create table if not exists public.nexus_time_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
talent_profile_id uuid not null references public.nexus_talent_profiles(id) on delete cascade,
|
||||
contract_id uuid references public.nexus_contracts(id) on delete set null,
|
||||
milestone_id uuid references public.nexus_milestones(id) on delete set null,
|
||||
log_date date not null,
|
||||
start_time timestamptz not null,
|
||||
end_time timestamptz not null,
|
||||
hours_worked numeric(5, 2) not null,
|
||||
description text,
|
||||
task_category text, -- 'development', 'design', 'review', 'meeting', etc.
|
||||
location_type text not null default 'remote' check (location_type in ('remote', 'onsite', 'hybrid')),
|
||||
location_state text, -- State where work was performed (critical for AZ)
|
||||
location_city text,
|
||||
location_latitude numeric(10, 7),
|
||||
location_longitude numeric(10, 7),
|
||||
location_verified boolean not null default false,
|
||||
az_eligible_hours numeric(5, 2) default 0, -- Hours qualifying for AZ Tax Credit
|
||||
billable boolean not null default true,
|
||||
billed boolean not null default false,
|
||||
billed_at timestamptz,
|
||||
invoice_id uuid references public.corp_invoices(id) on delete set null,
|
||||
submission_status text not null default 'draft' check (submission_status in ('draft', 'submitted', 'approved', 'rejected', 'exported')),
|
||||
submitted_at timestamptz,
|
||||
approved_at timestamptz,
|
||||
approved_by uuid references public.user_profiles(id) on delete set null,
|
||||
tax_period text, -- e.g., '2025-Q1', '2025-12'
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists nexus_time_logs_talent_idx on public.nexus_time_logs (talent_profile_id);
|
||||
create index if not exists nexus_time_logs_contract_idx on public.nexus_time_logs (contract_id);
|
||||
create index if not exists nexus_time_logs_date_idx on public.nexus_time_logs (log_date desc);
|
||||
create index if not exists nexus_time_logs_status_idx on public.nexus_time_logs (submission_status);
|
||||
create index if not exists nexus_time_logs_state_idx on public.nexus_time_logs (location_state);
|
||||
create index if not exists nexus_time_logs_az_idx on public.nexus_time_logs (az_eligible_hours) where az_eligible_hours > 0;
|
||||
create index if not exists nexus_time_logs_period_idx on public.nexus_time_logs (tax_period);
|
||||
|
||||
-- ============================================================================
|
||||
-- TIME LOG AUDITS (Review & AZ Submission Tracking)
|
||||
-- ============================================================================
|
||||
|
||||
create table if not exists public.nexus_time_log_audits (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
time_log_id uuid not null references public.nexus_time_logs(id) on delete cascade,
|
||||
reviewer_id uuid references public.user_profiles(id) on delete set null,
|
||||
audit_type text not null check (audit_type in ('review', 'approval', 'rejection', 'az_submission', 'correction', 'dispute')),
|
||||
decision text check (decision in ('approved', 'rejected', 'needs_correction', 'submitted', 'acknowledged')),
|
||||
notes text,
|
||||
corrections_made jsonb, -- { field: { old: value, new: value } }
|
||||
az_submission_id text, -- ID from AZ Tax Commission API
|
||||
az_submission_status text check (az_submission_status in ('pending', 'accepted', 'rejected', 'error')),
|
||||
az_submission_response jsonb,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists nexus_time_log_audits_log_idx on public.nexus_time_log_audits (time_log_id);
|
||||
create index if not exists nexus_time_log_audits_reviewer_idx on public.nexus_time_log_audits (reviewer_id);
|
||||
create index if not exists nexus_time_log_audits_type_idx on public.nexus_time_log_audits (audit_type);
|
||||
create index if not exists nexus_time_log_audits_az_idx on public.nexus_time_log_audits (az_submission_id) where az_submission_id is not null;
|
||||
|
||||
-- ============================================================================
|
||||
-- COMPLIANCE EVENTS (Cross-Entity Audit Trail)
|
||||
-- ============================================================================
|
||||
|
||||
create table if not exists public.nexus_compliance_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
entity_type text not null, -- 'talent', 'client', 'contract', 'time_log', 'payout'
|
||||
entity_id uuid not null,
|
||||
event_type text not null, -- 'created', 'verified', 'exported', 'access_logged', 'financial_update', etc.
|
||||
event_category text not null check (event_category in ('compliance', 'financial', 'access', 'data_change', 'tax_reporting', 'legal')),
|
||||
actor_id uuid references public.user_profiles(id) on delete set null,
|
||||
actor_role text, -- 'talent', 'client', 'admin', 'system', 'api'
|
||||
realm_context text, -- 'nexus', 'corp', 'foundation', 'studio'
|
||||
description text,
|
||||
payload jsonb, -- Full event data
|
||||
sensitive_data_accessed boolean not null default false,
|
||||
financial_amount numeric(12, 2),
|
||||
legal_entity text, -- 'for_profit', 'non_profit'
|
||||
cross_entity_access boolean not null default false, -- True if Foundation accessed Corp data
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists nexus_compliance_events_entity_idx on public.nexus_compliance_events (entity_type, entity_id);
|
||||
create index if not exists nexus_compliance_events_type_idx on public.nexus_compliance_events (event_type);
|
||||
create index if not exists nexus_compliance_events_category_idx on public.nexus_compliance_events (event_category);
|
||||
create index if not exists nexus_compliance_events_actor_idx on public.nexus_compliance_events (actor_id);
|
||||
create index if not exists nexus_compliance_events_realm_idx on public.nexus_compliance_events (realm_context);
|
||||
create index if not exists nexus_compliance_events_cross_entity_idx on public.nexus_compliance_events (cross_entity_access) where cross_entity_access = true;
|
||||
create index if not exists nexus_compliance_events_created_idx on public.nexus_compliance_events (created_at desc);
|
||||
|
||||
-- ============================================================================
|
||||
-- ESCROW LEDGER (Financial Tracking)
|
||||
-- ============================================================================
|
||||
|
||||
create table if not exists public.nexus_escrow_ledger (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
contract_id uuid not null references public.nexus_contracts(id) on delete cascade,
|
||||
client_id uuid not null references public.user_profiles(id) on delete cascade,
|
||||
creator_id uuid not null references public.user_profiles(id) on delete cascade,
|
||||
escrow_balance numeric(12, 2) not null default 0,
|
||||
funds_deposited numeric(12, 2) not null default 0,
|
||||
funds_released numeric(12, 2) not null default 0,
|
||||
funds_refunded numeric(12, 2) not null default 0,
|
||||
aethex_fees numeric(12, 2) not null default 0,
|
||||
stripe_customer_id text,
|
||||
stripe_escrow_intent_id text,
|
||||
status text not null default 'unfunded' check (status in ('unfunded', 'funded', 'partially_funded', 'released', 'disputed', 'refunded')),
|
||||
funded_at timestamptz,
|
||||
released_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists nexus_escrow_ledger_contract_idx on public.nexus_escrow_ledger (contract_id);
|
||||
create index if not exists nexus_escrow_ledger_client_idx on public.nexus_escrow_ledger (client_id);
|
||||
create index if not exists nexus_escrow_ledger_creator_idx on public.nexus_escrow_ledger (creator_id);
|
||||
create index if not exists nexus_escrow_ledger_status_idx on public.nexus_escrow_ledger (status);
|
||||
|
||||
-- ============================================================================
|
||||
-- PAYOUT RECORDS (Separate from payments for tax tracking)
|
||||
-- ============================================================================
|
||||
|
||||
create table if not exists public.nexus_payouts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
talent_profile_id uuid not null references public.nexus_talent_profiles(id) on delete cascade,
|
||||
contract_id uuid references public.nexus_contracts(id) on delete set null,
|
||||
payment_id uuid references public.nexus_payments(id) on delete set null,
|
||||
gross_amount numeric(12, 2) not null,
|
||||
platform_fee numeric(12, 2) not null default 0,
|
||||
processing_fee numeric(12, 2) not null default 0,
|
||||
tax_withholding numeric(12, 2) not null default 0,
|
||||
net_amount numeric(12, 2) not null,
|
||||
payout_method text not null default 'stripe' check (payout_method in ('stripe', 'ach', 'check', 'paypal')),
|
||||
stripe_payout_id text,
|
||||
ach_trace_number text,
|
||||
check_number text,
|
||||
status text not null default 'pending' check (status in ('pending', 'processing', 'completed', 'failed', 'cancelled')),
|
||||
scheduled_date date,
|
||||
processed_at timestamptz,
|
||||
failure_reason text,
|
||||
tax_year int not null default extract(year from now()),
|
||||
tax_form_type text, -- '1099-NEC', 'W-2', etc.
|
||||
tax_form_generated boolean not null default false,
|
||||
tax_form_file_id text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists nexus_payouts_talent_idx on public.nexus_payouts (talent_profile_id);
|
||||
create index if not exists nexus_payouts_contract_idx on public.nexus_payouts (contract_id);
|
||||
create index if not exists nexus_payouts_status_idx on public.nexus_payouts (status);
|
||||
create index if not exists nexus_payouts_tax_year_idx on public.nexus_payouts (tax_year);
|
||||
create index if not exists nexus_payouts_scheduled_idx on public.nexus_payouts (scheduled_date);
|
||||
|
||||
-- ============================================================================
|
||||
-- FOUNDATION GIG RADAR VIEW (Read-Only Projection)
|
||||
-- ============================================================================
|
||||
|
||||
create or replace view public.foundation_gig_radar as
|
||||
select
|
||||
o.id as opportunity_id,
|
||||
o.title,
|
||||
o.category,
|
||||
o.required_skills,
|
||||
o.timeline_type,
|
||||
o.location_requirement,
|
||||
o.required_experience,
|
||||
o.status,
|
||||
o.published_at,
|
||||
case
|
||||
when o.status = 'open' then 'available'
|
||||
when o.status = 'in_progress' then 'in_progress'
|
||||
else 'filled'
|
||||
end as availability_status,
|
||||
(select count(*) from public.nexus_applications a where a.opportunity_id = o.id) as applicant_count,
|
||||
case when o.budget_type = 'hourly' then 'hourly' else 'project' end as compensation_type
|
||||
from public.nexus_opportunities o
|
||||
where o.status in ('open', 'in_progress')
|
||||
order by o.published_at desc;
|
||||
|
||||
comment on view public.foundation_gig_radar is 'Read-only view for Foundation Gig Radar - no financial data exposed';
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES
|
||||
-- ============================================================================
|
||||
|
||||
alter table public.nexus_talent_profiles enable row level security;
|
||||
alter table public.nexus_time_logs enable row level security;
|
||||
alter table public.nexus_time_log_audits enable row level security;
|
||||
alter table public.nexus_compliance_events enable row level security;
|
||||
alter table public.nexus_escrow_ledger enable row level security;
|
||||
alter table public.nexus_payouts enable row level security;
|
||||
|
||||
-- Talent Profiles: own profile only (sensitive data)
|
||||
create policy "Users view own talent profile" on public.nexus_talent_profiles
|
||||
for select using (auth.uid() = user_id);
|
||||
|
||||
create policy "Users manage own talent profile" on public.nexus_talent_profiles
|
||||
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
|
||||
|
||||
create policy "Admins view all talent profiles" on public.nexus_talent_profiles
|
||||
for select using (exists(select 1 from public.user_profiles where id = auth.uid() and user_type = 'admin'));
|
||||
|
||||
-- Time Logs: talent and contract parties
|
||||
create policy "Talent views own time logs" on public.nexus_time_logs
|
||||
for select using (
|
||||
auth.uid() in (select user_id from public.nexus_talent_profiles where id = talent_profile_id)
|
||||
);
|
||||
|
||||
create policy "Contract clients view time logs" on public.nexus_time_logs
|
||||
for select using (
|
||||
contract_id is not null and
|
||||
auth.uid() in (select client_id from public.nexus_contracts where id = contract_id)
|
||||
);
|
||||
|
||||
create policy "Talent manages own time logs" on public.nexus_time_logs
|
||||
for all using (
|
||||
auth.uid() in (select user_id from public.nexus_talent_profiles where id = talent_profile_id)
|
||||
) with check (
|
||||
auth.uid() in (select user_id from public.nexus_talent_profiles where id = talent_profile_id)
|
||||
);
|
||||
|
||||
-- Time Log Audits: reviewers and talent
|
||||
create policy "Time log audit visibility" on public.nexus_time_log_audits
|
||||
for select using (
|
||||
auth.uid() = reviewer_id or
|
||||
auth.uid() in (select tp.user_id from public.nexus_talent_profiles tp join public.nexus_time_logs tl on tp.id = tl.talent_profile_id where tl.id = time_log_id)
|
||||
);
|
||||
|
||||
-- Compliance Events: admins only (sensitive audit data)
|
||||
create policy "Compliance events admin only" on public.nexus_compliance_events
|
||||
for select using (exists(select 1 from public.user_profiles where id = auth.uid() and user_type = 'admin'));
|
||||
|
||||
create policy "System inserts compliance events" on public.nexus_compliance_events
|
||||
for insert with check (true); -- Service role only in practice
|
||||
|
||||
-- Escrow Ledger: contract parties
|
||||
create policy "Escrow visible to contract parties" on public.nexus_escrow_ledger
|
||||
for select using (auth.uid() = client_id or auth.uid() = creator_id);
|
||||
|
||||
-- Payouts: talent only
|
||||
create policy "Payouts visible to talent" on public.nexus_payouts
|
||||
for select using (
|
||||
auth.uid() in (select user_id from public.nexus_talent_profiles where id = talent_profile_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGERS
|
||||
-- ============================================================================
|
||||
|
||||
create trigger nexus_talent_profiles_set_updated_at before update on public.nexus_talent_profiles for each row execute function public.set_updated_at();
|
||||
create trigger nexus_time_logs_set_updated_at before update on public.nexus_time_logs for each row execute function public.set_updated_at();
|
||||
create trigger nexus_escrow_ledger_set_updated_at before update on public.nexus_escrow_ledger for each row execute function public.set_updated_at();
|
||||
create trigger nexus_payouts_set_updated_at before update on public.nexus_payouts for each row execute function public.set_updated_at();
|
||||
|
||||
-- ============================================================================
|
||||
-- HELPER FUNCTIONS
|
||||
-- ============================================================================
|
||||
|
||||
-- Calculate AZ-eligible hours for a time period
|
||||
create or replace function public.calculate_az_eligible_hours(
|
||||
p_talent_id uuid,
|
||||
p_start_date date,
|
||||
p_end_date date
|
||||
) returns numeric as $$
|
||||
select coalesce(sum(az_eligible_hours), 0)
|
||||
from public.nexus_time_logs
|
||||
where talent_profile_id = p_talent_id
|
||||
and log_date between p_start_date and p_end_date
|
||||
and location_state = 'AZ'
|
||||
and submission_status = 'approved';
|
||||
$$ language sql stable;
|
||||
|
||||
-- Get talent compliance summary
|
||||
create or replace function public.get_talent_compliance_summary(p_user_id uuid)
|
||||
returns jsonb as $$
|
||||
select jsonb_build_object(
|
||||
'profile_complete', (tp.legal_first_name is not null and tp.tax_id_encrypted is not null),
|
||||
'compliance_status', tp.compliance_status,
|
||||
'az_eligible', tp.az_eligible,
|
||||
'w9_submitted', tp.w9_submitted,
|
||||
'bank_connected', tp.bank_account_connected,
|
||||
'pending_time_logs', (select count(*) from public.nexus_time_logs where talent_profile_id = tp.id and submission_status = 'submitted'),
|
||||
'total_hours_this_month', (select coalesce(sum(hours_worked), 0) from public.nexus_time_logs where talent_profile_id = tp.id and log_date >= date_trunc('month', now())),
|
||||
'az_hours_this_month', (select coalesce(sum(az_eligible_hours), 0) from public.nexus_time_logs where talent_profile_id = tp.id and log_date >= date_trunc('month', now()) and location_state = 'AZ')
|
||||
)
|
||||
from public.nexus_talent_profiles tp
|
||||
where tp.user_id = p_user_id;
|
||||
$$ language sql stable;
|
||||
|
||||
-- ============================================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================================
|
||||
|
||||
comment on table public.nexus_talent_profiles is 'Talent legal/tax profiles with encrypted PII for compliance';
|
||||
comment on table public.nexus_time_logs is 'Hour tracking with location for AZ Tax Credit eligibility';
|
||||
comment on table public.nexus_time_log_audits is 'Audit trail for time log reviews and AZ submissions';
|
||||
comment on table public.nexus_compliance_events is 'Cross-entity compliance event log for legal separation';
|
||||
comment on table public.nexus_escrow_ledger is 'Escrow account tracking per contract';
|
||||
comment on table public.nexus_payouts is 'Payout records with tax form tracking';
|
||||
comment on function public.calculate_az_eligible_hours is 'Calculate AZ Tax Credit eligible hours for a talent in a date range';
|
||||
comment on function public.get_talent_compliance_summary is 'Get compliance status summary for a talent';
|
||||
Loading…
Reference in a new issue