diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0127d29..13ea0fd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -55,7 +55,15 @@ "Bash(npx vercel@latest --prod)", "Bash(git add *)", "Bash(git commit -m ' *)", - "Bash(git push *)" + "Bash(git push *)", + "Bash(cat .env.local)", + "Bash(cat .env)", + "Bash(vercel env *)", + "Bash(python3 -m json.tool)", + "Bash(xargs -0 '-I{}' bash -c 'name=$\\(basename \"{}\" .jsx\\); grep -q \"$name\" \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal/src/App.jsx\" || echo \"UNLINKED: {}\"')", + "mcp__plugin_supabase_supabase__execute_sql", + "mcp__plugin_supabase_supabase__apply_migration", + "Bash(git commit *)" ] } } diff --git a/api/seafile.js b/api/seafile.js index 77f59f8..cba4231 100644 --- a/api/seafile.js +++ b/api/seafile.js @@ -428,6 +428,25 @@ export default async function handler(req, res) { }); } + if (req.method === 'POST' && action === 'rename') { + const newName = safeName(req.body?.name, ''); + if (!newName) return json(res, 400, { error: 'New name is required.' }); + + const type = req.body?.type || 'file'; + const endpoint = type === 'dir' + ? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolved.seafilePath)}` + : `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}`; + + const body = new URLSearchParams({ operation: 'rename', newname: newName }); + await seafileRequest(config, endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + return json(res, 200, { success: true }); + } + if (req.method === 'POST' && action === 'move') { const srcPath = req.body?.srcPath; const dstDir = req.body?.dstDir; diff --git a/src/App.jsx b/src/App.jsx index 046c32d..28dc39a 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -28,6 +28,10 @@ const FileSharing = lazy(() => import('./pages/team/FileSharing')); const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords')); const ExternalMyRequests = lazy(() => import('./pages/external/MyRequests')); const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders')); +const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices')); +const ExternalProjects = lazy(() => import('./pages/external/ExternalProjects')); +const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail')); +const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate')); const ClientDashboard = lazy(() => import('./pages/client/ClientDashboard')); const MyCompany = lazy(() => import('./pages/client/MyCompany')); const MyRequests = lazy(() => import('./pages/client/MyRequests')); @@ -67,6 +71,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 8fad640..0213df7 100755 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -63,7 +63,8 @@ function ExternalNav({ onNav }) { const links = [ { to: '/dashboard', label: 'Dashboard' }, { to: '/assigned-requests', label: 'Requests' }, - { to: '/my-purchase-orders', label: 'Purchase Orders' }, + { to: '/my-projects-sub', label: 'Projects' }, + { to: '/my-invoices-sub', label: 'Invoices' }, { to: '/file-sharing', label: 'File Sharing' }, { to: '/survey-maker', label: 'Survey Maker' }, { to: '/brand-book', label: 'Brand Book Maker' }, @@ -74,7 +75,7 @@ function ExternalNav({ onNav }) {
{links.map(({ to, label }, index) => (
- {index === 2 && ( + {index === 4 && ( <>
diff --git a/src/index.css b/src/index.css index 9c459dd..4e9b71a 100755 --- a/src/index.css +++ b/src/index.css @@ -519,6 +519,35 @@ body { position: relative; } +.file-browser-progress { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + z-index: 10; + background: transparent; + border-radius: 8px 8px 0 0; + overflow: hidden; +} + +.file-browser-progress-bar { + height: 100%; + background: var(--accent); + transition: width 0.2s ease; + border-radius: 0; +} + +.file-browser-progress-bar.indeterminate { + width: 40% !important; + animation: progress-slide 1.4s ease-in-out infinite; +} + +@keyframes progress-slide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } +} + .file-browser-dragging { border-color: var(--accent); } @@ -973,7 +1002,6 @@ select option { background: #222; color: #fff; } display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 18px; - margin-top: 18px; } .request-toolbar-actions, .request-filter-row { diff --git a/src/pages/external/ExternalProjects.jsx b/src/pages/external/ExternalProjects.jsx new file mode 100644 index 0000000..6134629 --- /dev/null +++ b/src/pages/external/ExternalProjects.jsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Layout from '../../components/Layout'; +import { supabase } from '../../lib/supabase'; +import { useAuth } from '../../context/AuthContext'; + +export default function ExternalProjects() { + const { currentUser } = useAuth(); + const navigate = useNavigate(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + if (!currentUser?.id) { setLoading(false); return; } + supabase + .from('projects') + .select('id, name, status, company:companies(name)') + .order('created_at', { ascending: false }) + .then(({ data, error: err }) => { + if (err) setError(err.message); + else setProjects(data || []); + setLoading(false); + }); + }, [currentUser?.id]); + + return ( + +
+
+
Projects
+
All projects you are assigned to.
+
+
+ + {error &&
{error}
} + + {loading ? ( +

Loading...

+ ) : projects.length === 0 ? ( +
+

No projects yet

+

Projects will appear here once the team assigns you to one.

+
+ ) : ( +
+ + + + + + + + + + {projects.map(p => ( + navigate(`/projects/${p.id}`)}> + + + + + ))} + +
ProjectClientStatus
{p.name}{p.company?.name || '—'}{p.status || 'Active'}
+
+ )} +
+ ); +} diff --git a/src/pages/external/MyInvoiceCreate.jsx b/src/pages/external/MyInvoiceCreate.jsx new file mode 100644 index 0000000..312920a --- /dev/null +++ b/src/pages/external/MyInvoiceCreate.jsx @@ -0,0 +1,282 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Layout from '../../components/Layout'; +import LoadingButton from '../../components/LoadingButton'; +import { supabase } from '../../lib/supabase'; +import { useAuth } from '../../context/AuthContext'; + +const INVOICE_TODAY = new Date().toISOString().split('T')[0]; + +function newItem(description = '', unit_price = '', quantity = 1, taskId = null, isRevision = false) { + return { id: crypto.randomUUID(), description, unit_price, quantity, task_id: taskId, is_revision: isRevision }; +} + +function genNumber(count) { + const year = new Date().getFullYear(); + return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`; +} + +export default function MyInvoiceCreate() { + const navigate = useNavigate(); + const { currentUser } = useAuth(); + + const [invoiceNumber, setInvoiceNumber] = useState(''); + const [completedTasks, setCompletedTasks] = useState([]); + const [loadingTasks, setLoadingTasks] = useState(true); + const [items, setItems] = useState([newItem()]); + const [notes, setNotes] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const dragItem = useRef(null); + + const rate = Number(currentUser?.brand_book_rate || 60); + + useEffect(() => { + async function load() { + const [{ data: tasks }, { data: existingInvoices }, { data: nextNum }] = await Promise.all([ + supabase + .from('tasks') + .select('id, title, current_version, project:projects(name)') + .eq('status', 'client_approved') + .order('title'), + supabase + .from('subcontractor_invoices') + .select('id, status, items:subcontractor_invoice_items(task_id)') + .in('status', ['submitted', 'paid']), + supabase.rpc('get_next_sub_invoice_number'), + ]); + + setInvoiceNumber(nextNum || genNumber(0)); + + const invoicedTaskIds = new Set( + (existingInvoices || []).flatMap(inv => (inv.items || []).map(i => i.task_id).filter(Boolean)) + ); + setCompletedTasks((tasks || []).filter(t => !invoicedTaskIds.has(t.id))); + setLoadingTasks(false); + } + load(); + }, []); + + const addedTaskIds = useMemo(() => new Set(items.map(i => i.task_id).filter(Boolean)), [items]); + + const addTask = (task) => { + if (addedTaskIds.has(task.id)) return; + const isRevision = (task.current_version || 0) > 0; + const desc = task.project?.name ? `${task.project.name} • ${task.title}` : task.title; + const price = isRevision ? 30 : rate; + setItems(prev => { + if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) { + return [newItem(desc, price, 1, task.id, isRevision)]; + } + return [...prev, newItem(desc, price, 1, task.id, isRevision)]; + }); + }; + + const updateItem = (id, field, value) => { + setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item)); + }; + + const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id)); + + const handleDrop = (targetIndex) => { + if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; } + setItems(prev => { + const next = [...prev]; + const [moved] = next.splice(dragItem.current, 1); + next.splice(targetIndex, 0, moved); + return next; + }); + dragItem.current = null; + }; + + const total = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0); + + const handleSubmit = async () => { + const valid = items.filter(i => String(i.description).trim()); + if (!valid.length) { setError('Add at least one line item.'); return; } + setSaving(true); + setError(''); + try { + const { data: inv, error: invErr } = await supabase + .from('subcontractor_invoices') + .insert({ + profile_id: currentUser.id, + invoice_number: invoiceNumber.trim(), + status: 'submitted', + notes: notes.trim(), + submitted_at: new Date().toISOString(), + }) + .select() + .single(); + if (invErr) throw invErr; + + const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert( + valid.map((item, idx) => ({ + invoice_id: inv.id, + task_id: item.task_id || null, + description: String(item.description).trim(), + quantity: Number(item.quantity) || 1, + unit_price: Number(item.unit_price) || 0, + sort_order: idx, + })) + ); + if (itemsErr) throw itemsErr; + + navigate(`/my-invoices-sub/${inv.id}`); + } catch (err) { + setError(err.message); + setSaving(false); + } + }; + + return ( + + + +
+
+
New Invoice
+
+ Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber} +
+
+
+ + {error &&
{error}
} + +
+
+
Completed Tasks
+ {completedTasks.length > 0 && !loadingTasks && ( + + )} +
+ {loadingTasks ? ( +

Loading...

+ ) : completedTasks.length === 0 ? ( +

No completed tasks available to invoice.

+ ) : ( +
+ {completedTasks.map(task => { + const isRevision = (task.current_version || 0) > 0; + const price = isRevision ? 30 : rate; + const alreadyAdded = addedTaskIds.has(task.id); + return ( +
+
+
+ {task.project?.name ? `${task.project.name} • ` : ''}{task.title} +
+
+ {isRevision ? 'Revision' : 'New'} · ${price.toFixed(2)}{isRevision ? '/hr' : ''} +
+
+ +
+ ); + })} +
+ )} +
+ +
+
+
Line Items
+
+ +
+ {['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((h, i) => ( +
3 ? 'right' : 'left' }}>{h}
+ ))} +
+ +
+ {items.map((item, index) => ( +
e.preventDefault()} + onDrop={() => handleDrop(index)} + style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }} + > +
{ dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} + style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }} + >⠿
+ + {item.is_revision ? 'Revision' : 'New'} + + updateItem(item.id, 'description', e.target.value)} + style={{ margin: 0 }} + /> + updateItem(item.id, 'quantity', e.target.value)} + style={{ margin: 0, textAlign: 'center' }} + /> + updateItem(item.id, 'unit_price', e.target.value)} + style={{ margin: 0, textAlign: 'right' }} + /> +
+ ${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)} +
+ +
+ ))} +
+ + + +
+
+
Total
+
${total.toFixed(2)}
+
+
+
+ +
+
Notes
+