Reintegrates C-West8's "storing data in DB instead of client only"
(commit e5102446 on shared-data) on top of the BIM / per-package work.
localStorage becomes a per-browser cache; the server is authoritative.
- project-data.js: pullProject() hydrates the apps' existing localStorage
keys from the API on load; pushSOP()/pushWP()/removeWP() write through
on save/delete. WPs store the whole flat object in `data`, so BIM
fields, kind, and projectLinks round-trip intact.
- index.html: home pulls the project before showing SOP status; feedback
loads from /api/comments (server-authoritative, local fallback).
- work-package-suite-app.js: pull-then-restore on boot; completeSOP
pushes the SOP to the server.
- wp-creation-app.js: save/duplicate/issue/setStatus push; delete/clear
remove; boot pulls from the server first, then boots off the cache.
- server/app.py: /api/sops and /api/wps take full=true to return the
data JSON for one-request hydration (list stays lean by default).
Co-Authored-By: C-West8 <125926137+C-West8@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
9.4 KiB
JavaScript
201 lines
9.4 KiB
JavaScript
/* Shared project layer for the Work Package Suite.
|
|
Projects are the top-level container — every SOP and Work Package belongs to
|
|
one. Project records live in the SQL database (via /api/projects); this
|
|
adapter is API-first and falls back to a localStorage mirror so the suite
|
|
still works in local dev / offline. Included by the home page and the suite. */
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
var API = '/api';
|
|
var LS_PROJECTS = 'wp_projects'; // local mirror of the project list
|
|
var LS_ACTIVE = 'wp_active_project'; // active project id
|
|
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
|
|
|
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
|
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
|
|
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
|
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
|
function cacheUpsert(p) {
|
|
var list = readLocal();
|
|
var ix = list.findIndex(function (x) { return x.id === p.id; });
|
|
if (ix >= 0) list[ix] = p; else list.unshift(p);
|
|
writeLocal(list);
|
|
}
|
|
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
|
|
|
var SAMPLE_PROJECT = {
|
|
name: 'Micron FMCS Install (sample)', number: '26-67-008',
|
|
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
|
site: 'Boise, ID — Fab', sample: true
|
|
};
|
|
|
|
var ProjectData = {
|
|
SAMPLE: SAMPLE_PROJECT,
|
|
esc: esc,
|
|
|
|
// Returns the project list. Tries the API; falls back to the local mirror.
|
|
list: function () {
|
|
return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
|
.then(function (rows) { writeLocal(rows); return rows; })
|
|
.catch(function () { return readLocal(); });
|
|
},
|
|
|
|
get: function (id) {
|
|
return fetch(API + '/projects/' + encodeURIComponent(id))
|
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
|
.catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; });
|
|
},
|
|
|
|
// Create or update. Assigns an id when new. Mirrors to localStorage either way.
|
|
save: function (p) {
|
|
if (!p.id) p.id = uid();
|
|
return fetch(API + '/projects', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p)
|
|
})
|
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
|
.then(function (saved) { cacheUpsert(saved); return saved; })
|
|
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
|
|
},
|
|
|
|
remove: function (id) {
|
|
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
|
.then(function () { cacheRemove(id); })
|
|
.catch(function () { cacheRemove(id); });
|
|
},
|
|
|
|
// ── active project context ────────────────────────────────────────────────
|
|
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
|
|
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
|
setActive: function (p) {
|
|
try {
|
|
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
|
|
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
|
|
} catch (e) {}
|
|
},
|
|
|
|
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
|
|
// key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12'
|
|
// Falls back to the bare key when no project is active.
|
|
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
|
};
|
|
|
|
// ── Server sync for SOPs and Work Packages ─────────────────────────────────
|
|
// SOPs and WPs are authoritative on the server (so every user of a project sees
|
|
// the same data). To avoid rewriting the two apps, we keep their existing
|
|
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
|
|
// keys from the API on page load, and the push* helpers write through to the
|
|
// API whenever the apps save. The apps' own (synchronous) reads are unchanged.
|
|
// (Original author: C-West8, "storing data in DB instead of client only";
|
|
// reintegrated on top of the BIM/per-package work.)
|
|
function nsKey(base, id) { return id ? base + '__' + id : base; }
|
|
function currentUser() {
|
|
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
|
|
}
|
|
|
|
// A saved Work Package is a flat object in the browser; the API splits it into
|
|
// promoted columns + a `data` blob. We store the whole flat object in `data`
|
|
// for perfect round-tripping (so BIM fields, kind, projectLinks, etc. all
|
|
// survive), and mirror the few fields the API promotes to columns.
|
|
function pkgToServer(p, projectId) {
|
|
return {
|
|
id: p.id,
|
|
project_id: p.projectId || projectId || null,
|
|
parent_id: p.instanceOf || null,
|
|
number: p.number || '',
|
|
subject: p.subject || '',
|
|
type: p.type || '',
|
|
status: p.status || 'Draft',
|
|
created_by: p.createdBy || currentUser(),
|
|
data: p
|
|
};
|
|
}
|
|
function serverToPkg(row) {
|
|
var p = Object.assign({}, row.data || {}); // full flat object lives in data
|
|
p.id = row.id;
|
|
p.projectId = row.project_id || p.projectId || '';
|
|
if (row.number) p.number = row.number;
|
|
if (row.subject != null) p.subject = row.subject;
|
|
if (row.type != null) p.type = row.type;
|
|
if (row.status) p.status = row.status; // honor server-side status changes
|
|
if (row.parent_id) p.instanceOf = row.parent_id;
|
|
return p;
|
|
}
|
|
|
|
// Pull this project's SOP + WPs from the API into the localStorage keys the
|
|
// apps read. Resolves even on failure (offline / no API) so boot continues.
|
|
ProjectData.pullProject = function (projectId) {
|
|
if (!projectId) return Promise.resolve();
|
|
var jobs = [];
|
|
jobs.push(
|
|
fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (sopRow) {
|
|
if (sopRow && sopRow.data) {
|
|
var d = sopRow.data; // { sop, state } as written by pushSOP
|
|
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
|
|
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
|
|
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
|
}
|
|
}).catch(function () {})
|
|
);
|
|
jobs.push(
|
|
fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (rows) {
|
|
if (Array.isArray(rows)) {
|
|
localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg)));
|
|
}
|
|
}).catch(function () {})
|
|
);
|
|
return Promise.all(jobs).then(function () {});
|
|
};
|
|
|
|
// Write a completed SOP (plus the builder's raw state) to the API. Uses a
|
|
// deterministic id per project so re-completing updates the same row.
|
|
ProjectData.pushSOP = function (projectId, sop, state) {
|
|
if (!projectId) return Promise.resolve(null);
|
|
var body = {
|
|
id: 'sop__' + projectId,
|
|
project_id: projectId,
|
|
name: (sop && sop.project && sop.project.name) || 'SOP',
|
|
number: (sop && sop.project && sop.project.number) || '',
|
|
complete: true,
|
|
created_by: currentUser(),
|
|
data: { sop: sop, state: state }
|
|
};
|
|
return fetch(API + '/sops', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
|
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
|
};
|
|
|
|
// Upsert a single Work Package to the API (fire-and-forget from the caller's
|
|
// perspective; the local cache is the source of truth for immediate rendering).
|
|
ProjectData.pushWP = function (p, projectId) {
|
|
if (!p || !p.id) return Promise.resolve(null);
|
|
return fetch(API + '/wps', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
|
|
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
|
};
|
|
|
|
ProjectData.removeWP = function (id) {
|
|
if (!id) return Promise.resolve();
|
|
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
|
|
.then(function () {}).catch(function () {});
|
|
};
|
|
|
|
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
|
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
|
try {
|
|
if (!localStorage.getItem('wp_ns_migrated_v1')) {
|
|
['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) {
|
|
try { localStorage.removeItem(k); } catch (e) {}
|
|
});
|
|
localStorage.setItem('wp_ns_migrated_v1', '1');
|
|
}
|
|
} catch (e) {}
|
|
|
|
global.ProjectData = ProjectData;
|
|
})(window);
|