+
+
+
+
- Items
-
+
+
+
+
+
+
@@ -118,7 +146,7 @@
type="text"
class="search-input"
id="search-input"
- placeholder="Search by part number or description..."
+ placeholder="Search items... (Ctrl+F)"
onkeyup="debounceSearch()"
onfocus="showSearchHelp()"
onblur="hideSearchHelp()"
@@ -139,6 +167,29 @@
+
+
+
+
@@ -151,32 +202,199 @@
-
-
-
+
+
-
-| Part Number | -Type | -Description | -Revision | -Created | -Actions | -
|---|---|---|---|---|---|
|
-
-
-
- |
- |||||
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -312,72 +530,6 @@
+
-
+
+
+
+
+
+
+
| Part Number | +Type | +Description | +Revision | +Created | +Actions | +
|---|---|---|---|---|---|
|
+
+
+
+ |
+ |||||
+
+
+
+ Select an item to view details
++ Click a row in the item list, or use Ctrl+F to search +
+
+
+
+
+ Item Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- Item Details
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -807,7 +959,10 @@
font-size: 0.9rem;
}
- /* Layout Toggle */
+ /* Layout Toggle & Toolbar */
+ .items-toolbar {
+ margin-bottom: 1rem;
+ }
.header-actions {
display: flex;
align-items: center;
@@ -838,30 +993,230 @@
background: var(--ctp-mauve);
color: var(--ctp-crust);
}
- @media (max-width: 1024px) {
- .layout-toggle {
- display: none;
- }
+
+ /* Column Config */
+ .column-config-container {
+ position: relative;
+ }
+ .column-config-popover {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ margin-top: 0.5rem;
+ background: var(--ctp-surface0);
+ border: 1px solid var(--ctp-surface2);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ min-width: 180px;
+ z-index: 200;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ }
+ .column-config-popover label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.35rem 0;
+ font-size: 0.85rem;
+ color: var(--ctp-text);
+ cursor: pointer;
+ }
+ .column-config-popover input[type="checkbox"] {
+ accent-color: var(--ctp-mauve);
+ }
+ .column-config-title {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--ctp-subtext0);
+ letter-spacing: 0.05em;
+ margin-bottom: 0.5rem;
}
- /* Detail Modal Tabs */
+ /* Search Scope Toggle */
+ .search-scope-toggle {
+ display: flex;
+ background: var(--ctp-surface1);
+ border-radius: 0.5rem;
+ padding: 0.2rem;
+ }
+ .scope-btn {
+ background: transparent;
+ border: none;
+ color: var(--ctp-subtext0);
+ padding: 0.4rem 0.75rem;
+ cursor: pointer;
+ border-radius: 0.375rem;
+ font-size: 0.8rem;
+ font-weight: 500;
+ transition: all 0.2s;
+ }
+ .scope-btn:hover {
+ color: var(--ctp-text);
+ }
+ .scope-btn.active {
+ background: var(--ctp-mauve);
+ color: var(--ctp-crust);
+ }
+
+ /* Filter Overlay (Ctrl+F) */
+ .filter-overlay {
+ background: var(--ctp-surface0);
+ border: 1px solid var(--ctp-surface1);
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+ padding: 0.75rem 1rem;
+ animation: slideDown 0.15s ease-out;
+ }
+ @keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-0.5rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+ .filter-overlay-bar {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+ .filter-overlay-bar .search-input {
+ flex: 1;
+ min-width: 200px;
+ }
+ .filter-close-btn {
+ padding: 0.5rem 0.75rem !important;
+ font-size: 0.8rem !important;
+ }
+
+ /* Split-Panel Workspace */
+ .items-workspace {
+ display: flex;
+ gap: 1rem;
+ min-height: calc(100vh - 300px);
+ }
+ .items-list-panel {
+ flex: 0 0 500px;
+ min-width: 350px;
+ overflow-y: auto;
+ background: var(--ctp-surface0);
+ border-radius: 0.75rem;
+ padding: 0;
+ }
+ .items-list-panel .table-container {
+ border: none;
+ border-radius: 0;
+ }
+ .items-list-panel .pagination {
+ padding: 0.75rem;
+ }
+ .items-detail-panel {
+ flex: 1;
+ min-width: 400px;
+ overflow-y: auto;
+ background: var(--ctp-surface0);
+ border-radius: 0.75rem;
+ padding: 0;
+ }
+
+ /* Vertical Layout */
+ .items-workspace.vertical {
+ flex-direction: column;
+ }
+ .items-workspace.vertical .items-list-panel {
+ flex: 1;
+ min-width: 0;
+ overflow-y: auto;
+ }
+ .items-workspace.vertical .items-detail-panel {
+ flex: 0 0 auto;
+ max-height: 55vh;
+ min-width: 0;
+ overflow-y: auto;
+ order: -1;
+ }
+
+ /* Detail Panel: Empty State */
+ .detail-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ min-height: 300px;
+ color: var(--ctp-subtext0);
+ text-align: center;
+ padding: 2rem;
+ }
+ .detail-empty-hint {
+ font-size: 0.85rem;
+ color: var(--ctp-overlay0);
+ margin-top: 0.5rem;
+ }
+
+ /* Detail Panel: Content */
+ .detail-content {
+ padding: 1.25rem;
+ }
+ .detail-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+ }
+ .detail-title {
+ font-size: 1.15rem;
+ font-weight: 600;
+ color: var(--ctp-peach);
+ font-family: "JetBrains Mono", "Fira Code", monospace;
+ }
+ .detail-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+ .detail-close-btn {
+ background: none;
+ border: none;
+ color: var(--ctp-subtext0);
+ cursor: pointer;
+ font-size: 1.5rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.375rem;
+ transition: all 0.2s;
+ line-height: 1;
+ }
+ .detail-close-btn:hover {
+ color: var(--ctp-text);
+ background: var(--ctp-surface1);
+ }
+
+ /* Detail Panel: Tabs */
.detail-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--ctp-surface1);
margin-bottom: 1rem;
padding-bottom: 0;
+ position: sticky;
+ top: 0;
+ background: var(--ctp-surface0);
+ z-index: 10;
}
.tab-btn {
background: transparent;
border: none;
color: var(--ctp-subtext0);
- padding: 0.75rem 1.25rem;
+ padding: 0.6rem 1rem;
cursor: pointer;
border-radius: 0.5rem 0.5rem 0 0;
transition: all 0.2s;
font-weight: 500;
- font-size: 0.9rem;
+ font-size: 0.85rem;
+ white-space: nowrap;
}
.tab-btn:hover {
color: var(--ctp-text);
@@ -872,7 +1227,40 @@
background: var(--ctp-surface1);
}
.tab-content {
- min-height: 200px;
+ min-height: 150px;
+ }
+
+ /* Selected Row Highlight */
+ #items-table tr {
+ cursor: pointer;
+ transition: background 0.1s;
+ }
+ #items-table tr.selected {
+ background: var(--ctp-surface1) !important;
+ border-left: 3px solid var(--ctp-mauve);
+ }
+ #items-table tr.selected .part-number {
+ color: var(--ctp-mauve);
+ }
+
+ /* Responsive */
+ @media (max-width: 900px) {
+ .items-workspace {
+ flex-direction: column;
+ }
+ .items-list-panel {
+ flex: 1;
+ min-width: 0;
+ }
+ .items-detail-panel {
+ flex: 0 0 auto;
+ max-height: 50vh;
+ min-width: 0;
+ order: -1;
+ }
+ .layout-toggle {
+ display: none;
+ }
}
/* BOM Tab */
@@ -1300,6 +1688,34 @@
let schema = null;
let itemToDelete = null;
let projectCodes = [];
+ let currentItems = [];
+ let searchScope = "all";
+ let filterTimeout = null;
+
+ // Column definitions
+ const ALL_COLUMNS = [
+ { key: "part_number", label: "Part Number" },
+ { key: "item_type", label: "Type" },
+ { key: "description", label: "Description" },
+ { key: "revision", label: "Revision" },
+ { key: "projects", label: "Projects" },
+ { key: "created", label: "Created" },
+ { key: "actions", label: "Actions" },
+ ];
+ const DEFAULT_COLUMNS_H = [
+ "part_number",
+ "item_type",
+ "description",
+ "revision",
+ ];
+ const DEFAULT_COLUMNS_V = [
+ "part_number",
+ "item_type",
+ "description",
+ "revision",
+ "created",
+ "actions",
+ ];
// SVG Icons (Catppuccin-style)
const icons = {
@@ -1462,77 +1878,250 @@
}
// Load items from API
+ // Column config helpers
+ function getVisibleColumns() {
+ const layout =
+ localStorage.getItem("silo-items-layout") || "horizontal";
+ const key =
+ layout === "vertical"
+ ? "silo-items-columns-v"
+ : "silo-items-columns-h";
+ const saved = localStorage.getItem(key);
+ if (saved) {
+ try {
+ return JSON.parse(saved);
+ } catch (e) {}
+ }
+ return layout === "vertical" ? DEFAULT_COLUMNS_V : DEFAULT_COLUMNS_H;
+ }
+
+ function saveVisibleColumns(cols) {
+ const layout =
+ localStorage.getItem("silo-items-layout") || "horizontal";
+ const key =
+ layout === "vertical"
+ ? "silo-items-columns-v"
+ : "silo-items-columns-h";
+ localStorage.setItem(key, JSON.stringify(cols));
+ }
+
+ function renderTableHeader() {
+ const cols = getVisibleColumns();
+ const thead = document.getElementById("items-thead");
+ thead.innerHTML =
+ "" +
+ cols
+ .map((key) => {
+ const col = ALL_COLUMNS.find((c) => c.key === key);
+ return col ? `${col.label} ` : "";
+ })
+ .join("") +
+ " ";
+ }
+
+ function renderTableRow(item) {
+ const cols = getVisibleColumns();
+ const pn = escapeAttr(item.part_number);
+ const isSelected = currentItemPartNumber === item.part_number;
+
+ const cellRenderers = {
+ part_number: () =>
+ `${item.part_number} `,
+ item_type: () =>
+ `${item.item_type} `,
+ description: () => `${item.description || "-"} `,
+ revision: () => `Rev ${item.current_revision} `,
+ projects: () =>
+ `${(item.projects || []).map((p) => `${p}`).join(" ") || "-"} `,
+ created: () => `${formatDate(item.created_at)} `,
+ actions: () =>
+ ` `,
+ };
+
+ const cells = cols
+ .map((key) => (cellRenderers[key] || (() => " "))())
+ .join("");
+ return `${cells} `;
+ }
+
+ function selectItem(partNumber) {
+ // Highlight selected row
+ document
+ .querySelectorAll("#items-table tr")
+ .forEach((tr) => tr.classList.remove("selected"));
+ const rows = document.querySelectorAll("#items-table tr");
+ rows.forEach((tr) => {
+ const pnEl = tr.querySelector(".part-number");
+ if (pnEl && pnEl.textContent === partNumber)
+ tr.classList.add("selected");
+ });
+ showItemDetail(partNumber);
+ }
+
+ // Column config popover
+ function toggleColumnConfig() {
+ const popover = document.getElementById("column-config-popover");
+ if (popover.style.display === "none") {
+ const visible = getVisibleColumns();
+ popover.innerHTML =
+ ' `;
+ return;
+ }
+ tbody.innerHTML = currentItems
+ .map((item) => renderTableRow(item))
+ .join("");
+ }
+
async function loadItems() {
- const search = document.getElementById("search-input").value;
+ const search = document.getElementById("search-input").value.trim();
const type = document.getElementById("type-filter").value;
const project = document.getElementById("project-filter").value;
- const params = new URLSearchParams();
- if (search) params.set("search", search);
- if (type) params.set("type", type);
- if (project) params.set("project", project);
- params.set("limit", pageSize);
- params.set("offset", (currentPage - 1) * pageSize);
-
const tbody = document.getElementById("items-table");
- tbody.innerHTML =
- ' ';
+ const cols = getVisibleColumns();
+ tbody.innerHTML = ` `;
try {
- const response = await fetch(`/api/items?${params}`);
- const items = await response.json();
-
- if (!items || items.length === 0) {
- tbody.innerHTML = `
-
-
-
-
- `;
- updateStats([]);
- return;
+ let url;
+ if (search) {
+ // Use fuzzy search endpoint
+ const params = new URLSearchParams();
+ params.set("q", search);
+ if (type) params.set("type", type);
+ if (project) params.set("project", project);
+ params.set("limit", "50");
+ // Map scope to fields param
+ if (searchScope !== "all") {
+ params.set("fields", searchScope);
+ }
+ url = `/api/items/search?${params}`;
+ } else {
+ // Use standard list endpoint with pagination
+ const params = new URLSearchParams();
+ if (type) params.set("type", type);
+ if (project) params.set("project", project);
+ params.set("limit", pageSize);
+ params.set("offset", (currentPage - 1) * pageSize);
+ url = `/api/items?${params}`;
}
- tbody.innerHTML = items
- .map(
- (item) => `
-
-
-
- ${item.part_number}
-
-
-
- ${item.item_type}
- ${item.description || "-"}
- Rev ${item.current_revision}
- ${formatDate(item.created_at)}
-
-
-
-
-
- `,
- )
- .join("");
+ const response = await fetch(url);
+ const items = await response.json();
+ currentItems = items || [];
- updateStats(items);
+ renderTableHeader();
+ renderItemsList();
+ updateStats(currentItems);
} catch (error) {
console.error("Failed to load items:", error);
- tbody.innerHTML = `
-
-
-
-
- `;
+ tbody.innerHTML = ` `;
}
}
@@ -1718,18 +2307,36 @@
let currentItemPartNumber = null;
let propsMode = "form"; // 'form' or 'json'
- function closeDetailModal() {
- document.getElementById("detail-modal").classList.remove("active");
+ function closeDetailPanel() {
+ document.getElementById("detail-content").style.display = "none";
+ document.getElementById("detail-empty").style.display = "flex";
+ document
+ .querySelectorAll("#items-table tr")
+ .forEach((tr) => tr.classList.remove("selected"));
currentDetailItem = null;
currentDetailRevisions = null;
currentItemPartNumber = null;
}
+ // Legacy alias for any remaining references
+ function closeDetailModal() {
+ closeDetailPanel();
+ }
+
+ function openEditModalFromDetail() {
+ if (currentItemPartNumber) openEditModal(currentItemPartNumber);
+ }
+
+ function openDeleteModalFromDetail() {
+ if (currentItemPartNumber) openDeleteModal(currentItemPartNumber);
+ }
+
function switchDetailTab(tab) {
- document.querySelectorAll(".detail-tabs .tab-btn").forEach((btn) => {
+ const panel = document.getElementById("detail-content");
+ panel.querySelectorAll(".detail-tabs .tab-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === tab);
});
- document.querySelectorAll(".tab-content").forEach((content) => {
+ panel.querySelectorAll(".tab-content").forEach((content) => {
content.style.display = "none";
});
document.getElementById(`tab-${tab}`).style.display = "block";
@@ -1757,13 +2364,15 @@
}
async function showItemDetail(partNumber) {
- document.getElementById("detail-modal").classList.add("active");
+ // Show detail panel
+ document.getElementById("detail-empty").style.display = "none";
+ document.getElementById("detail-content").style.display = "block";
document.getElementById("detail-title").textContent = partNumber;
currentItemPartNumber = partNumber;
- // Reset to info tab
- switchDetailTab("info");
- document.getElementById("tab-info").innerHTML =
+ // Reset to main tab
+ switchDetailTab("main");
+ document.getElementById("tab-main").innerHTML =
'
Visible Columns
' +
+ ALL_COLUMNS.map(
+ (col) =>
+ ``,
+ ).join("");
+ popover.style.display = "block";
+ } else {
+ popover.style.display = "none";
+ }
+ }
+
+ function updateColumnConfig() {
+ const checks = document.querySelectorAll(
+ '#column-config-popover input[type="checkbox"]',
+ );
+ const cols = [];
+ checks.forEach((cb) => {
+ if (cb.checked) cols.push(cb.value);
+ });
+ if (cols.length === 0) cols.push("part_number");
+ saveVisibleColumns(cols);
+ renderTableHeader();
+ renderItemsList();
+ }
+
+ // Search scope
+ function setSearchScope(scope) {
+ searchScope = scope;
+ document
+ .querySelectorAll("#search-scope-toggle .scope-btn")
+ .forEach((btn) => {
+ btn.classList.toggle("active", btn.dataset.scope === scope);
+ });
+ if (document.getElementById("search-input").value) loadItems();
+ }
+
+ // Filter overlay (Ctrl+F)
+ function showFilterOverlay() {
+ const overlay = document.getElementById("filter-overlay");
+ overlay.style.display = "block";
+ const input = document.getElementById("filter-input");
+ input.focus();
+ // Populate filter-project dropdown
+ const fp = document.getElementById("filter-project");
+ if (fp.options.length <= 1) {
+ projectCodes.forEach((code) => {
+ const opt = document.createElement("option");
+ opt.value = code;
+ opt.textContent = code;
+ fp.appendChild(opt);
+ });
+ }
+ }
+
+ function closeFilterOverlay() {
+ document.getElementById("filter-overlay").style.display = "none";
+ document.getElementById("filter-input").value = "";
+ }
+
+ function debounceFilter() {
+ clearTimeout(filterTimeout);
+ filterTimeout = setTimeout(applyFilter, 300);
+ }
+
+ function setFilterScope(scope) {
+ const overlay = document.getElementById("filter-overlay");
+ overlay.querySelectorAll(".scope-btn").forEach((btn) => {
+ btn.classList.toggle("active", btn.dataset.scope === scope);
+ });
+ applyFilter();
+ }
+
+ function applyFilter() {
+ // Sync filter overlay values to main search bar and reload
+ const query = document.getElementById("filter-input").value;
+ const type = document.getElementById("filter-type").value;
+ const project = document.getElementById("filter-project").value;
+ document.getElementById("search-input").value = query;
+ document.getElementById("type-filter").value = type;
+ document.getElementById("project-filter").value = project;
+ // Get scope from filter overlay
+ const activeScope = document.querySelector(
+ "#filter-overlay .scope-btn.active",
+ );
+ if (activeScope) setSearchScope(activeScope.dataset.scope);
+ currentPage = 1;
+ loadItems();
+ }
+
+ // Ctrl+F keyboard shortcut
+ document.addEventListener("keydown", (e) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === "f") {
+ e.preventDefault();
+ showFilterOverlay();
+ }
+ if (e.key === "Escape") {
+ const overlay = document.getElementById("filter-overlay");
+ if (overlay.style.display !== "none") {
+ closeFilterOverlay();
+ }
+ }
+ });
+
+ function renderItemsList() {
+ const tbody = document.getElementById("items-table");
+ const cols = getVisibleColumns();
+ if (!currentItems || currentItems.length === 0) {
+ tbody.innerHTML = `No items found
Create your first item or adjust your search filters.
-
- No items found
-Create your first item or adjust your search filters.
-
-
- Error loading items
-${error.message}
-Error loading items
${error.message}
';
document.getElementById("tab-properties").innerHTML = "";
document.getElementById("tab-revisions").innerHTML = "";
@@ -1867,7 +2476,7 @@
}
// Info tab
- document.getElementById("tab-info").innerHTML = `
+ document.getElementById("tab-main").innerHTML = `
ID: ${item.id}
Part Number: ${item.part_number}
@@ -1950,7 +2559,7 @@ revisions[0].revision_number; } } catch (error) { - document.getElementById("tab-info").innerHTML = + document.getElementById("tab-main").innerHTML = `Error loading item: ${error.message}
`; } } @@ -2206,7 +2815,7 @@ const LAYOUT_KEY = "silo-items-layout"; function initLayout() { - const saved = localStorage.getItem(LAYOUT_KEY) || "vertical"; + const saved = localStorage.getItem(LAYOUT_KEY) || "horizontal"; setLayout(saved, false); } @@ -2215,12 +2824,20 @@ btn.classList.toggle("active", btn.dataset.layout === mode); }); + const workspace = document.getElementById("items-workspace"); + if (mode === "vertical") { + workspace.classList.add("vertical"); + } else { + workspace.classList.remove("vertical"); + } + if (save) { localStorage.setItem(LAYOUT_KEY, mode); } - // For now, both modes use modal - horizontal panel view would be a larger change - // This establishes the toggle infrastructure for future enhancement + // Re-render table with layout-appropriate columns + renderTableHeader(); + renderItemsList(); } // Download file for a specific revision @@ -2237,6 +2854,15 @@ }); }); + // Close column config popover on outside click + document.addEventListener("click", (e) => { + const container = document.querySelector(".column-config-container"); + const popover = document.getElementById("column-config-popover"); + if (container && popover && !container.contains(e.target)) { + popover.style.display = "none"; + } + }); + // Copy part number to clipboard async function copyPartNumber(partNumber, btn) { try { @@ -3084,9 +3710,9 @@ } // Initialize + initLayout(); loadSchema(); loadProjectCodes(); loadItems(); - initLayout(); {{end}} diff --git a/internal/api/templates/projects.html b/internal/api/templates/projects.html new file mode 100644 index 0000000..fd8123a --- /dev/null +++ b/internal/api/templates/projects.html @@ -0,0 +1,300 @@ +{{define "projects_content"}} +
+
+
+
+
+-
+ Total Projects
+
+
+
+
+
+
+
+ Projects
+ +
+
+
+
+
+| Code | +Name | +Description | +Items | +Created | +Actions | +
|---|---|---|---|---|---|
|
+
+ |
+ |||||
+
+
+
+
+
+
+
+
+ Create New Project
+ +
+
+
+
+
+
+
+
+
+ Edit Project
+ +
+
+{{end}} {{define "projects_scripts"}}
+
+{{end}}
diff --git a/internal/api/web.go b/internal/api/web.go
index 15e7dfc..bdd64a3 100644
--- a/internal/api/web.go
+++ b/internal/api/web.go
@@ -77,6 +77,20 @@ func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
}
}
+// HandleProjectsPage serves the projects page.
+func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) {
+ data := PageData{
+ Title: "Projects",
+ Page: "projects",
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil {
+ h.logger.Error().Err(err).Msg("failed to render template")
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ }
+}
+
// HandleSchemasPage serves the schemas page.
func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
data := PageData{
diff --git a/internal/config/config.go b/internal/config/config.go
index 93d2f0f..487d38a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -15,6 +15,7 @@ type Config struct {
Storage StorageConfig `yaml:"storage"`
Schemas SchemasConfig `yaml:"schemas"`
FreeCAD FreeCADConfig `yaml:"freecad"`
+ Odoo OdooConfig `yaml:"odoo"`
}
// ServerConfig holds HTTP server settings.
@@ -57,6 +58,15 @@ type FreeCADConfig struct {
Executable string `yaml:"executable"`
}
+// OdooConfig holds Odoo ERP integration settings.
+type OdooConfig struct {
+ Enabled bool `yaml:"enabled"`
+ URL string `yaml:"url"`
+ Database string `yaml:"database"`
+ Username string `yaml:"username"`
+ APIKey string `yaml:"api_key"`
+}
+
// Load reads configuration from a YAML file.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
diff --git a/internal/db/integrations.go b/internal/db/integrations.go
new file mode 100644
index 0000000..a92ebd9
--- /dev/null
+++ b/internal/db/integrations.go
@@ -0,0 +1,139 @@
+package db
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// Integration represents an ERP integration configuration.
+type Integration struct {
+ ID string
+ Name string
+ Enabled bool
+ Config map[string]any
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// SyncLog represents a sync log entry.
+type SyncLog struct {
+ ID string
+ IntegrationID string
+ ItemID *string
+ Direction string
+ Status string
+ ExternalID string
+ ExternalModel string
+ RequestPayload json.RawMessage
+ ResponsePayload json.RawMessage
+ ErrorMessage string
+ StartedAt *time.Time
+ CompletedAt *time.Time
+ CreatedAt time.Time
+}
+
+// IntegrationRepository provides integration database operations.
+type IntegrationRepository struct {
+ db *DB
+}
+
+// NewIntegrationRepository creates a new integration repository.
+func NewIntegrationRepository(db *DB) *IntegrationRepository {
+ return &IntegrationRepository{db: db}
+}
+
+// GetByName returns an integration by name.
+func (r *IntegrationRepository) GetByName(ctx context.Context, name string) (*Integration, error) {
+ row := r.db.pool.QueryRow(ctx, `
+ SELECT id, name, enabled, config, created_at, updated_at
+ FROM integrations
+ WHERE name = $1
+ `, name)
+
+ var i Integration
+ var configJSON []byte
+ err := row.Scan(&i.ID, &i.Name, &i.Enabled, &configJSON, &i.CreatedAt, &i.UpdatedAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if len(configJSON) > 0 {
+ if err := json.Unmarshal(configJSON, &i.Config); err != nil {
+ return nil, err
+ }
+ }
+
+ return &i, nil
+}
+
+// Upsert creates or updates an integration by name.
+func (r *IntegrationRepository) Upsert(ctx context.Context, name string, enabled bool, config map[string]any) error {
+ configJSON, err := json.Marshal(config)
+ if err != nil {
+ return err
+ }
+
+ _, err = r.db.pool.Exec(ctx, `
+ INSERT INTO integrations (name, enabled, config)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (name) DO UPDATE SET
+ enabled = EXCLUDED.enabled,
+ config = EXCLUDED.config,
+ updated_at = now()
+ `, name, enabled, configJSON)
+ return err
+}
+
+// CreateSyncLog inserts a new sync log entry.
+func (r *IntegrationRepository) CreateSyncLog(ctx context.Context, entry *SyncLog) error {
+ _, err := r.db.pool.Exec(ctx, `
+ INSERT INTO sync_log (integration_id, item_id, direction, status, external_id, external_model, request_payload, response_payload, error_message, started_at, completed_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+ `, entry.IntegrationID, entry.ItemID, entry.Direction, entry.Status,
+ entry.ExternalID, entry.ExternalModel, entry.RequestPayload, entry.ResponsePayload,
+ entry.ErrorMessage, entry.StartedAt, entry.CompletedAt)
+ return err
+}
+
+// ListSyncLog returns recent sync log entries for an integration.
+func (r *IntegrationRepository) ListSyncLog(ctx context.Context, integrationID string, limit int) ([]*SyncLog, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+
+ rows, err := r.db.pool.Query(ctx, `
+ SELECT id, integration_id, item_id, direction, status,
+ external_id, external_model, request_payload, response_payload,
+ error_message, started_at, completed_at, created_at
+ FROM sync_log
+ WHERE integration_id = $1
+ ORDER BY created_at DESC
+ LIMIT $2
+ `, integrationID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var logs []*SyncLog
+ for rows.Next() {
+ var l SyncLog
+ err := rows.Scan(
+ &l.ID, &l.IntegrationID, &l.ItemID, &l.Direction, &l.Status,
+ &l.ExternalID, &l.ExternalModel, &l.RequestPayload, &l.ResponsePayload,
+ &l.ErrorMessage, &l.StartedAt, &l.CompletedAt, &l.CreatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ logs = append(logs, &l)
+ }
+
+ return logs, rows.Err()
+}
diff --git a/internal/odoo/client.go b/internal/odoo/client.go
new file mode 100644
index 0000000..d98fa36
--- /dev/null
+++ b/internal/odoo/client.go
@@ -0,0 +1,75 @@
+package odoo
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/rs/zerolog"
+)
+
+// Client provides access to the Odoo JSON-RPC API.
+type Client struct {
+ config Config
+ http *http.Client
+ logger zerolog.Logger
+ uid int
+}
+
+// NewClient creates a new Odoo API client.
+func NewClient(cfg Config, logger zerolog.Logger) *Client {
+ return &Client{
+ config: cfg,
+ http: &http.Client{},
+ logger: logger.With().Str("component", "odoo-client").Logger(),
+ }
+}
+
+// Authenticate authenticates with the Odoo server and stores the user ID.
+func (c *Client) Authenticate(_ context.Context) error {
+ // TODO: Implement JSON-RPC call to /web/session/authenticate
+ c.logger.Info().
+ Str("url", c.config.URL).
+ Str("database", c.config.Database).
+ Str("username", c.config.Username).
+ Msg("odoo authenticate stub called")
+ return fmt.Errorf("odoo authentication not yet implemented")
+}
+
+// SearchRead queries records from an Odoo model.
+func (c *Client) SearchRead(_ context.Context, model string, domain []any, fields []string, limit int) ([]map[string]any, error) {
+ // TODO: Implement JSON-RPC call to /web/dataset/call_kw
+ c.logger.Info().
+ Str("model", model).
+ Int("limit", limit).
+ Msg("odoo search_read stub called")
+ return nil, fmt.Errorf("odoo search_read not yet implemented")
+}
+
+// Create creates a new record in an Odoo model.
+func (c *Client) Create(_ context.Context, model string, values map[string]any) (int, error) {
+ // TODO: Implement JSON-RPC call to create
+ c.logger.Info().
+ Str("model", model).
+ Msg("odoo create stub called")
+ return 0, fmt.Errorf("odoo create not yet implemented")
+}
+
+// Write updates an existing record in an Odoo model.
+func (c *Client) Write(_ context.Context, model string, id int, values map[string]any) error {
+ // TODO: Implement JSON-RPC call to write
+ c.logger.Info().
+ Str("model", model).
+ Int("id", id).
+ Msg("odoo write stub called")
+ return fmt.Errorf("odoo write not yet implemented")
+}
+
+// TestConnection verifies the Odoo connection is working.
+func (c *Client) TestConnection(_ context.Context) error {
+ // TODO: Attempt authentication and return result
+ c.logger.Info().
+ Str("url", c.config.URL).
+ Msg("odoo test_connection stub called")
+ return fmt.Errorf("odoo test connection not yet implemented")
+}
diff --git a/internal/odoo/sync.go b/internal/odoo/sync.go
new file mode 100644
index 0000000..7d500ad
--- /dev/null
+++ b/internal/odoo/sync.go
@@ -0,0 +1,39 @@
+package odoo
+
+import (
+ "context"
+
+ "github.com/rs/zerolog"
+)
+
+// Syncer orchestrates sync operations between Silo and Odoo.
+type Syncer struct {
+ client *Client
+ logger zerolog.Logger
+}
+
+// NewSyncer creates a new sync orchestrator.
+func NewSyncer(client *Client, logger zerolog.Logger) *Syncer {
+ return &Syncer{
+ client: client,
+ logger: logger.With().Str("component", "odoo-syncer").Logger(),
+ }
+}
+
+// PushItem pushes a Silo item to Odoo as a product.
+func (s *Syncer) PushItem(_ context.Context, partNumber string) error {
+ s.logger.Info().
+ Str("part_number", partNumber).
+ Msg("push item to odoo stub called")
+ // TODO: Fetch item from DB, map fields, call client.Create or client.Write
+ return nil
+}
+
+// PullProduct pulls an Odoo product into Silo as an item.
+func (s *Syncer) PullProduct(_ context.Context, odooID int) error {
+ s.logger.Info().
+ Int("odoo_id", odooID).
+ Msg("pull product from odoo stub called")
+ // TODO: Call client.SearchRead, map fields, create/update item in DB
+ return nil
+}
diff --git a/internal/odoo/types.go b/internal/odoo/types.go
new file mode 100644
index 0000000..b07dcb3
--- /dev/null
+++ b/internal/odoo/types.go
@@ -0,0 +1,87 @@
+package odoo
+
+import "time"
+
+// Config holds Odoo connection configuration.
+type Config struct {
+ URL string `json:"url" yaml:"url"`
+ Database string `json:"database" yaml:"database"`
+ Username string `json:"username" yaml:"username"`
+ APIKey string `json:"api_key" yaml:"api_key"`
+ FieldMap map[string]string `json:"field_map,omitempty" yaml:"field_map,omitempty"`
+}
+
+// JSONRPCRequest is the Odoo JSON-RPC request envelope.
+type JSONRPCRequest struct {
+ JSONRPC string `json:"jsonrpc"`
+ Method string `json:"method"`
+ ID int `json:"id"`
+ Params any `json:"params"`
+}
+
+// JSONRPCResponse is the Odoo JSON-RPC response envelope.
+type JSONRPCResponse struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID int `json:"id"`
+ Result any `json:"result,omitempty"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data any `json:"data,omitempty"`
+ } `json:"error,omitempty"`
+}
+
+// ProductProduct maps to the Odoo product.product model.
+type ProductProduct struct {
+ ID int `json:"id"`
+ DefaultCode string `json:"default_code"` // maps to part_number
+ Name string `json:"name"` // maps to description
+ Type string `json:"type"`
+ ListPrice float64 `json:"list_price"`
+ Active bool `json:"active"`
+}
+
+// ProductTemplate maps to the Odoo product.template model.
+type ProductTemplate struct {
+ ID int `json:"id"`
+ DefaultCode string `json:"default_code"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ ListPrice float64 `json:"list_price"`
+ Active bool `json:"active"`
+}
+
+// SyncDirection indicates the direction of a sync operation.
+type SyncDirection string
+
+const (
+ SyncPush SyncDirection = "push"
+ SyncPull SyncDirection = "pull"
+)
+
+// SyncStatus indicates the status of a sync operation.
+type SyncStatus string
+
+const (
+ SyncPending SyncStatus = "pending"
+ SyncRunning SyncStatus = "running"
+ SyncCompleted SyncStatus = "completed"
+ SyncFailed SyncStatus = "failed"
+)
+
+// SyncLogEntry represents a single sync operation record.
+type SyncLogEntry struct {
+ ID string `json:"id"`
+ IntegrationID string `json:"integration_id"`
+ ItemID *string `json:"item_id,omitempty"`
+ Direction SyncDirection `json:"direction"`
+ Status SyncStatus `json:"status"`
+ ExternalID string `json:"external_id,omitempty"`
+ ExternalModel string `json:"external_model,omitempty"`
+ RequestPayload any `json:"request_payload,omitempty"`
+ ResponsePayload any `json:"response_payload,omitempty"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ StartedAt *time.Time `json:"started_at,omitempty"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/migrations/008_odoo_integration.sql b/migrations/008_odoo_integration.sql
new file mode 100644
index 0000000..e49c4df
--- /dev/null
+++ b/migrations/008_odoo_integration.sql
@@ -0,0 +1,30 @@
+-- Integration configuration and sync logging for ERP connections (Odoo, etc.)
+
+CREATE TABLE IF NOT EXISTS integrations (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ name TEXT UNIQUE NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT false,
+ config JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS sync_log (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
+ item_id UUID REFERENCES items(id) ON DELETE SET NULL,
+ direction TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ external_id TEXT,
+ external_model TEXT,
+ request_payload JSONB,
+ response_payload JSONB,
+ error_message TEXT,
+ started_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_sync_log_integration ON sync_log(integration_id);
+CREATE INDEX IF NOT EXISTS idx_sync_log_item ON sync_log(item_id);
+CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
+
+
+
+ Delete Project
+ +
+
+ Are you sure you want to permanently delete project ?
+This action cannot be undone.
+
+
+
+
+