"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
GripVertical,
Plus,
Download,
Trash2,
RefreshCw,
Edit3,
Check,
X,
Save,
} from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { saveAs } from "file-saver";
const API_BASE = process.env.NEXT_PUBLIC_API_URL;
// Types
export type OutlineNode = {
id: string;
title: string;
description?: string;
children?: OutlineNode[];
};
type BackendSubsection = {
sub_name?: string;
sub_description?: string;
};
type BackendSection = {
section_name?: string;
section_description?: string;
subsections?: BackendSubsection[];
};
type BackendOutline = {
opportunity_name?: string;
submitted_to_name?: string;
submitted_to_email?: string;
submitted_to_phone?: string;
submitted_by_email?: string;
submitted_by_address?: string;
cover_letter_date?: string;
cover_letter_client_contact?: string;
cover_letter_subject_line?: string;
cover_letter_body?: string;
proposal_type?: string;
sections?: BackendSection[];
table_of_contents?: string[];
[k: string]: any;
};
export type OutlineBuilderProps = {
proposalId: string;
onGenerate: (outlineText: string) => void;
isLoading?: boolean;
};
// Generic helpers
const cloneSafe = (v: T): T => {
try {
return JSON.parse(JSON.stringify(v)) as T;
} catch {
return v;
}
};
const findAndRemoveNode = (tree: OutlineNode[], id: string): [OutlineNode | null, OutlineNode[]] => {
let removed: OutlineNode | null = null;
const walk = (list: OutlineNode[]): OutlineNode[] => {
let changed = false;
const out: OutlineNode[] = [];
for (let i = 0; i < list.length; i++) {
const n = list[i];
if (n.id === id) {
removed = n;
changed = true;
continue;
}
if (Array.isArray(n.children) && n.children.length) {
const newChildren = walk(n.children);
if (newChildren !== n.children) {
changed = true;
out.push({ ...n, children: newChildren });
} else {
out.push(n);
}
} else {
out.push(n);
}
}
return changed ? out : list;
};
const newTree = walk(tree);
return [removed, newTree];
};
const insertNodeAt = (tree: OutlineNode[], nodeToInsert: OutlineNode, parentId: string | null, index: number): OutlineNode[] => {
if (parentId === null) {
const safeIndex = Math.max(0, Math.min(tree.length, index));
return [...tree.slice(0, safeIndex), nodeToInsert, ...tree.slice(safeIndex)];
}
let inserted = false;
const walk = (list: OutlineNode[]): OutlineNode[] => {
let changed = false;
const out: OutlineNode[] = [];
for (let i = 0; i < list.length; i++) {
const n = list[i];
if (n.id === parentId) {
const children = Array.isArray(n.children) ? [...n.children] : [];
const safe = Math.max(0, Math.min(children.length, index));
children.splice(safe, 0, nodeToInsert);
inserted = true;
changed = true;
out.push({ ...n, children });
continue;
}
if (Array.isArray(n.children) && n.children.length) {
const nchild = walk(n.children);
if (nchild !== n.children) {
changed = true;
out.push({ ...n, children: nchild });
} else {
out.push(n);
}
} else {
out.push(n);
}
}
return changed ? out : list;
};
const newTree = walk(tree);
return inserted ? newTree : tree;
};
const findNode = (tree: OutlineNode[], id: string): OutlineNode | null => {
const walk = (list: OutlineNode[]): OutlineNode | null => {
for (let i = 0; i < list.length; i++) {
const n = list[i];
if (n.id === id) return n;
if (Array.isArray(n.children) && n.children.length) {
const f = walk(n.children);
if (f) return f;
}
}
return null;
};
return walk(tree);
};
const numberOutline = (nodes?: OutlineNode[] | null, prefix: number[] = []): { no: string; title: string }[] => {
const out: { no: string; title: string }[] = [];
const arr = Array.isArray(nodes) ? nodes : [];
arr.forEach((n, idx) => {
const no = [...prefix, idx + 1].join(".");
out.push({ no, title: n.title });
if (n.children?.length) {
const sub = numberOutline(n.children, [...prefix, idx + 1]);
out.push(...sub);
}
});
return out;
};
const outlineToText = (nodes?: OutlineNode[] | null): string =>
numberOutline(nodes).map((x) => `${x.no} ${x.title}`).join("\n");
const OutlineBuilder: React.FC = ({ proposalId, onGenerate, isLoading }) => {
const [nodes, setNodes] = useState([]);
const [outlineJson, setOutlineJson] = useState(null);
const [loadingOutline, setLoadingOutline] = useState(true);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [draggedId, setDraggedId] = useState(null);
const [dropHint, setDropHint] = useState(null);
const [selectedTemplate, setSelectedTemplate] = useState("template.docx");
const [meta, setMeta] = useState({
opportunity_name: "",
submitted_to_name: "",
submitted_to_email: "",
submitted_to_phone: "",
cover_letter_date: "",
cover_letter_client_contact: "",
cover_letter_subject_line: "",
cover_letter_body: "",
});
const mapBackendToNodes = (outline: BackendOutline | null): OutlineNode[] => {
if (!outline) return [];
const sections = Array.isArray(outline.sections) ? outline.sections : [];
return sections.map((s, idx) => ({
id: crypto.randomUUID(),
title: s.section_name ?? `Section ${idx + 1}`,
description: s.section_description ?? "",
children: Array.isArray(s.subsections)
? s.subsections.map((sub, j) => ({
id: crypto.randomUUID(),
title: sub.sub_name ?? `Subsection ${j + 1}`,
description: sub.sub_description ?? "",
children: [],
}))
: [],
}));
};
const mapNodesToBackendSections = (list: OutlineNode[]): BackendSection[] => {
return (Array.isArray(list) ? list : []).map((n) => ({
section_name: n.title,
section_description: n.description ?? "",
subsections: Array.isArray(n.children)
? n.children.map((c) => ({
sub_name: c.title,
sub_description: c.description ?? "",
}))
: [],
}));
};
const fetchOutline = async () => {
if (!proposalId || !API_BASE) return;
try {
setLoadingOutline(true);
const res = await fetch(`${API_BASE}/api/proposals/${proposalId}/outline`, { method: "GET" });
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`GET failed: ${res.status} ${txt}`);
}
const data = await res.json();
const outline: BackendOutline = data.outline || data.outlineJson || data;
setOutlineJson(outline);
setMeta({
opportunity_name: outline.opportunity_name ?? "",
submitted_to_name: outline.submitted_to_name ?? "",
submitted_to_email: outline.submitted_to_email ?? "",
submitted_to_phone: outline.submitted_to_phone ?? "",
cover_letter_date: outline.cover_letter_date ?? "",
cover_letter_client_contact: outline.cover_letter_client_contact ?? "",
cover_letter_subject_line: outline.cover_letter_subject_line ?? "",
cover_letter_body: outline.cover_letter_body ?? "",
});
const mapped = mapBackendToNodes(outline);
setNodes(mapped);
} catch (err) {
console.error("fetchOutline error:", err);
alert("Failed to load outline.");
} finally {
setLoadingOutline(false);
}
};
useEffect(() => {
fetchOutline();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proposalId]);
const outlineText = useMemo(() => outlineToText(nodes), [nodes]);
const removeNodeById = (id: string) => {
const [, newTree] = findAndRemoveNode(nodes as any, id);
setNodes(newTree as any);
};
const updateTitle = (id: string, text: string) => {
const node = findNode(nodes as any, id);
if (node) {
node.title = text;
setNodes([...nodes]);
}
};
const updateDescription = (id: string, desc: string) => {
const node = findNode(nodes, id);
if (node) {
const updatedNode = { ...node, description: desc };
const newNodes = nodes.map((n) => (n.id === id ? updatedNode : n));
setNodes(newNodes);
}
};
const addChild = (parentId: string) => {
const newChild: OutlineNode = {
id: crypto.randomUUID(),
title: "New Subsection",
description: "",
children: [],
};
const parentNode = findNode(nodes as any, parentId);
const newIndex = parentNode?.children?.length ?? 0;
const newTree = insertNodeAt(nodes as any, newChild as any, parentId, newIndex);
if (newTree !== nodes) setNodes(newTree as any);
};
const addRoot = () => {
setNodes([...nodes, { id: crypto.randomUUID(), title: "New Section", description: "" }]);
};
const onDragStart = (e: React.DragEvent, id: string) => {
setDraggedId(id);
e.dataTransfer.setData("text/plain", id);
};
const onDragOverGeneric = (e: React.DragEvent) => {
e.preventDefault();
};
const endDragCleanup = () => {
setDraggedId(null);
setDropHint(null);
};
useEffect(() => {
const cleanup = () => endDragCleanup();
window.addEventListener("dragend", cleanup);
return () => window.removeEventListener("dragend", cleanup);
}, []);
const findParentAndIndex = (tree: OutlineNode[], id: string): { parentId: string | null; index: number } | null => {
const walk = (arr: OutlineNode[], parentId: string | null): { parentId: string | null; index: number } | null => {
for (let i = 0; i < arr.length; i++) {
const n = arr[i];
if (n.id === id) return { parentId, index: i };
if (n.children?.length) {
const found = walk(n.children, n.id);
if (found) return found;
}
}
return null;
};
return walk(tree, null);
};
const handleDragLeaveRoot = (e: React.DragEvent) => {
const to = e.relatedTarget as HTMLElement | null;
if (!to || !to.closest(".outline-left-column")) {
setDropHint(null);
}
};
const onDropBetween = (e: React.DragEvent, parentId: string | null, index: number) => {
e.preventDefault();
let dragged = e.dataTransfer.getData("text/plain");
if (!dragged && draggedId) dragged = draggedId;
if (!dragged) return endDragCleanup();
const origin = findParentAndIndex(nodes, dragged);
const originParent = origin?.parentId ?? null;
const originIndex = origin?.index ?? -1;
let targetIndex = index;
if (originParent === parentId && originIndex < index) targetIndex = index - 1;
if (originParent === parentId && targetIndex === originIndex) return endDragCleanup();
const [removed, afterRemove] = findAndRemoveNode(nodes as any, dragged);
if (!removed) return endDragCleanup();
const newTree = insertNodeAt(afterRemove as any, removed as any, parentId, targetIndex);
if (newTree !== nodes) setNodes(newTree as any);
endDragCleanup();
};
const onDropInto = (e: React.DragEvent, parentId: string | null) => {
e.preventDefault();
let dragged = e.dataTransfer.getData("text/plain");
if (!dragged && draggedId) dragged = draggedId;
if (!dragged) return endDragCleanup();
if (parentId && isDescendant(nodes, dragged, parentId)) return endDragCleanup();
const origin = findParentAndIndex(nodes, dragged);
const originParent = origin?.parentId ?? null;
const originIndex = origin?.index ?? -1;
const [removed, afterRemove] = findAndRemoveNode(nodes as any, dragged);
if (!removed) return endDragCleanup();
const parentNode = parentId ? findNode(afterRemove as any, parentId) : null;
let newIndex = parentNode?.children?.length ?? afterRemove.length;
if (originParent === parentId && originIndex < newIndex) newIndex = Math.max(0, newIndex - 1);
const newTree = insertNodeAt(afterRemove as any, removed as any, parentId, newIndex);
if (newTree !== nodes) setNodes(newTree as any);
endDragCleanup();
};
const TitleInline: React.FC = ({ id, value }) => {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(value);
useEffect(() => setVal(value), [value]);
return (
{editing ? (
setVal(e.target.value)} />
{
updateTitle(id, val || value);
setEditing(false);
}}
>
{
setVal(value);
setEditing(false);
}}
>
) : (
{value}
setEditing(true)}>
)}
return (
{meta.opportunity_name || "Proposal Outline"}
{outlineJson?.proposal_type ? `Type: ${outlineJson.proposal_type}` : ""}
Reload
{saving ? (
Saving
) : (
Save Outline
)}
onGenerate(outlineText)} disabled={!nodes.length}>
Generate Draft
downloadDocx(selectedTemplate)} disabled={!nodes.length}>
Download DOCX
Delete Outline
{nodes.length > 0 ? (
) : (
No sections found. Add or reload.
)}
Add Top-Level Section
Cover & Metadata
Opportunity Name
setMeta({ ...meta, opportunity_name: e.target.value })
}
/>
Submitted To (Name)
setMeta({ ...meta, submitted_to_name: e.target.value })
}
/>
Email
setMeta({ ...meta, submitted_to_email: e.target.value })
}
/>
Phone
setMeta({ ...meta, submitted_to_phone: e.target.value })
}
/>
Cover Letter Date
setMeta({ ...meta, cover_letter_date: e.target.value })
}
/>
Client Contact
setMeta({
...meta,
cover_letter_client_contact: e.target.value,
})
}
/>
Subject Line
setMeta({
...meta,
cover_letter_subject_line: e.target.value,
})
}
/>
Cover Letter Body
setMeta({
...meta,
cover_letter_body: e.target.value,
})
}
/>
Template
Choose Template
setSelectedTemplate(e.target.value)}
>
template.docx
Tip: Edit outline on left. Edit cover details here. Save to Firestore or Export DOCX anytime.
export default OutlineBuilder;
Location:Richmond, Virginia, United States
Responsibilities:
- Support the development and scoping of the risk assessment plan.
- Identify, assess, and document applicable security controls.
- Collaborate with stakeholders to collect data and evidence.
- Perform detailed analysis of access control measures.
- Review security documentation and system procedures.
- Prepare draft and final risk assessment reports.
- Collect and review artifacts demonstrating compliance with applicable security controls.
- Incorporate feedback and deliver a final report.
Required Skills & Certifications:
- Bachelor's degree in Cybersecurity, Information Technology, Computer Science, or related field.
- Minimum 5+ years of experience in cybersecurity risk assessment or compliance projects.
- Strong understanding of NIST 800-53 and SEC530 Information Security Standards.
- Hands-on experience with access control evaluations and documentation of compliance evidence.
- Excellent analytical, reporting, and communication skills.
Preferred Skills & Certifications:
- Certifications such as CISSP, CISA, CISM, CRISC, or Security+.
- Experience working with state or federal agencies or similar regulatory environments.
Special Considerations:
- N/A
Scheduling:
- N/A
...earnings daily* Paid Time Off* Excellent Health, Dental and Vision Insurance* Health... ...Enters and maintains medical professional information in the EMR.Requires knowledge and... ...records. Supports and educates others on managing private information.Prepares and participates...
Job Title: Tig Welding TechnicianLocation: Paris, ILZip Code: 61944Start Date: Right Away Job Type: Direct HirePay Rate... ...respected enterprises. We offer excellent opportunities for contract/temporary, temp-to-hire, and direct assignments in the engineering...
...Hiring immediately for Class A Driver! Bold Safe Trucking is a top employer of CDL Truck Drivers across the country. In addition to a great starting salary, we offer great benefits and great schedules. Come work for a great company that takes pride in its Drivers....
**Walmart Retail Specialist****General Information****Company:** PRE-US**Location:** NORRISTOWN, Pennsylvania, 19403**Ref #:** 129... ...* $ 15.00**Function:** Merchandising**Employment Duration:** Part-time**Description and Requirements**As a Retail Specialist at Premium...
Job Summary:TLC House & Pet Sitting Service in Chandler, Arizona, is seeking experienced and reliable adults to join our team. We offer part-time opportunities for overnight stays, dog walking, and vacation visits, with flexible scheduling to fit your lifestyle.This...