Add multi-project support: projects entity, picker home page, project context
Projects become the top-level container; SOPs and Work Packages belong to one. Backend: - New projects table + CRUD (/api/projects). - sops.project_id (FK, cascade) and work_packages.project_id added; list/latest/metrics endpoints accept a project_id filter. Front end (now under html/): - project-data.js: shared API-first ProjectData adapter with localStorage fallback + active-project helpers. - Home page: removed "About This Suite"; added a Project picker (create / use sample / select). Tool cards stay hidden until a project is active and carry &project=<id>; hero shows the active project. - Suite reads ?project, resolves it, shows it in the header, and prefills the SOP project fields; passes &project into the WP-creator iframe. - WP creator stamps projectId onto saved packages. SOP/WP localStorage is not yet namespaced per project (next step). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,42 @@ to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged.
|
|||||||
> real data this is fine; once there is, add Alembic (see open question #2) and
|
> real data this is fine; once there is, add Alembic (see open question #2) and
|
||||||
> migrate rather than relying on `create_all`.
|
> migrate rather than relying on `create_all`.
|
||||||
|
|
||||||
|
## Multi-project support
|
||||||
|
|
||||||
|
> **Note on layout:** the IT admin moved all static files into **`html/`** and
|
||||||
|
> added a Docker/NGINX deployment (`Dockerfile`, `docker-compose.yml`, `nginx/`).
|
||||||
|
> Front-end paths below are under `html/`. `server/` stayed at the repo root.
|
||||||
|
|
||||||
|
The suite is now multi-project. **Projects are the top-level container**; every
|
||||||
|
SOP and Work Package belongs to one.
|
||||||
|
|
||||||
|
- **Backend:** new `projects` table + CRUD (`/api/projects`). `sops` gained
|
||||||
|
`project_id` (FK, cascade) and `work_packages` gained `project_id`; list/latest/
|
||||||
|
metrics endpoints accept a `project_id` filter.
|
||||||
|
- **Project layer:** [html/project-data.js](html/project-data.js) — a shared,
|
||||||
|
**API-first** `ProjectData` adapter (`list/get/save/remove` hit `/api/projects`)
|
||||||
|
that **falls back to a localStorage mirror** (`wp_projects`) when the API is
|
||||||
|
unreachable, plus active-project helpers (`getActive`/`setActive`, stored in
|
||||||
|
`wp_active_project` / `wp_active_project_obj`).
|
||||||
|
- **Home page** ([html/index.html](html/index.html)): "About This Suite" removed;
|
||||||
|
a **Project** picker added. With no projects it offers *Create Project* / *Use
|
||||||
|
Sample Project*; otherwise a dropdown to select. The tool cards stay hidden
|
||||||
|
until a project is active and then carry `&project=<id>`; the hero shows the
|
||||||
|
active project.
|
||||||
|
- **Suite** ([html/work-package-suite-app.js](html/work-package-suite-app.js)):
|
||||||
|
reads `?project=<id>`, resolves it via `ProjectData`, shows it in the header,
|
||||||
|
and **prefills the SOP project fields** (step 1) from the project record when
|
||||||
|
empty. Passes `&project` into the WP-creator iframe.
|
||||||
|
- **WP creator:** stamps `projectId` onto every saved package (for API sync).
|
||||||
|
|
||||||
|
**Not yet done (the next fork):** SOP/WP *localStorage* is still global, not
|
||||||
|
namespaced per project — selecting a different project locally still shows the
|
||||||
|
same `wp_suite_sop` / `wp_iwp_v1` data. The intended end state is per-project
|
||||||
|
data via the API (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`).
|
||||||
|
Decide whether to (a) namespace the local keys by project id with a migration of
|
||||||
|
existing un-namespaced data into a "default" project, or (b) jump straight to the
|
||||||
|
API for SOP/WP reads. See the open question below.
|
||||||
|
|
||||||
**Pending — Phase 2: wire the front end to the API**
|
**Pending — Phase 2: wire the front end to the API**
|
||||||
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
|
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
|
||||||
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
||||||
|
|||||||
188
html/index.html
188
html/index.html
@@ -348,6 +348,22 @@
|
|||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PROJECT PICKER */
|
||||||
|
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
||||||
|
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
||||||
|
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
|
||||||
|
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||||
|
border-radius: 6px; padding: 1.25rem; }
|
||||||
|
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
||||||
|
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
|
||||||
|
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
|
||||||
|
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
||||||
|
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
|
||||||
|
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
|
||||||
|
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
|
||||||
|
|
||||||
/* RESPONSIVE */
|
/* RESPONSIVE */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-content { flex-direction: column; text-align: center; }
|
.header-content { flex-direction: column; text-align: center; }
|
||||||
@@ -379,12 +395,19 @@
|
|||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Work Package Suite</h1>
|
<h1 id="hero-title">Work Package Suite</h1>
|
||||||
<p>Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.</p>
|
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TOOL CARDS -->
|
<!-- PROJECT SELECTION -->
|
||||||
<div class="cards-grid" id="overview">
|
<div class="section" id="project-section">
|
||||||
|
<h2>Project</h2>
|
||||||
|
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
|
||||||
|
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOOL CARDS (shown once a project is active) -->
|
||||||
|
<div class="cards-grid" id="overview" style="display:none">
|
||||||
|
|
||||||
<!-- SOP CONFIG -->
|
<!-- SOP CONFIG -->
|
||||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||||
@@ -443,25 +466,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SUPPORT SECTION -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>About This Suite</h2>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
|
|
||||||
<div>
|
|
||||||
<h3>Two-Step Workflow</h3>
|
|
||||||
<p>Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Leave Feedback</h3>
|
|
||||||
<p>Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Offline & Collaborative</h3>
|
|
||||||
<p>All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FOOTER -->
|
<!-- FOOTER -->
|
||||||
@@ -470,20 +474,150 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Reflect SOP completion on the tool cards.
|
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||||
(function reflectSOPStatus(){
|
const esc = ProjectData.esc;
|
||||||
|
let _projects = [];
|
||||||
|
|
||||||
|
function initProjects(){
|
||||||
|
ProjectData.list().then(list => {
|
||||||
|
_projects = list || [];
|
||||||
|
// Reconcile the active project against the list; clear if it's gone.
|
||||||
|
const active = ProjectData.getActive();
|
||||||
|
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
|
||||||
|
renderProjectPicker();
|
||||||
|
applyActiveProject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFormHtml(){
|
||||||
|
return `<div class="proj-form" id="proj-form" style="display:none">
|
||||||
|
<div class="proj-form-grid">
|
||||||
|
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
|
||||||
|
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
||||||
|
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
||||||
|
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
||||||
|
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
||||||
|
</div>
|
||||||
|
<div class="proj-actions">
|
||||||
|
<button class="card-button" onclick="saveNewProject()">Create & Select</button>
|
||||||
|
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectPicker(){
|
||||||
|
const box = document.getElementById('project-picker');
|
||||||
|
const activeId = ProjectData.getActiveId();
|
||||||
|
if(!_projects.length){
|
||||||
|
box.innerHTML = `<div class="proj-empty">
|
||||||
|
<p>No projects yet. Create your first project, or start from a sample.</p>
|
||||||
|
<div class="proj-actions">
|
||||||
|
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
|
||||||
|
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
|
||||||
|
</div>
|
||||||
|
</div>` + createFormHtml();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = _projects.map(p =>
|
||||||
|
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
|
||||||
|
).join('');
|
||||||
|
box.innerHTML = `<div class="proj-row">
|
||||||
|
<select id="project-select" onchange="selectProject(this.value)">
|
||||||
|
<option value="">Select a project…</option>${opts}
|
||||||
|
</select>
|
||||||
|
<button class="card-button" onclick="showCreateProject()">+ New</button>
|
||||||
|
<button class="close-btn" onclick="useSampleProject()">Sample</button>
|
||||||
|
</div>
|
||||||
|
<div id="active-project-info"></div>` + createFormHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
|
||||||
|
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
|
||||||
|
|
||||||
|
function saveNewProject(){
|
||||||
|
const v = id => (document.getElementById(id)?.value || '').trim();
|
||||||
|
const name = v('np_name');
|
||||||
|
if(!name){ alert('Project name is required.'); return; }
|
||||||
|
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
|
||||||
|
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSampleProject(){
|
||||||
|
const existing = _projects.find(p => p.sample);
|
||||||
|
if(existing){ afterProjectChosen(existing); return; }
|
||||||
|
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProject(id){
|
||||||
|
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
||||||
|
const p = _projects.find(x => x.id === id);
|
||||||
|
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterProjectChosen(p){
|
||||||
|
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
||||||
|
ProjectData.setActive(p);
|
||||||
|
renderProjectPicker();
|
||||||
|
applyActiveProject();
|
||||||
|
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide the tool cards and stamp the active project into their links.
|
||||||
|
function applyActiveProject(){
|
||||||
|
const active = ProjectData.getActive();
|
||||||
|
const cards = document.getElementById('overview');
|
||||||
|
const heroTitle = document.getElementById('hero-title');
|
||||||
|
const heroSub = document.getElementById('hero-sub');
|
||||||
|
const info = document.getElementById('active-project-info');
|
||||||
|
|
||||||
|
if(!active){
|
||||||
|
cards.style.display = 'none';
|
||||||
|
heroTitle.textContent = 'Work Package Suite';
|
||||||
|
heroSub.textContent = 'Select a project to begin — or create one.';
|
||||||
|
if(info) info.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = '&project=' + encodeURIComponent(active.id);
|
||||||
|
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
|
||||||
|
setHref('card-sop', 'work-package-suite.html?tab=sop');
|
||||||
|
setHref('card-wp', 'work-package-suite.html?tab=wp');
|
||||||
|
setHref('card-dash', 'work-package-suite.html?view=dashboard');
|
||||||
|
|
||||||
|
cards.style.display = '';
|
||||||
|
heroTitle.textContent = active.name || 'Work Package Suite';
|
||||||
|
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
|
||||||
|
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
||||||
|
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
||||||
|
|
||||||
|
reflectSOPStatus(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||||
|
|
||||||
|
// Reflect SOP completion on the tool cards (scoped to the active project).
|
||||||
|
function reflectSOPStatus(active){
|
||||||
let complete = false, projName = '';
|
let complete = false, projName = '';
|
||||||
try {
|
try {
|
||||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
||||||
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
||||||
projName = sop && sop.project && sop.project.name || '';
|
projName = sop && sop.project && sop.project.name || '';
|
||||||
|
// Only treat the SOP as belonging to this project when names match.
|
||||||
|
if(active && projName && active.name && projName !== active.name) complete = false;
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
|
|
||||||
const sopCard = document.getElementById('card-sop');
|
const sopCard = document.getElementById('card-sop');
|
||||||
const sopBtn = document.getElementById('card-sop-btn');
|
const sopBtn = document.getElementById('card-sop-btn');
|
||||||
const wpCard = document.getElementById('card-wp');
|
const wpCard = document.getElementById('card-wp');
|
||||||
const wpBtn = document.getElementById('card-wp-btn');
|
const wpBtn = document.getElementById('card-wp-btn');
|
||||||
|
if(!sopCard) return;
|
||||||
|
|
||||||
|
// reset (re-render can run multiple times)
|
||||||
|
sopCard.classList.remove('complete');
|
||||||
|
wpCard && wpCard.classList.remove('disabled');
|
||||||
|
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
|
||||||
|
|
||||||
if(complete){
|
if(complete){
|
||||||
sopCard.classList.add('complete');
|
sopCard.classList.add('complete');
|
||||||
@@ -497,7 +631,9 @@
|
|||||||
if(wpCard) wpCard.classList.add('disabled');
|
if(wpCard) wpCard.classList.add('disabled');
|
||||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
initProjects();
|
||||||
|
|
||||||
let allComments = [];
|
let allComments = [];
|
||||||
|
|
||||||
|
|||||||
80
html/project-data.js
Normal file
80
html/project-data.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/* 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 — INC Construction Work Packages', 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) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
global.ProjectData = ProjectData;
|
||||||
|
})(window);
|
||||||
@@ -130,8 +130,9 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
updateStepUI();
|
updateStepUI();
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
|
|
||||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page cards.
|
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?project=<id> from the home page.
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
applyProjectContext(params.get('project'));
|
||||||
const tab = params.get('tab');
|
const tab = params.get('tab');
|
||||||
if(params.get('view') === 'dashboard') switchTool('wp');
|
if(params.get('view') === 'dashboard') switchTool('wp');
|
||||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||||
@@ -284,8 +285,11 @@ function renderWPTab(){
|
|||||||
gate.style.display = 'none';
|
gate.style.display = 'none';
|
||||||
frame.style.display = 'block';
|
frame.style.display = 'block';
|
||||||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
// Reload each time so the creator picks up the latest SOP from localStorage.
|
||||||
const wantDash = new URLSearchParams(window.location.search).get('view') === 'dashboard';
|
const sp = new URLSearchParams(window.location.search);
|
||||||
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + '&t=' + Date.now();
|
const wantDash = sp.get('view') === 'dashboard';
|
||||||
|
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||||||
|
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '')
|
||||||
|
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
||||||
}else{
|
}else{
|
||||||
gate.style.display = 'block';
|
gate.style.display = 'block';
|
||||||
frame.style.display = 'none';
|
frame.style.display = 'none';
|
||||||
@@ -297,8 +301,40 @@ function onSOPReady(){
|
|||||||
if(currentTool === 'wp') renderWPTab();
|
if(currentTool === 'wp') renderWPTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active project comes from the home page (?project=<id> + ProjectData.getActive()).
|
||||||
|
// When the SOP's project fields are still empty, prefill them from the project
|
||||||
|
// record so the SOP is authored against the chosen project.
|
||||||
|
let activeProject = null;
|
||||||
|
function applyProjectContext(projectId){
|
||||||
|
try { if(typeof ProjectData !== 'undefined') activeProject = ProjectData.getActive(); } catch(e){}
|
||||||
|
if(projectId && (!activeProject || activeProject.id !== projectId)){
|
||||||
|
// Deep-linked to a project we don't have cached yet — resolve it, then prefill.
|
||||||
|
try {
|
||||||
|
if(typeof ProjectData !== 'undefined'){
|
||||||
|
ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } });
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
}
|
||||||
|
prefillProjectFields();
|
||||||
|
}
|
||||||
|
function prefillProjectFields(){
|
||||||
|
if(!activeProject) return;
|
||||||
|
const set = (id,v)=>{ const el=document.getElementById(id); if(el && !el.value && v) el.value = v; };
|
||||||
|
set('proj_name', activeProject.name);
|
||||||
|
set('proj_number', activeProject.number);
|
||||||
|
set('proj_client', activeProject.client);
|
||||||
|
set('proj_division', activeProject.division);
|
||||||
|
set('proj_site', activeProject.site);
|
||||||
|
if(typeof state !== 'undefined' && state.project){
|
||||||
|
state.project.name = state.project.name || activeProject.name || '';
|
||||||
|
state.project.number = state.project.number || activeProject.number || '';
|
||||||
|
state.project.client = state.project.client || activeProject.client || '';
|
||||||
|
state.project.division = state.project.division || activeProject.division || '';
|
||||||
|
state.project.site = state.project.site || activeProject.site || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
function updateProjectDisplay(){
|
function updateProjectDisplay(){
|
||||||
const projName = document.getElementById('proj_name')?.value || 'Project';
|
const projName = document.getElementById('proj_name')?.value || (activeProject && activeProject.name) || 'Project';
|
||||||
const display = document.getElementById('project-display');
|
const display = document.getElementById('project-display');
|
||||||
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,6 +369,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
<script src="work-package-suite-app.js"></script>
|
<script src="work-package-suite-app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":
|
|||||||
|
|
||||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||||
let SOP=null, editingId=null, numberDirty=false;
|
let SOP=null, editingId=null, numberDirty=false;
|
||||||
|
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
||||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||||
@@ -631,6 +632,7 @@ function collectPackage(){
|
|||||||
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
||||||
return {
|
return {
|
||||||
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
||||||
|
projectId: (prev&&prev.projectId) || activeProjectId || '',
|
||||||
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
||||||
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
||||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||||||
@@ -1016,6 +1018,7 @@ loadStore();
|
|||||||
// and prefer the SOP the Suite just completed (persisted to localStorage).
|
// and prefer the SOP the Suite just completed (persisted to localStorage).
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
if(params.get('embedded')) document.body.classList.add('embedded');
|
if(params.get('embedded')) document.body.classList.add('embedded');
|
||||||
|
activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('wp_suite_sop');
|
const raw = localStorage.getItem('wp_suite_sop');
|
||||||
if(raw){
|
if(raw){
|
||||||
|
|||||||
@@ -276,6 +276,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
<script src="wp-creation-app.js"></script>
|
<script src="wp-creation-app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -41,8 +41,21 @@ def gen_id(prefix: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||||
|
class ProjectIn(BaseModel):
|
||||||
|
id: Optional[str] = None
|
||||||
|
name: str = ""
|
||||||
|
number: str = ""
|
||||||
|
client: str = ""
|
||||||
|
division: str = ""
|
||||||
|
site: str = ""
|
||||||
|
sample: bool = False
|
||||||
|
created_by: str = ""
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class SopIn(BaseModel):
|
class SopIn(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
project_id: Optional[str] = None
|
||||||
name: str = ""
|
name: str = ""
|
||||||
number: str = ""
|
number: str = ""
|
||||||
complete: bool = False
|
complete: bool = False
|
||||||
@@ -52,6 +65,7 @@ class SopIn(BaseModel):
|
|||||||
|
|
||||||
class WpIn(BaseModel):
|
class WpIn(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
project_id: Optional[str] = None
|
||||||
sop_id: Optional[str] = None
|
sop_id: Optional[str] = None
|
||||||
parent_id: Optional[str] = None
|
parent_id: Optional[str] = None
|
||||||
number: str = ""
|
number: str = ""
|
||||||
@@ -86,6 +100,50 @@ def health():
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||||
|
@app.post("/api/projects")
|
||||||
|
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, body.id) if body.id else None
|
||||||
|
if proj is None:
|
||||||
|
proj = models.Project(id=body.id or gen_id("proj"))
|
||||||
|
db.add(proj)
|
||||||
|
proj.name = body.name
|
||||||
|
proj.number = body.number
|
||||||
|
proj.client = body.client
|
||||||
|
proj.division = body.division
|
||||||
|
proj.site = body.site
|
||||||
|
proj.sample = body.sample
|
||||||
|
proj.created_by = body.created_by or proj.created_by
|
||||||
|
proj.data = body.data
|
||||||
|
db.commit()
|
||||||
|
db.refresh(proj)
|
||||||
|
return proj.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects")
|
||||||
|
def list_projects(db: Session = Depends(get_db)):
|
||||||
|
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all()
|
||||||
|
return [p.summary() for p in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects/{project_id}")
|
||||||
|
def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, project_id)
|
||||||
|
if not proj:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return proj.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/projects/{project_id}")
|
||||||
|
def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, project_id)
|
||||||
|
if not proj:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
db.delete(proj)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": project_id}
|
||||||
|
|
||||||
|
|
||||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||||
@app.post("/api/sops")
|
@app.post("/api/sops")
|
||||||
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||||
@@ -93,6 +151,7 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
|||||||
if sop is None:
|
if sop is None:
|
||||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||||
db.add(sop)
|
db.add(sop)
|
||||||
|
sop.project_id = body.project_id
|
||||||
sop.name = body.name
|
sop.name = body.name
|
||||||
sop.number = body.number
|
sop.number = body.number
|
||||||
sop.complete = body.complete
|
sop.complete = body.complete
|
||||||
@@ -104,16 +163,21 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/sops")
|
@app.get("/api/sops")
|
||||||
def list_sops(db: Session = Depends(get_db)):
|
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||||
rows = db.scalars(select(models.Sop).order_by(models.Sop.updated_at.desc())).all()
|
stmt = select(models.Sop)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||||
|
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
|
||||||
return [s.summary() for s in rows]
|
return [s.summary() for s in rows]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/sops/latest")
|
@app.get("/api/sops/latest")
|
||||||
def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)):
|
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||||
stmt = select(models.Sop)
|
stmt = select(models.Sop)
|
||||||
if complete is not None:
|
if complete is not None:
|
||||||
stmt = stmt.where(models.Sop.complete == complete)
|
stmt = stmt.where(models.Sop.complete == complete)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||||
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
||||||
if not sop:
|
if not sop:
|
||||||
raise HTTPException(status_code=404, detail="No SOP found")
|
raise HTTPException(status_code=404, detail="No SOP found")
|
||||||
@@ -145,6 +209,7 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
|||||||
if wp is None:
|
if wp is None:
|
||||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||||
db.add(wp)
|
db.add(wp)
|
||||||
|
wp.project_id = body.project_id
|
||||||
wp.sop_id = body.sop_id
|
wp.sop_id = body.sop_id
|
||||||
wp.parent_id = body.parent_id
|
wp.parent_id = body.parent_id
|
||||||
wp.number = body.number
|
wp.number = body.number
|
||||||
@@ -160,12 +225,15 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@app.get("/api/wps")
|
@app.get("/api/wps")
|
||||||
def list_wps(
|
def list_wps(
|
||||||
|
project_id: Optional[str] = Query(None),
|
||||||
sop_id: Optional[str] = Query(None),
|
sop_id: Optional[str] = Query(None),
|
||||||
parent_id: Optional[str] = Query(None),
|
parent_id: Optional[str] = Query(None),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(models.WorkPackage)
|
stmt = select(models.WorkPackage)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||||
if sop_id:
|
if sop_id:
|
||||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||||
if parent_id:
|
if parent_id:
|
||||||
@@ -177,11 +245,13 @@ def list_wps(
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/wps/metrics")
|
@app.get("/api/wps/metrics")
|
||||||
def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||||
from counts so a split package's hours aren't double-counted with its
|
from counts so a split package's hours aren't double-counted with its
|
||||||
instances."""
|
instances."""
|
||||||
stmt = select(models.WorkPackage)
|
stmt = select(models.WorkPackage)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||||
if sop_id:
|
if sop_id:
|
||||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||||
rows = db.scalars(stmt).all()
|
rows = db.scalars(stmt).all()
|
||||||
|
|||||||
@@ -21,10 +21,42 @@ def utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
"""A construction project — the top-level container. SOPs and Work Packages
|
||||||
|
belong to a project so the suite can be used for many jobs at once."""
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
number: Mapped[str] = mapped_column(String(100), default="", index=True)
|
||||||
|
client: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
division: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
site: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
sample: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id, "name": self.name, "number": self.number,
|
||||||
|
"client": self.client, "division": self.division, "site": self.site,
|
||||||
|
"sample": self.sample, "created_by": self.created_by,
|
||||||
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {**self.summary(), "data": self.data or {}}
|
||||||
|
|
||||||
|
|
||||||
class Sop(Base):
|
class Sop(Base):
|
||||||
__tablename__ = "sops"
|
__tablename__ = "sops"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
name: Mapped[str] = mapped_column(String(300), default="")
|
name: Mapped[str] = mapped_column(String(300), default="")
|
||||||
number: Mapped[str] = mapped_column(String(100), default="")
|
number: Mapped[str] = mapped_column(String(100), default="")
|
||||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
@@ -35,8 +67,8 @@ class Sop(Base):
|
|||||||
|
|
||||||
def summary(self) -> dict:
|
def summary(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id, "name": self.name, "number": self.number,
|
"id": self.id, "project_id": self.project_id, "name": self.name,
|
||||||
"complete": self.complete, "created_by": self.created_by,
|
"number": self.number, "complete": self.complete, "created_by": self.created_by,
|
||||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +80,9 @@ class WorkPackage(Base):
|
|||||||
__tablename__ = "work_packages"
|
__tablename__ = "work_packages"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
sop_id: Mapped[Optional[str]] = mapped_column(
|
sop_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
@@ -65,9 +100,9 @@ class WorkPackage(Base):
|
|||||||
|
|
||||||
def summary(self) -> dict:
|
def summary(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id,
|
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||||
"number": self.number, "subject": self.subject, "type": self.type,
|
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||||
"status": self.status, "issued_at": _iso(self.issued_at),
|
"type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
|
||||||
"created_by": self.created_by,
|
"created_by": self.created_by,
|
||||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user