// Create Project (search-first dedup → form) + Upload Artifact metadata modal
const CreateProjectModal = ({ open, onClose, onCreated, tier = "execution" }) => {
const [stage, setStage] = React.useState("search"); // search | match | form | success
const [client, setClient] = React.useState("");
const [matched, setMatched] = React.useState(null);
const [suggestions, setSuggestions] = React.useState([]);
const [form, setForm] = React.useState({
client: "", clientType: "", vertical: "", engagement: "", months: "", budget: "", client_details: ""
});
const [errors, setErrors] = React.useState({});
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (open) {
setStage("search"); setClient(""); setMatched(null); setSuggestions([]);
setForm({ client: "", clientType: "", vertical: "", engagement: "", months: "", budget: "", client_details: "" });
setErrors({});
}
}, [open]);
// Live search suggestions from backend
React.useEffect(() => {
if (!client.trim() || client.length < 1) { setSuggestions([]); return; }
const t = setTimeout(() => {
PrimaAPI.listProjects(client.trim())
.then(results => setSuggestions((results || []).slice(0, 4)))
.catch(() => setSuggestions([]));
}, 250);
return () => clearTimeout(t);
}, [client]);
const checkMatch = async () => {
const q = client.trim().toLowerCase();
if (!q) return;
setLoading(true);
try {
const results = await PrimaAPI.listProjects(q);
const exact = (results || []).find(p => p.client_name.toLowerCase() === q);
if (exact) { setMatched(exact); setStage("match"); }
else { setForm(f => ({ ...f, client })); setStage("form"); }
} catch (e) {
// If backend unavailable, proceed to form
setForm(f => ({ ...f, client })); setStage("form");
} finally {
setLoading(false);
}
};
const validate = () => {
const e = {};
if (!form.client.trim()) e.client = "Required.";
if (!form.clientType) e.clientType = "Required.";
if (!form.vertical) e.vertical = "Required.";
if (!form.engagement) e.engagement = "Required.";
if (!form.months || isNaN(+form.months) || +form.months < 1 || +form.months > 60) e.months = "Months must be between 1 and 60.";
setErrors(e);
return Object.keys(e).length === 0;
};
const submit = async () => {
if (!validate()) return;
setLoading(true);
setErrors({});
try {
const resp = await PrimaAPI.createProject({
client_name: form.client,
client_type: form.clientType,
vertical: form.vertical,
engagement: form.engagement,
months: parseInt(form.months, 10),
budget: form.budget || undefined,
client_details: form.client_details || "",
});
if (resp.message && resp.message.includes("already exists")) {
// Leadership auto-joined case
setMatched(resp);
setStage("success");
setTimeout(() => { onCreated(resp); onClose(); }, 1500);
} else {
setStage("success");
setTimeout(() => { onCreated(resp); onClose(); }, 1100);
}
} catch (e) {
if (e.status === 409) {
// Management join request sent case
setJoinSent(true);
setStage("match"); // Go back to match stage to show the "Request Sent" message
setErrors({ api: e.message });
} else {
setErrors({ api: e.message || "Failed to create project." });
}
} finally {
setLoading(false);
}
};
const [joinSent, setJoinSent] = React.useState(false);
const joinExisting = async () => {
if (!matched) return;
setLoading(true);
try {
const resp = await PrimaAPI.joinProject(matched.id);
if (resp && resp.status === "approved") {
// Leadership auto-approve: enter project immediately
onCreated(matched);
onClose();
} else {
setJoinSent(true);
}
} catch (e) {
setErrors({ api: e.message || "Failed to request access." });
} finally {
setLoading(false);
}
};
return (
Cancel
{loading ? "Searching…" : <>Continue >}
>
) : stage === "match" ? (
<>
{ setStage("search"); setJoinSent(false); }}>Back
{joinSent ? (
Request sent — awaiting approval
) : (
tier !== "execution" ? (
{loading ? "Requesting…" : "Request to join"}
) : (
Ask the project owner to invite you.
)
)}
>
) : stage === "form" ? (
<>
setStage("search")}>Back
{loading ? "Creating…" : <> Create project>}
>
) : null
}>
{stage === "search" && (
setClient(e.target.value)}
placeholder="Start typing the project name…" autoFocus
onKeyDown={(e) => { if (e.key === "Enter" && client.trim()) checkMatch(); }} />
{suggestions.length > 0 && (
{suggestions.map(p => (
{ e.preventDefault(); setClient(p.client_name); setMatched(p); setStage("match"); }}>
{p.client_name} · {p.vertical}
{p.code}
))}
)}
Already exists? You'll be able to request access to contribute.
)}
{stage === "match" && matched && (
{errors.api &&
{errors.api}
}
This project already exists
{matched.client_name}
{matched.client_type} · {matched.vertical}
{matched.description || "—"}
{matched.code}
{matched.engagement}
{matched.status}
Duplicate projects are blocked . Request to join — the project Owner or any Tier 1 (VP+) user can approve. Once approved, this project appears in your workspace with upload rights.
)}
{stage === "form" && (
{errors.api &&
{errors.api}
}
setForm({ ...form, client: e.target.value })} error={errors.client} />
setForm({ ...form, clientType: v })} options={TAXONOMY.clientTypes} error={errors.clientType} />
setForm({ ...form, vertical: v })} options={TAXONOMY.verticals} error={errors.vertical} />
setForm({ ...form, engagement: v })} options={TAXONOMY.engagements} error={errors.engagement} />
setForm({ ...form, months: e.target.value })} placeholder="6" error={errors.months} />
setForm({ ...form, budget: v })} options={TAXONOMY.budgets} placeholder="Optional…" />
On creation, we auto-generate the six standard folders (01_Proposal_BD → 06_Financial_Docs) and tag the project for the Prima Research pipeline.
)}
{stage === "success" && (
Project created
Generating folder structure…
)}
);
};
// ─── Upload artifact metadata ───
const UploadArtifactModal = ({ open, onClose, onSaved, defaultFolderId, openProject }) => {
const [stage, setStage] = React.useState("drop"); // drop | meta | uploading | done
const [file, setFile] = React.useState(null);
const [folderId, setFolderId] = React.useState(defaultFolderId || "01");
const [meta, setMeta] = React.useState({
fileType: "PDF", docType: "", conf: "internal",
approvedBy: "", submittedTo: "",
reusable: false, bestInClass: false, managerApproved: false
});
const [errors, setErrors] = React.useState({});
const [uploading, setUploading] = React.useState(false);
const fileInputRef = React.useRef(null);
React.useEffect(() => {
if (open) {
setStage("drop"); setFile(null);
setFolderId(defaultFolderId || "01");
setMeta({ fileType: "PDF", docType: "", conf: "internal", approvedBy: "", submittedTo: "", reusable: false, bestInClass: false, managerApproved: false });
setErrors({});
}
}, [open, defaultFolderId]);
const handleFileSelect = (selectedFile) => {
if (!selectedFile) return;
setFile(selectedFile);
// Auto-detect file type from extension
const ext = selectedFile.name.split(".").pop().toUpperCase();
const knownTypes = { PDF: "PDF", DOC: "DOCX", DOCX: "DOCX", PPT: "PPTX", PPTX: "PPTX", XLS: "XLSX", XLSX: "XLSX", MP4: "MP4" };
setMeta(m => ({ ...m, fileType: knownTypes[ext] || "PDF" }));
setStage("meta");
};
const handleSave = async () => {
if (!meta.docType) { setErrors({ docType: "Required." }); return; }
if (!openProject) { setErrors({ api: "No project selected. Open a project first." }); return; }
setErrors({});
setUploading(true);
try {
let gcsPath = null;
if (file) {
const uploadResp = await PrimaAPI.uploadFile(openProject.id, folderId, file);
gcsPath = uploadResp.gcs_path;
}
await PrimaAPI.saveArtifact({
project_id: openProject.id,
folder_id: folderId,
file_name: file ? file.name : "unknown",
file_type: meta.fileType,
doc_type: meta.docType,
confidentiality: meta.conf === "confidential" ? "client_confidential" : meta.conf,
gcs_path: gcsPath || undefined,
approved_by: meta.approvedBy,
submitted_to: meta.submittedTo,
reusable: meta.reusable,
best_in_class: meta.bestInClass,
manager_approved: meta.managerApproved,
});
onSaved(file ? file.name : "file");
onClose();
} catch (e) {
setErrors({ api: e.message || "Upload failed." });
} finally {
setUploading(false);
}
};
const selectedFolder = FOLDERS.find(f => f.id === folderId) || FOLDERS[0];
return (
Cancel
file && setStage("meta")}>
Continue
>
) : (
<>
setStage("drop")} disabled={uploading}>Back
{uploading ? "Uploading…" : <> Save & ingest>}
>
)}>
{/* Hidden file input */}
handleFileSelect(e.target.files[0])}
/>
{stage === "drop" ? (
{FOLDERS.map(f => (
setFolderId(f.id)}>
{f.id}
{f.name.replace(/^\d+_/, "")}
{f.tip}
{folderId === f.id && }
))}
{ e.preventDefault(); e.currentTarget.dataset.over = "true"; }}
onDragLeave={(e) => { e.currentTarget.dataset.over = "false"; }}
onDrop={(e) => { e.preventDefault(); e.currentTarget.dataset.over = "false"; handleFileSelect(e.dataTransfer.files[0]); }}
onClick={() => fileInputRef.current && fileInputRef.current.click()}>
Drop files here or click to browse
PDF · DOCX · PPTX · XLSX · MP4 · Image — up to 2 GB each
{file &&
{file.name} selected
}
After upload, you'll capture metadata (type, confidentiality, governance). Files won't be ingested into Prima Research until tagged.
) : (
{errors.api && (
{errors.api}
)}
{file && (
{file.name.split(".").pop().toUpperCase()}
{file.name}
{(file.size / 1024 / 1024).toFixed(1)} MB
Ready
)}
setMeta({ ...meta, fileType: v })} options={TAXONOMY.fileTypes} />
setMeta({ ...meta, docType: v })} options={TAXONOMY.docTypes} error={errors.docType} />
{TAXONOMY.confidentiality.map(c => (
setMeta({ ...meta, conf: c.id })}>
{c.label}
{c.desc}
))}
setMeta({ ...meta, approvedBy: e.target.value })} placeholder="e.g. M. Aravind" />
setMeta({ ...meta, submittedTo: e.target.value })} placeholder="e.g. Smt. K. Iyer" />
Governance flags
{[
{ k: "reusable", l: "Reusable" },
{ k: "bestInClass", l: "Best-in-Class" },
{ k: "managerApproved", l: "Manager Approved" },
].map(g => (
setMeta({ ...meta, [g.k]: !meta[g.k] })}>
{meta[g.k] && } {g.l}
))}
)}
);
};
// Command palette (⌘K)
const CommandPalette = ({ open, onClose, onNav, onCreate, onUpload }) => {
const [q, setQ] = React.useState("");
React.useEffect(() => { if (open) setQ(""); }, [open]);
const cmds = [
{ id: "ws", label: "Go to Project Workspace", hint: "G W", action: () => onNav("workspace"), icon: "folder" },
{ id: "rh", label: "Go to Research Hub", hint: "G R", action: () => onNav("research"), icon: "book" },
{ id: "tl", label: "Go to Thought Leadership", hint: "G T", action: () => onNav("thought"), icon: "mic" },
{ id: "lb", label: "Go to Leaderboard", hint: "G L", action: () => onNav("leaderboard"), icon: "trophy" },
{ id: "pr", label: "Update profile", hint: "G P", action: () => onNav("profile"), icon: "user" },
{ id: "np", label: "Create new project", hint: "C", action: onCreate, icon: "plus" },
{ id: "up", label: "Upload artifact", hint: "U", action: onUpload, icon: "upload" },
];
const filtered = q.trim() ? cmds.filter(c => c.label.toLowerCase().includes(q.toLowerCase())) : cmds;
if (!open) return null;
return (
{ if (e.target.classList.contains("modal-backdrop")) onClose(); }}>
setQ(e.target.value)}
placeholder="Type a command or search…"
style={{ flex: 1, border: 0, background: "transparent", outline: "none", fontSize: 15, color: "var(--fg)" }} />
esc
{filtered.map(c => (
{ c.action(); onClose(); }}
style={{ display: "flex", alignItems: "center", gap: 12, width: "100%", padding: "10px 12px", border: 0, background: "transparent", borderRadius: 4, color: "var(--fg)", textAlign: "left", fontSize: 13.5 }}
onMouseEnter={(e) => e.currentTarget.style.background = "var(--bg-tint)"}
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
{c.label}
{c.hint}
))}
{filtered.length === 0 && (
No matches.
)}
);
};
Object.assign(window, { CreateProjectModal, UploadArtifactModal, CommandPalette });