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:
Forbes
2026-01-31 14:27:11 -06:00
parent 0888c0210c
commit fc0eb6d2be
6 changed files with 431 additions and 48 deletions

View File

@@ -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]
}

View File

@@ -232,16 +232,24 @@ 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"`
}
// CreateItemRequest represents a request to create an item.
type CreateItemRequest struct {
Schema string `json:"schema"`
Category string `json:"category"`
Description string `json:"description"`
Projects []string `json:"projects,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
Schema string `json:"schema"`
Category string `json:"category"`
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.
@@ -370,9 +378,13 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
// Create item
item := &db.Item{
PartNumber: partNumber,
ItemType: itemType,
Description: req.Description,
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
@@ -439,11 +451,15 @@ func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {
// UpdateItemRequest represents a request to update an item.
type UpdateItemRequest struct {
PartNumber string `json:"part_number,omitempty"`
ItemType string `json:"item_type,omitempty"`
Description string `json:"description,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
Comment string `json:"comment,omitempty"`
PartNumber string `json:"part_number,omitempty"`
ItemType string `json:"item_type,omitempty"`
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,
}
}

View File

@@ -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">&times;</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();

View File

@@ -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)
}

View File

@@ -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
}

View 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);