feat: add sourcing type, extended fields, and inline project tagging
- Add migration 009: sourcing_type (manufactured/purchased), sourcing_link, long_description, and standard_cost columns on items table - Update Item struct, repository queries, and API handlers for new fields - Add sourcing badge, long description block, standard cost, and sourcing link display to item detail panel - Add inline project tag editor in detail panel (add/remove via dropdown) - Add new fields to create and edit modals - Update CSV import/export for new columns - Merge with auth CreatedBy/UpdatedBy changes from stash
This commit is contained in:
@@ -50,6 +50,10 @@ var csvColumns = []string{
|
||||
"updated_at",
|
||||
"category",
|
||||
"projects", // comma-separated project codes
|
||||
"sourcing_type",
|
||||
"sourcing_link",
|
||||
"long_description",
|
||||
"standard_cost",
|
||||
}
|
||||
|
||||
// HandleExportCSV exports items to CSV format.
|
||||
@@ -153,6 +157,16 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
row[5] = item.UpdatedAt.Format(time.RFC3339)
|
||||
row[6] = category
|
||||
row[7] = projectCodes
|
||||
row[8] = item.SourcingType
|
||||
if item.SourcingLink != nil {
|
||||
row[9] = *item.SourcingLink
|
||||
}
|
||||
if item.LongDescription != nil {
|
||||
row[10] = *item.LongDescription
|
||||
}
|
||||
if item.StandardCost != nil {
|
||||
row[11] = strconv.FormatFloat(*item.StandardCost, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Property columns
|
||||
if includeProps {
|
||||
@@ -350,6 +364,12 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse extended fields
|
||||
sourcingType := getCSVValue(record, colIndex, "sourcing_type")
|
||||
sourcingLink := getCSVValue(record, colIndex, "sourcing_link")
|
||||
longDesc := getCSVValue(record, colIndex, "long_description")
|
||||
stdCostStr := getCSVValue(record, colIndex, "standard_cost")
|
||||
|
||||
// Create item
|
||||
item := &db.Item{
|
||||
PartNumber: partNumber,
|
||||
@@ -359,6 +379,20 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
item.CreatedBy = &user.Username
|
||||
}
|
||||
if sourcingType != "" {
|
||||
item.SourcingType = sourcingType
|
||||
}
|
||||
if sourcingLink != "" {
|
||||
item.SourcingLink = &sourcingLink
|
||||
}
|
||||
if longDesc != "" {
|
||||
item.LongDescription = &longDesc
|
||||
}
|
||||
if stdCostStr != "" {
|
||||
if cost, err := strconv.ParseFloat(stdCostStr, 64); err == nil {
|
||||
item.StandardCost = &cost
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.items.Create(ctx, item, properties); err != nil {
|
||||
result.Errors = append(result.Errors, CSVImportErr{
|
||||
@@ -543,6 +577,10 @@ func isStandardColumn(col string) bool {
|
||||
"projects": true,
|
||||
"objects": true, // FreeCAD objects data - skip on import
|
||||
"archived_at": true,
|
||||
"sourcing_type": true,
|
||||
"sourcing_link": true,
|
||||
"long_description": true,
|
||||
"standard_cost": true,
|
||||
}
|
||||
return standardCols[col]
|
||||
}
|
||||
|
||||
@@ -232,6 +232,10 @@ type ItemResponse struct {
|
||||
CurrentRevision int `json:"current_revision"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
SourcingType string `json:"sourcing_type"`
|
||||
SourcingLink *string `json:"sourcing_link,omitempty"`
|
||||
LongDescription *string `json:"long_description,omitempty"`
|
||||
StandardCost *float64 `json:"standard_cost,omitempty"`
|
||||
Properties map[string]any `json:"properties,omitempty"`
|
||||
}
|
||||
|
||||
@@ -242,6 +246,10 @@ type CreateItemRequest struct {
|
||||
Description string `json:"description"`
|
||||
Projects []string `json:"projects,omitempty"`
|
||||
Properties map[string]any `json:"properties,omitempty"`
|
||||
SourcingType string `json:"sourcing_type,omitempty"`
|
||||
SourcingLink *string `json:"sourcing_link,omitempty"`
|
||||
LongDescription *string `json:"long_description,omitempty"`
|
||||
StandardCost *float64 `json:"standard_cost,omitempty"`
|
||||
}
|
||||
|
||||
// HandleListItems lists items with optional filtering.
|
||||
@@ -373,6 +381,10 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
PartNumber: partNumber,
|
||||
ItemType: itemType,
|
||||
Description: req.Description,
|
||||
SourcingType: req.SourcingType,
|
||||
SourcingLink: req.SourcingLink,
|
||||
LongDescription: req.LongDescription,
|
||||
StandardCost: req.StandardCost,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
item.CreatedBy = &user.Username
|
||||
@@ -444,6 +456,10 @@ type UpdateItemRequest struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Properties map[string]any `json:"properties,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
SourcingType *string `json:"sourcing_type,omitempty"`
|
||||
SourcingLink *string `json:"sourcing_link,omitempty"`
|
||||
LongDescription *string `json:"long_description,omitempty"`
|
||||
StandardCost *float64 `json:"standard_cost,omitempty"`
|
||||
}
|
||||
|
||||
// HandleUpdateItem updates an item's fields and/or creates a new revision.
|
||||
@@ -469,26 +485,31 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Update item fields if provided
|
||||
newPartNumber := item.PartNumber
|
||||
newItemType := item.ItemType
|
||||
newDescription := item.Description
|
||||
fields := db.UpdateItemFields{
|
||||
PartNumber: item.PartNumber,
|
||||
ItemType: item.ItemType,
|
||||
Description: item.Description,
|
||||
SourcingType: req.SourcingType,
|
||||
SourcingLink: req.SourcingLink,
|
||||
LongDescription: req.LongDescription,
|
||||
StandardCost: req.StandardCost,
|
||||
}
|
||||
|
||||
if req.PartNumber != "" {
|
||||
newPartNumber = req.PartNumber
|
||||
fields.PartNumber = req.PartNumber
|
||||
}
|
||||
if req.ItemType != "" {
|
||||
newItemType = req.ItemType
|
||||
fields.ItemType = req.ItemType
|
||||
}
|
||||
if req.Description != "" {
|
||||
newDescription = req.Description
|
||||
fields.Description = req.Description
|
||||
}
|
||||
|
||||
// Update the item record (UUID stays the same)
|
||||
var updatedBy *string
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
updatedBy = &user.Username
|
||||
fields.UpdatedBy = &user.Username
|
||||
}
|
||||
if err := s.items.Update(ctx, item.ID, newPartNumber, newItemType, newDescription, updatedBy); err != nil {
|
||||
if err := s.items.Update(ctx, item.ID, fields); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update item")
|
||||
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
||||
return
|
||||
@@ -513,7 +534,7 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Get updated item (use new part number if changed)
|
||||
item, _ = s.items.GetByPartNumber(ctx, newPartNumber)
|
||||
item, _ = s.items.GetByPartNumber(ctx, fields.PartNumber)
|
||||
writeJSON(w, http.StatusOK, itemToResponse(item))
|
||||
}
|
||||
|
||||
@@ -1074,6 +1095,10 @@ func itemToResponse(item *db.Item) ItemResponse {
|
||||
CurrentRevision: item.CurrentRevision,
|
||||
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
SourcingType: item.SourcingType,
|
||||
SourcingLink: item.SourcingLink,
|
||||
LongDescription: item.LongDescription,
|
||||
StandardCost: item.StandardCost,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -447,6 +447,42 @@
|
||||
placeholder="Item description"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sourcing Type</label>
|
||||
<select class="form-input" id="sourcing-type">
|
||||
<option value="manufactured">Manufactured</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Long Description (optional)</label>
|
||||
<textarea
|
||||
class="form-input"
|
||||
id="long-description"
|
||||
rows="3"
|
||||
placeholder="Extended description, specifications, notes..."
|
||||
style="resize: vertical"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Standard Cost (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
id="standard-cost"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sourcing Link (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-input"
|
||||
id="sourcing-link"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Project Tags (optional)</label>
|
||||
<div class="project-tags-container" id="project-tags-container">
|
||||
@@ -514,6 +550,42 @@
|
||||
placeholder="Item description"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sourcing Type</label>
|
||||
<select class="form-input" id="edit-sourcing-type">
|
||||
<option value="manufactured">Manufactured</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Long Description</label>
|
||||
<textarea
|
||||
class="form-input"
|
||||
id="edit-long-description"
|
||||
rows="3"
|
||||
placeholder="Extended description, specifications, notes..."
|
||||
style="resize: vertical"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Standard Cost</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
id="edit-standard-cost"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sourcing Link</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-input"
|
||||
id="edit-sourcing-link"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1680,6 +1752,80 @@
|
||||
color: var(--ctp-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Sourcing Type Badges */
|
||||
.sourcing-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sourcing-manufactured {
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
.sourcing-purchased {
|
||||
background: var(--ctp-peach);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
/* Long Description Block */
|
||||
.long-desc-block {
|
||||
background: var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
margin: 0.25rem 0 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ctp-subtext1);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Inline Project Tag Editor */
|
||||
.project-tags-editor {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.project-tags-editor .item-project-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.project-tags-editor .tag-remove-inline {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay0);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.project-tags-editor .tag-remove-inline:hover {
|
||||
background: var(--ctp-red);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
.project-add-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.project-add-inline select {
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--ctp-surface1);
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--ctp-subtext0);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
@@ -2180,8 +2326,24 @@
|
||||
schema: "kindred-rd",
|
||||
category: document.getElementById("category").value,
|
||||
description: document.getElementById("description").value,
|
||||
sourcing_type: document.getElementById("sourcing-type").value,
|
||||
};
|
||||
|
||||
const longDesc = document
|
||||
.getElementById("long-description")
|
||||
.value.trim();
|
||||
if (longDesc) data.long_description = longDesc;
|
||||
|
||||
const stdCost = parseFloat(
|
||||
document.getElementById("standard-cost").value,
|
||||
);
|
||||
if (!isNaN(stdCost)) data.standard_cost = stdCost;
|
||||
|
||||
const sourcingLink = document
|
||||
.getElementById("sourcing-link")
|
||||
.value.trim();
|
||||
if (sourcingLink) data.sourcing_link = sourcingLink;
|
||||
|
||||
// Add project tags if any selected
|
||||
if (selectedProjectTags.length > 0) {
|
||||
data.projects = selectedProjectTags;
|
||||
@@ -2227,6 +2389,14 @@
|
||||
document.getElementById("edit-type").value = item.item_type;
|
||||
document.getElementById("edit-description").value =
|
||||
item.description || "";
|
||||
document.getElementById("edit-sourcing-type").value =
|
||||
item.sourcing_type || "manufactured";
|
||||
document.getElementById("edit-long-description").value =
|
||||
item.long_description || "";
|
||||
document.getElementById("edit-standard-cost").value =
|
||||
item.standard_cost != null ? item.standard_cost : "";
|
||||
document.getElementById("edit-sourcing-link").value =
|
||||
item.sourcing_link || "";
|
||||
} catch (error) {
|
||||
alert(`Error loading item: ${error.message}`);
|
||||
closeEditModal();
|
||||
@@ -2242,10 +2412,19 @@
|
||||
event.preventDefault();
|
||||
|
||||
const originalPN = document.getElementById("edit-original-pn").value;
|
||||
const stdCostVal = parseFloat(
|
||||
document.getElementById("edit-standard-cost").value,
|
||||
);
|
||||
const data = {
|
||||
part_number: document.getElementById("edit-part-number").value,
|
||||
item_type: document.getElementById("edit-type").value,
|
||||
description: document.getElementById("edit-description").value,
|
||||
sourcing_type: document.getElementById("edit-sourcing-type").value,
|
||||
long_description:
|
||||
document.getElementById("edit-long-description").value || null,
|
||||
standard_cost: !isNaN(stdCostVal) ? stdCostVal : null,
|
||||
sourcing_link:
|
||||
document.getElementById("edit-sourcing-link").value || null,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -2454,35 +2633,44 @@
|
||||
}
|
||||
|
||||
// Fetch project tags for this item
|
||||
let projectTagsHtml =
|
||||
'<span class="item-projects"><em style="color: var(--ctp-subtext0);">None</em></span>';
|
||||
let itemProjectsList = [];
|
||||
try {
|
||||
const projectsRes = await fetch(
|
||||
`/api/items/${partNumber}/projects`,
|
||||
);
|
||||
if (projectsRes.ok) {
|
||||
const itemProjects = await projectsRes.json();
|
||||
if (itemProjects && itemProjects.length > 0) {
|
||||
projectTagsHtml = `<span class="item-projects">${itemProjects
|
||||
.map(
|
||||
(p) =>
|
||||
`<span class="item-project-tag">${p.code || p}</span>`,
|
||||
)
|
||||
.join("")}</span>`;
|
||||
}
|
||||
itemProjectsList = await projectsRes.json();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load project tags:", e);
|
||||
}
|
||||
|
||||
const projectTagsHtml = buildProjectTagsEditor(
|
||||
partNumber,
|
||||
itemProjectsList,
|
||||
);
|
||||
|
||||
// Build sourcing info
|
||||
const sourcingType = item.sourcing_type || "manufactured";
|
||||
const sourcingLink = item.sourcing_link || "";
|
||||
const longDesc = item.long_description || "";
|
||||
const stdCost =
|
||||
item.standard_cost != null
|
||||
? `$${Number(item.standard_cost).toFixed(2)}`
|
||||
: "-";
|
||||
|
||||
// Info tab
|
||||
document.getElementById("tab-main").innerHTML = `
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<p><strong>ID:</strong> <code style="background: var(--ctp-surface1); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.85rem;">${item.id}</code></p>
|
||||
<p><strong>Part Number:</strong> <span class="part-number-container"><span class="part-number">${item.part_number}</span><button class="copy-btn" onclick="copyPartNumber('${item.part_number}', this)" title="Copy part number">${icons.clipboard}</button></span></p>
|
||||
<p><strong>Type:</strong> <span class="item-type item-type-${item.item_type}">${item.item_type}</span></p>
|
||||
<p><strong>Sourcing:</strong> <span class="sourcing-badge sourcing-${sourcingType}">${sourcingType}</span></p>
|
||||
<p><strong>Description:</strong> ${item.description || "-"}</p>
|
||||
<p><strong>Projects:</strong> ${projectTagsHtml}</p>
|
||||
${longDesc ? `<p><strong>Long Description:</strong></p><div class="long-desc-block">${escapeHtml(longDesc)}</div>` : ""}
|
||||
<p><strong>Standard Cost:</strong> <span style="font-family: 'JetBrains Mono', monospace;">${stdCost}</span></p>
|
||||
${sourcingLink ? `<p><strong>Sourcing Link:</strong> <a href="${escapeAttr(sourcingLink)}" target="_blank" rel="noopener" style="color: var(--ctp-blue);">${escapeHtml(sourcingLink.length > 60 ? sourcingLink.substring(0, 60) + "..." : sourcingLink)}</a></p>` : ""}
|
||||
<div style="margin: 0.75rem 0;"><strong>Projects:</strong> ${projectTagsHtml}</div>
|
||||
<p><strong>Current Revision:</strong> ${item.current_revision}</p>
|
||||
<p><strong>Created:</strong> ${formatDate(item.created_at)}</p>
|
||||
<p><strong>Updated:</strong> ${formatDate(item.updated_at)}</p>
|
||||
@@ -3709,6 +3897,76 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Inline Project Tag Editor (Detail Panel)
|
||||
// ========================================
|
||||
|
||||
function buildProjectTagsEditor(partNumber, itemProjects) {
|
||||
const escapedPN = escapeAttr(partNumber);
|
||||
const tags = (itemProjects || [])
|
||||
.map((p) => {
|
||||
const code = p.code || p;
|
||||
return `<span class="item-project-tag">${escapeHtml(code)}<button class="tag-remove-inline" onclick="removeDetailProjectTag('${escapedPN}', '${escapeAttr(code)}')" title="Remove">×</button></span>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Build dropdown of available projects (exclude already tagged)
|
||||
const taggedCodes = (itemProjects || []).map((p) => p.code || p);
|
||||
const available = projectCodes.filter((c) => !taggedCodes.includes(c));
|
||||
let addDropdown = "";
|
||||
if (available.length > 0) {
|
||||
const opts = available
|
||||
.map(
|
||||
(c) =>
|
||||
`<option value="${escapeAttr(c)}">${escapeHtml(c)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
addDropdown = `<span class="project-add-inline"><select onchange="addDetailProjectTag('${escapedPN}', this.value); this.value='';">
|
||||
<option value="">+ tag</option>${opts}
|
||||
</select></span>`;
|
||||
}
|
||||
|
||||
return `<span class="project-tags-editor">${tags || '<em style="color: var(--ctp-subtext0); font-size: 0.85rem;">None</em>'}${addDropdown}</span>`;
|
||||
}
|
||||
|
||||
async function addDetailProjectTag(partNumber, projectCode) {
|
||||
if (!projectCode) return;
|
||||
try {
|
||||
const response = await fetch(`/api/items/${partNumber}/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projects: [projectCode] }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
alert(err.message || err.error);
|
||||
return;
|
||||
}
|
||||
showItemDetail(partNumber);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDetailProjectTag(partNumber, projectCode) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/items/${partNumber}/projects/${projectCode}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const err = await response.json();
|
||||
alert(err.message || err.error);
|
||||
return;
|
||||
}
|
||||
showItemDetail(partNumber);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
initLayout();
|
||||
loadSchema();
|
||||
|
||||
@@ -24,6 +24,10 @@ type Item struct {
|
||||
CADFilePath *string
|
||||
CreatedBy *string
|
||||
UpdatedBy *string
|
||||
SourcingType string // "manufactured" or "purchased"
|
||||
SourcingLink *string // URL to supplier/datasheet
|
||||
LongDescription *string // extended description
|
||||
StandardCost *float64 // baseline unit cost
|
||||
}
|
||||
|
||||
// Revision represents a revision record.
|
||||
@@ -85,11 +89,18 @@ func NewItemRepository(db *DB) *ItemRepository {
|
||||
func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[string]any) error {
|
||||
return r.db.Tx(ctx, func(tx pgx.Tx) error {
|
||||
// Insert item
|
||||
sourcingType := item.SourcingType
|
||||
if sourcingType == "" {
|
||||
sourcingType = "manufactured"
|
||||
}
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO items (part_number, schema_id, item_type, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO items (part_number, schema_id, item_type, description, created_by,
|
||||
sourcing_type, sourcing_link, long_description, standard_cost)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, created_at, updated_at, current_revision
|
||||
`, item.PartNumber, item.SchemaID, item.ItemType, item.Description, item.CreatedBy).Scan(
|
||||
`, item.PartNumber, item.SchemaID, item.ItemType, item.Description, item.CreatedBy,
|
||||
sourcingType, item.SourcingLink, item.LongDescription, item.StandardCost,
|
||||
).Scan(
|
||||
&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.CurrentRevision,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -120,13 +131,15 @@ func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string)
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT id, part_number, schema_id, item_type, description,
|
||||
created_at, updated_at, archived_at, current_revision,
|
||||
cad_synced_at, cad_file_path
|
||||
cad_synced_at, cad_file_path,
|
||||
sourcing_type, sourcing_link, long_description, standard_cost
|
||||
FROM items
|
||||
WHERE part_number = $1 AND archived_at IS NULL
|
||||
`, partNumber).Scan(
|
||||
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
||||
&item.CADSyncedAt, &item.CADFilePath,
|
||||
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -143,13 +156,15 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error)
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT id, part_number, schema_id, item_type, description,
|
||||
created_at, updated_at, archived_at, current_revision,
|
||||
cad_synced_at, cad_file_path
|
||||
cad_synced_at, cad_file_path,
|
||||
sourcing_type, sourcing_link, long_description, standard_cost
|
||||
FROM items
|
||||
WHERE id = $1
|
||||
`, id).Scan(
|
||||
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
||||
&item.CADSyncedAt, &item.CADFilePath,
|
||||
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -171,7 +186,8 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
|
||||
// Filter by project via many-to-many relationship
|
||||
query = `
|
||||
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
||||
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost
|
||||
FROM items i
|
||||
JOIN item_projects ip ON ip.item_id = i.id
|
||||
JOIN projects p ON p.id = ip.project_id
|
||||
@@ -182,7 +198,8 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
|
||||
} else {
|
||||
query = `
|
||||
SELECT id, part_number, schema_id, item_type, description,
|
||||
created_at, updated_at, archived_at, current_revision
|
||||
created_at, updated_at, archived_at, current_revision,
|
||||
sourcing_type, sourcing_link, long_description, standard_cost
|
||||
FROM items
|
||||
WHERE archived_at IS NULL
|
||||
`
|
||||
@@ -233,6 +250,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
|
||||
err := rows.Scan(
|
||||
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
||||
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning item: %w", err)
|
||||
@@ -626,14 +644,35 @@ func (r *ItemRepository) Archive(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update modifies an item's part number, type, and description.
|
||||
// The UUID remains stable.
|
||||
func (r *ItemRepository) Update(ctx context.Context, id string, partNumber string, itemType string, description string, updatedBy *string) error {
|
||||
// UpdateItemFields holds the fields that can be updated on an item.
|
||||
type UpdateItemFields struct {
|
||||
PartNumber string
|
||||
ItemType string
|
||||
Description string
|
||||
UpdatedBy *string
|
||||
SourcingType *string
|
||||
SourcingLink *string
|
||||
LongDescription *string
|
||||
StandardCost *float64
|
||||
}
|
||||
|
||||
// Update modifies an item's fields. The UUID remains stable.
|
||||
func (r *ItemRepository) Update(ctx context.Context, id string, fields UpdateItemFields) error {
|
||||
_, err := r.db.pool.Exec(ctx, `
|
||||
UPDATE items
|
||||
SET part_number = $2, item_type = $3, description = $4, updated_by = $5, updated_at = now()
|
||||
SET part_number = $2, item_type = $3, description = $4, updated_by = $5,
|
||||
sourcing_type = COALESCE($6, sourcing_type),
|
||||
sourcing_link = CASE WHEN $7::boolean THEN $8 ELSE sourcing_link END,
|
||||
long_description = CASE WHEN $9::boolean THEN $10 ELSE long_description END,
|
||||
standard_cost = CASE WHEN $11::boolean THEN $12 ELSE standard_cost END,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND archived_at IS NULL
|
||||
`, id, partNumber, itemType, description, updatedBy)
|
||||
`, id, fields.PartNumber, fields.ItemType, fields.Description, fields.UpdatedBy,
|
||||
fields.SourcingType,
|
||||
fields.SourcingLink != nil, fields.SourcingLink,
|
||||
fields.LongDescription != nil, fields.LongDescription,
|
||||
fields.StandardCost != nil, fields.StandardCost,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating item: %w", err)
|
||||
}
|
||||
|
||||
@@ -239,7 +239,8 @@ func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID st
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
||||
i.cad_synced_at, i.cad_file_path
|
||||
i.cad_synced_at, i.cad_file_path,
|
||||
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost
|
||||
FROM items i
|
||||
JOIN item_projects ip ON ip.item_id = i.id
|
||||
WHERE ip.project_id = $1 AND i.archived_at IS NULL
|
||||
@@ -257,6 +258,7 @@ func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID st
|
||||
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
||||
&item.CADSyncedAt, &item.CADFilePath,
|
||||
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
21
migrations/009_item_extended_fields.sql
Normal file
21
migrations/009_item_extended_fields.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Migration 009: Extended Item Fields
|
||||
--
|
||||
-- Adds sourcing classification, sourcing link, long description, and standard cost
|
||||
-- directly to the items table for first-class item metadata.
|
||||
|
||||
-- Sourcing type: manufactured in-house vs. purchased/COTS
|
||||
-- Using a check constraint rather than an enum for flexibility.
|
||||
ALTER TABLE items ADD COLUMN sourcing_type VARCHAR(20) NOT NULL DEFAULT 'manufactured'
|
||||
CHECK (sourcing_type IN ('manufactured', 'purchased'));
|
||||
|
||||
-- Sourcing link: URL to supplier page, datasheet, or procurement source
|
||||
ALTER TABLE items ADD COLUMN sourcing_link TEXT;
|
||||
|
||||
-- Long description: extended description for detailed specifications, notes, etc.
|
||||
ALTER TABLE items ADD COLUMN long_description TEXT;
|
||||
|
||||
-- Standard cost: baseline unit cost for budgeting/estimation (precision: up to $9,999,999.99)
|
||||
ALTER TABLE items ADD COLUMN standard_cost DECIMAL(12, 4);
|
||||
|
||||
-- Index on sourcing_type for filtering
|
||||
CREATE INDEX idx_items_sourcing_type ON items(sourcing_type);
|
||||
Reference in New Issue
Block a user