Compare commits
2 Commits
17e05cb61d
...
6307babbfa
| Author | SHA1 | Date | |
|---|---|---|---|
| 6307babbfa | |||
| 309f19520b |
@@ -178,14 +178,17 @@ func (h *ProfileHandler) CreateStep(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusBadRequest, "title required")
|
respondError(w, http.StatusBadRequest, "title required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if body.StepType == "" {
|
||||||
|
body.StepType = "work_step"
|
||||||
|
}
|
||||||
var maxOrder int
|
var maxOrder int
|
||||||
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_profile_steps WHERE profile_id=@p1`, profileID).Scan(&maxOrder)
|
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_profile_steps WHERE profile_id=@p1`, profileID).Scan(&maxOrder)
|
||||||
|
|
||||||
var sid int
|
var sid int
|
||||||
err = h.db.QueryRow(`
|
err = h.db.QueryRow(`
|
||||||
INSERT INTO wo_profile_steps (profile_id, step_order, title, description, required)
|
INSERT INTO wo_profile_steps (profile_id, step_order, title, description, required, step_type, type_config)
|
||||||
OUTPUT INSERTED.id VALUES (@p1, @p2, @p3, @p4, @p5)`,
|
OUTPUT INSERTED.id VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`,
|
||||||
profileID, maxOrder+1, body.Title, body.Description, body.Required,
|
profileID, maxOrder+1, body.Title, body.Description, body.Required, body.StepType, body.TypeConfig,
|
||||||
).Scan(&sid)
|
).Scan(&sid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, err.Error())
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -207,8 +210,11 @@ func (h *ProfileHandler) UpdateStep(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusBadRequest, "title required")
|
respondError(w, http.StatusBadRequest, "title required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.db.Exec(`UPDATE wo_profile_steps SET title=@p1, description=@p2, required=@p3, step_order=@p4 WHERE id=@p5`,
|
if body.StepType == "" {
|
||||||
body.Title, body.Description, body.Required, body.StepOrder, sid)
|
body.StepType = "work_step"
|
||||||
|
}
|
||||||
|
h.db.Exec(`UPDATE wo_profile_steps SET title=@p1, description=@p2, required=@p3, step_order=@p4, step_type=@p5, type_config=@p6 WHERE id=@p7`,
|
||||||
|
body.Title, body.Description, body.Required, body.StepOrder, body.StepType, body.TypeConfig, sid)
|
||||||
var step model.ProfileStep
|
var step model.ProfileStep
|
||||||
h.db.Get(&step, `SELECT * FROM wo_profile_steps WHERE id=@p1`, sid)
|
h.db.Get(&step, `SELECT * FROM wo_profile_steps WHERE id=@p1`, sid)
|
||||||
respond(w, http.StatusOK, step)
|
respond(w, http.StatusOK, step)
|
||||||
@@ -262,10 +268,13 @@ func (h *ProfileHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
|
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
|
||||||
|
|
||||||
for _, s := range steps {
|
for _, s := range steps {
|
||||||
|
if s.StepType == "" {
|
||||||
|
s.StepType = "work_step"
|
||||||
|
}
|
||||||
h.db.Exec(`
|
h.db.Exec(`
|
||||||
INSERT INTO wo_steps (wo_id, step_order, title, description, required)
|
INSERT INTO wo_steps (wo_id, step_order, title, description, required, step_type, type_config)
|
||||||
VALUES (@p1, @p2, @p3, @p4, @p5)`,
|
VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7)`,
|
||||||
woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required)
|
woID, maxOrder+s.StepOrder, s.Title, s.Description, s.Required, s.StepType, s.TypeConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill instructions if blank; update priority only on draft WOs
|
// Fill instructions if blank; update priority only on draft WOs
|
||||||
|
|||||||
@@ -41,11 +41,14 @@ func (h *StepHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
var maxOrder int
|
var maxOrder int
|
||||||
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
|
h.db.QueryRow(`SELECT ISNULL(MAX(step_order),0) FROM wo_steps WHERE wo_id=@p1`, woID).Scan(&maxOrder)
|
||||||
|
|
||||||
|
if body.StepType == "" {
|
||||||
|
body.StepType = "work_step"
|
||||||
|
}
|
||||||
var sid int
|
var sid int
|
||||||
err = h.db.QueryRow(`
|
err = h.db.QueryRow(`
|
||||||
INSERT INTO wo_steps (wo_id,step_order,title,description,required)
|
INSERT INTO wo_steps (wo_id,step_order,title,description,required,step_type,type_config)
|
||||||
OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5)`,
|
OUTPUT INSERTED.id VALUES (@p1,@p2,@p3,@p4,@p5,@p6,@p7)`,
|
||||||
woID, maxOrder+1, body.Title, body.Description, body.Required,
|
woID, maxOrder+1, body.Title, body.Description, body.Required, body.StepType, body.TypeConfig,
|
||||||
).Scan(&sid)
|
).Scan(&sid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, err.Error())
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ type Step struct {
|
|||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
Description string `db:"description" json:"description"`
|
Description string `db:"description" json:"description"`
|
||||||
Required bool `db:"required" json:"required"`
|
Required bool `db:"required" json:"required"`
|
||||||
|
StepType string `db:"step_type" json:"step_type"`
|
||||||
|
TypeConfig string `db:"type_config" json:"type_config"`
|
||||||
Completed bool `db:"completed" json:"completed"`
|
Completed bool `db:"completed" json:"completed"`
|
||||||
CompletedBy *string `db:"completed_by" json:"completed_by"`
|
CompletedBy *string `db:"completed_by" json:"completed_by"`
|
||||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at"`
|
CompletedAt *time.Time `db:"completed_at" json:"completed_at"`
|
||||||
@@ -166,6 +168,8 @@ type ProfileStep struct {
|
|||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
Description string `db:"description" json:"description"`
|
Description string `db:"description" json:"description"`
|
||||||
Required bool `db:"required" json:"required"`
|
Required bool `db:"required" json:"required"`
|
||||||
|
StepType string `db:"step_type" json:"step_type"`
|
||||||
|
TypeConfig string `db:"type_config" json:"type_config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Users & Auth ──────────────────────────────────────────────────────────────
|
// ── Users & Auth ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Work Order Profiles
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profiles')
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE wo_profiles (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
name NVARCHAR(200) NOT NULL,
|
||||||
|
description NVARCHAR(MAX),
|
||||||
|
category NVARCHAR(100),
|
||||||
|
default_priority NVARCHAR(10) NOT NULL DEFAULT 'normal',
|
||||||
|
default_duration_hours INT,
|
||||||
|
default_instructions NVARCHAR(MAX),
|
||||||
|
active BIT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||||
|
updated_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||||
|
);
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'wo_profile_steps')
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE wo_profile_steps (
|
||||||
|
id INT IDENTITY PRIMARY KEY,
|
||||||
|
profile_id INT NOT NULL REFERENCES wo_profiles(id) ON DELETE CASCADE,
|
||||||
|
step_order INT NOT NULL,
|
||||||
|
title NVARCHAR(200) NOT NULL,
|
||||||
|
description NVARCHAR(MAX),
|
||||||
|
required BIT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_profile_steps ON wo_profile_steps (profile_id, step_order);
|
||||||
|
END
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add step_type and type_config to profile steps and WO steps
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'step_type')
|
||||||
|
ALTER TABLE wo_profile_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
||||||
|
-- 'work_step' | 'photo' | 'inspection' | 'note'
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'type_config')
|
||||||
|
ALTER TABLE wo_profile_steps ADD type_config NVARCHAR(MAX) NULL;
|
||||||
|
-- JSON; shape depends on step_type (see CLAUDE.md)
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type')
|
||||||
|
ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config')
|
||||||
|
ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add step_type and type_config to profile steps and WO steps
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'step_type')
|
||||||
|
ALTER TABLE wo_profile_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
||||||
|
-- 'work_step' | 'photo' | 'inspection' | 'note'
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_profile_steps') AND name = 'type_config')
|
||||||
|
ALTER TABLE wo_profile_steps ADD type_config NVARCHAR(MAX) NULL;
|
||||||
|
-- JSON; shape depends on step_type (see CLAUDE.md)
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'step_type')
|
||||||
|
ALTER TABLE wo_steps ADD step_type NVARCHAR(30) NOT NULL DEFAULT 'work_step';
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('wo_steps') AND name = 'type_config')
|
||||||
|
ALTER TABLE wo_steps ADD type_config NVARCHAR(MAX) NULL;
|
||||||
+1
-1
@@ -92,7 +92,7 @@ function showLoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [root] });
|
if (window.lucide) lucide.createIcons({ root: root });
|
||||||
|
|
||||||
root.querySelector('#login-form').addEventListener('submit', async e => {
|
root.querySelector('#login-form').addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class AppMobileNav extends HTMLElement {
|
|||||||
<span>${t.label}</span>
|
<span>${t.label}</span>
|
||||||
</a>`;
|
</a>`;
|
||||||
}).join('')}`;
|
}).join('')}`;
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [this] });
|
if (window.lucide) lucide.createIcons({ root: this });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
customElements.define('app-mobile-nav', AppMobileNav);
|
customElements.define('app-mobile-nav', AppMobileNav);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class AppRoot extends HTMLElement {
|
|||||||
if (main) {
|
if (main) {
|
||||||
main.innerHTML = html;
|
main.innerHTML = html;
|
||||||
main.scrollTop = 0;
|
main.scrollTop = 0;
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [main] });
|
if (window.lucide) lucide.createIcons({ root: main });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class AppSidebar extends HTMLElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [this] });
|
if (window.lucide) lucide.createIcons({ root: this });
|
||||||
this.querySelector('#toggle-btn')?.addEventListener('click', () => this.#toggle());
|
this.querySelector('#toggle-btn')?.addEventListener('click', () => this.#toggle());
|
||||||
this.querySelector('#logout-btn')?.addEventListener('click', () => {
|
this.querySelector('#logout-btn')?.addEventListener('click', () => {
|
||||||
clearToken();
|
clearToken();
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class AppTopbar extends HTMLElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>` : ''}`;
|
</div>` : ''}`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [this] });
|
if (window.lucide) lucide.createIcons({ root: this });
|
||||||
|
|
||||||
this.querySelector('#user-menu-btn')?.addEventListener('click', e => {
|
this.querySelector('#user-menu-btn')?.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class EquipmentForm extends HTMLElement {
|
|||||||
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Equipment'}</button>
|
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Equipment'}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [d] });
|
if (window.lucide) lucide.createIcons({ root: d });
|
||||||
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
import './equipment-form.mjs';
|
import './equipment-form.mjs';
|
||||||
|
|
||||||
class EquipmentList extends HTMLElement {
|
class EquipmentList extends HTMLElement {
|
||||||
@@ -64,7 +64,7 @@ class EquipmentList extends HTMLElement {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Equipment</h1>
|
<h1>Equipment</h1>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input class="search-input" id="search" placeholder="Search equipment…" value="${this.#esc(this.#search)}">
|
<input class="search-input" id="search" placeholder="Search equipment…" value="${this.#esc(this.#search)}">
|
||||||
<button class="btn-primary" id="new-btn">
|
<button class="btn-primary" id="new-btn">
|
||||||
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Equipment
|
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Equipment
|
||||||
</button>
|
</button>
|
||||||
@@ -102,7 +102,7 @@ class EquipmentList extends HTMLElement {
|
|||||||
|
|
||||||
<equipment-form id="equipment-form"></equipment-form>`;
|
<equipment-form id="equipment-form"></equipment-form>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
|
||||||
const searchEl = s.querySelector('#search');
|
const searchEl = s.querySelector('#search');
|
||||||
let debounce;
|
let debounce;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class MaterialForm extends HTMLElement {
|
|||||||
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Material'}</button>
|
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Material'}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [d] });
|
if (window.lucide) lucide.createIcons({ root: d });
|
||||||
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
import './material-form.mjs';
|
import './material-form.mjs';
|
||||||
|
|
||||||
class MaterialList extends HTMLElement {
|
class MaterialList extends HTMLElement {
|
||||||
@@ -65,7 +65,7 @@ class MaterialList extends HTMLElement {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Materials</h1>
|
<h1>Materials</h1>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input class="search-input" id="search" placeholder="Search materials…" value="${this.#esc(this.#search)}">
|
<input class="search-input" id="search" placeholder="Search materials…" value="${this.#esc(this.#search)}">
|
||||||
<button class="btn-primary" id="new-btn">
|
<button class="btn-primary" id="new-btn">
|
||||||
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Material
|
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Material
|
||||||
</button>
|
</button>
|
||||||
@@ -103,7 +103,7 @@ class MaterialList extends HTMLElement {
|
|||||||
|
|
||||||
<material-form id="material-form"></material-form>`;
|
<material-form id="material-form"></material-form>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
|
||||||
const searchEl = s.querySelector('#search');
|
const searchEl = s.querySelector('#search');
|
||||||
let debounce;
|
let debounce;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class PeopleForm extends HTMLElement {
|
|||||||
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Person'}</button>
|
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Person'}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [d] });
|
if (window.lucide) lucide.createIcons({ root: d });
|
||||||
|
|
||||||
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
import './people-form.mjs';
|
import './people-form.mjs';
|
||||||
|
|
||||||
class PeopleList extends HTMLElement {
|
class PeopleList extends HTMLElement {
|
||||||
@@ -76,7 +76,7 @@ class PeopleList extends HTMLElement {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>People</h1>
|
<h1>People</h1>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input class="search-input" id="search" placeholder="Search people…" value="${this.#esc(this.#search)}">
|
<input class="search-input" id="search" placeholder="Search people…" value="${this.#esc(this.#search)}">
|
||||||
<button class="btn-primary" id="new-btn">
|
<button class="btn-primary" id="new-btn">
|
||||||
<i data-lucide="user-plus" style="width:14px;height:14px"></i> Add Person
|
<i data-lucide="user-plus" style="width:14px;height:14px"></i> Add Person
|
||||||
</button>
|
</button>
|
||||||
@@ -115,7 +115,7 @@ class PeopleList extends HTMLElement {
|
|||||||
|
|
||||||
<people-form id="people-form"></people-form>`;
|
<people-form id="people-form"></people-form>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
|
||||||
const searchEl = s.querySelector('#search');
|
const searchEl = s.querySelector('#search');
|
||||||
let debounce;
|
let debounce;
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { api } from '../../lib/api.mjs';
|
|||||||
|
|
||||||
const PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
const PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||||
|
|
||||||
|
const STEP_TYPES = [
|
||||||
|
{ value: 'work_step', label: 'Work Step', icon: 'check-square', color: '#0A7EA4', hint: '' },
|
||||||
|
{ value: 'photo', label: 'Photo', icon: 'camera', color: '#8B5CF6', hint: 'Requires a photo to complete' },
|
||||||
|
{ value: 'inspection', label: 'Inspect', icon: 'clipboard-check', color: '#E07B39', hint: 'Pass / Fail / N/A' },
|
||||||
|
{ value: 'note', label: 'Note', icon: 'file-text', color: '#64748B', hint: 'Free-text entry' },
|
||||||
|
];
|
||||||
|
|
||||||
class ProfileForm extends HTMLElement {
|
class ProfileForm extends HTMLElement {
|
||||||
#onSave = null;
|
#onSave = null;
|
||||||
|
|
||||||
@@ -19,7 +26,7 @@ class ProfileForm extends HTMLElement {
|
|||||||
|
|
||||||
d.innerHTML = `
|
d.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:600px; max-width:96vw; max-height:90vh; background:var(--surface); color:var(--text); display:flex; flex-direction:column; }
|
dialog { border:1px solid var(--border); border-radius:var(--radius-lg); padding:0; box-shadow:var(--shadow-lg); width:640px; max-width:96vw; max-height:90vh; background:var(--surface); color:var(--text); display:flex; flex-direction:column; }
|
||||||
dialog::backdrop { background:rgba(0,0,0,.45); }
|
dialog::backdrop { background:rgba(0,0,0,.45); }
|
||||||
.dlg-header { display:flex; align-items:center; justify-content:space-between; padding:1.1rem 1.25rem; border-bottom:1px solid var(--border); flex-shrink:0; }
|
.dlg-header { display:flex; align-items:center; justify-content:space-between; padding:1.1rem 1.25rem; border-bottom:1px solid var(--border); flex-shrink:0; }
|
||||||
.dlg-title { font-size:1rem; font-weight:700; }
|
.dlg-title { font-size:1rem; font-weight:700; }
|
||||||
@@ -34,19 +41,41 @@ class ProfileForm extends HTMLElement {
|
|||||||
.toggle-label { font-size:.875rem; font-weight:500; }
|
.toggle-label { font-size:.875rem; font-weight:500; }
|
||||||
input[type=checkbox] { width:18px; height:18px; accent-color:var(--teal); cursor:pointer; }
|
input[type=checkbox] { width:18px; height:18px; accent-color:var(--teal); cursor:pointer; }
|
||||||
.section-title { font-size:.75rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; padding-top:.25rem; border-top:1px solid var(--border); margin-top:.25rem; }
|
.section-title { font-size:.75rem; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; padding-top:.25rem; border-top:1px solid var(--border); margin-top:.25rem; }
|
||||||
|
/* Step list */
|
||||||
.step-list { display:flex; flex-direction:column; gap:.4rem; }
|
.step-list { display:flex; flex-direction:column; gap:.4rem; }
|
||||||
.step-row { display:flex; align-items:center; gap:.5rem; background:var(--surface-2); border-radius:var(--radius-sm); padding:.4rem .6rem; }
|
.step-row { display:flex; align-items:center; gap:.5rem; background:var(--surface-2); border-radius:var(--radius-sm); padding:.45rem .6rem; border-left:3px solid var(--border); }
|
||||||
.step-order { font-size:.75rem; font-weight:700; color:var(--text-muted); min-width:1.5rem; text-align:center; }
|
.step-row[data-type="photo"] { border-left-color:#8B5CF6; }
|
||||||
.step-title-input { flex:1; border:none; background:transparent; font-size:.875rem; color:var(--text); padding:.15rem .3rem; border-radius:4px; }
|
.step-row[data-type="inspection"] { border-left-color:#E07B39; }
|
||||||
|
.step-row[data-type="note"] { border-left-color:#64748B; }
|
||||||
|
.step-row[data-type="work_step"] { border-left-color:var(--teal); }
|
||||||
|
.step-order { font-size:.75rem; font-weight:700; color:var(--text-muted); min-width:1.5rem; text-align:center; flex-shrink:0; }
|
||||||
|
.step-type-pill { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; padding:.15rem .4rem; border-radius:99px; white-space:nowrap; flex-shrink:0; cursor:pointer; }
|
||||||
|
.step-title-input { flex:1; border:none; background:transparent; font-size:.875rem; color:var(--text); padding:.15rem .3rem; border-radius:4px; min-width:0; }
|
||||||
.step-title-input:focus { outline:1px solid var(--teal); background:var(--surface); }
|
.step-title-input:focus { outline:1px solid var(--teal); background:var(--surface); }
|
||||||
.step-req { font-size:.7rem; color:var(--text-muted); white-space:nowrap; display:flex; align-items:center; gap:.25rem; }
|
.step-cfg-summary { font-size:.7rem; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:120px; }
|
||||||
.step-del { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.2rem; display:flex; border-radius:4px; }
|
.step-req { font-size:.7rem; color:var(--text-muted); white-space:nowrap; display:flex; align-items:center; gap:.25rem; flex-shrink:0; }
|
||||||
|
.step-del { background:none; border:none; cursor:pointer; color:var(--text-muted); padding:.2rem; display:flex; border-radius:4px; flex-shrink:0; }
|
||||||
.step-del:hover { color:var(--danger); background:#FEF2F2; }
|
.step-del:hover { color:var(--danger); background:#FEF2F2; }
|
||||||
|
/* Add step area */
|
||||||
|
.add-step-area { border:1px dashed var(--border); border-radius:var(--radius); padding:.875rem; display:flex; flex-direction:column; gap:.6rem; }
|
||||||
.add-step-row { display:flex; gap:.5rem; }
|
.add-step-row { display:flex; gap:.5rem; }
|
||||||
.add-step-input { flex:1; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.45rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); }
|
.add-step-input { flex:1; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.45rem .75rem; font-size:.875rem; background:var(--surface); color:var(--text); }
|
||||||
.add-step-input:focus { outline:none; border-color:var(--teal); }
|
.add-step-input:focus { outline:none; border-color:var(--teal); }
|
||||||
.btn-add-step { background:var(--teal); color:#fff; border:none; border-radius:var(--radius-sm); padding:.45rem .85rem; font-size:.813rem; font-weight:600; cursor:pointer; white-space:nowrap; }
|
/* Type picker */
|
||||||
|
.type-picker { display:flex; gap:.35rem; flex-wrap:wrap; }
|
||||||
|
.type-btn { display:flex; align-items:center; gap:.3rem; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.3rem .65rem; font-size:.75rem; font-weight:600; cursor:pointer; background:var(--surface); color:var(--text-muted); transition:all .15s; }
|
||||||
|
.type-btn:hover { border-color:var(--teal); color:var(--teal); }
|
||||||
|
.type-btn.active { border-color:var(--selected-color, var(--teal)); background:color-mix(in srgb, var(--selected-color, var(--teal)) 10%, transparent); color:var(--selected-color, var(--teal)); }
|
||||||
|
.type-btn i { width:13px; height:13px; }
|
||||||
|
/* Config fields */
|
||||||
|
.cfg-fields { display:flex; flex-direction:column; gap:.45rem; padding:.5rem .6rem; background:var(--surface-2); border-radius:var(--radius-sm); }
|
||||||
|
.cfg-field-label { font-size:.75rem; font-weight:600; color:var(--text-muted); display:block; margin-bottom:.2rem; }
|
||||||
|
.cfg-input { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.4rem .6rem; font-size:.813rem; background:var(--surface); color:var(--text); box-sizing:border-box; }
|
||||||
|
.cfg-input:focus { outline:none; border-color:var(--teal); }
|
||||||
|
.cfg-select { width:100%; border:1px solid var(--border); border-radius:var(--radius-sm); padding:.4rem .6rem; font-size:.813rem; background:var(--surface); color:var(--text); }
|
||||||
|
.btn-add-step { background:var(--teal); color:#fff; border:none; border-radius:var(--radius-sm); padding:.45rem .85rem; font-size:.813rem; font-weight:600; cursor:pointer; white-space:nowrap; flex-shrink:0; }
|
||||||
.btn-add-step:hover { opacity:.88; }
|
.btn-add-step:hover { opacity:.88; }
|
||||||
|
/* Footer */
|
||||||
.dlg-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; flex-shrink:0; }
|
.dlg-footer { padding:.875rem 1.25rem; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:.5rem; flex-shrink:0; }
|
||||||
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
|
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
|
||||||
.btn-primary { background:var(--teal); color:#fff; border:none; border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
|
.btn-primary { background:var(--teal); color:#fff; border:none; border-radius:var(--radius); padding:.5rem 1rem; font-size:.875rem; font-weight:600; cursor:pointer; }
|
||||||
@@ -99,9 +128,21 @@ class ProfileForm extends HTMLElement {
|
|||||||
? '<div class="empty-steps" id="empty-steps">No steps yet — add steps below</div>'
|
? '<div class="empty-steps" id="empty-steps">No steps yet — add steps below</div>'
|
||||||
: (profile?.steps || []).map((s, i) => this.#stepRowHTML(s, i)).join('')}
|
: (profile?.steps || []).map((s, i) => this.#stepRowHTML(s, i)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="add-step-row">
|
|
||||||
<input class="add-step-input" id="new-step-title" placeholder="Step title…" maxlength="200">
|
<div class="add-step-area">
|
||||||
<button class="btn-add-step" id="add-step-btn">+ Add Step</button>
|
<div class="add-step-row">
|
||||||
|
<input class="add-step-input" id="new-step-title" placeholder="Step title…" maxlength="200">
|
||||||
|
<button class="btn-add-step" id="add-step-btn">+ Add Step</button>
|
||||||
|
</div>
|
||||||
|
<div class="type-picker" id="type-picker">
|
||||||
|
${STEP_TYPES.map(t => `
|
||||||
|
<button class="type-btn${t.value === 'work_step' ? ' active' : ''}" data-type="${t.value}"
|
||||||
|
style="--selected-color:${t.color}" title="${t.hint}">
|
||||||
|
<i data-lucide="${t.icon}" style="width:13px;height:13px"></i>
|
||||||
|
${t.label}
|
||||||
|
</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="cfg-fields" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="error-msg" id="form-error"></div>
|
<div class="error-msg" id="form-error"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,21 +151,22 @@ class ProfileForm extends HTMLElement {
|
|||||||
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Create Profile'}</button>
|
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Create Profile'}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [d] });
|
if (window.lucide) lucide.createIcons({ root: d });
|
||||||
|
|
||||||
// Local step state (not saved until the profile save)
|
const profileId = profile?.id || null;
|
||||||
const profileId = profile?.id || null;
|
let pendingSteps = (profile?.steps || []).map(s => ({ ...s }));
|
||||||
let pendingSteps = (profile?.steps || []).map(s => ({ ...s }));
|
let selectedType = 'work_step';
|
||||||
|
|
||||||
|
// ── Step list rendering ───────────────────────────────────────────────
|
||||||
const renderSteps = () => {
|
const renderSteps = () => {
|
||||||
const list = d.querySelector('#step-list');
|
const list = d.querySelector('#step-list');
|
||||||
const empty = pendingSteps.length === 0;
|
list.innerHTML = pendingSteps.length === 0
|
||||||
list.innerHTML = empty
|
|
||||||
? '<div class="empty-steps">No steps yet — add steps below</div>'
|
? '<div class="empty-steps">No steps yet — add steps below</div>'
|
||||||
: pendingSteps.map((s, i) => this.#stepRowHTML(s, i)).join('');
|
: pendingSteps.map((s, i) => this.#stepRowHTML(s, i)).join('');
|
||||||
|
if (window.lucide) lucide.createIcons({ root: list });
|
||||||
|
|
||||||
list.querySelectorAll('.step-title-input').forEach((inp, i) => {
|
list.querySelectorAll('.step-title-input').forEach((inp, i) => {
|
||||||
inp.addEventListener('input', () => { pendingSteps[i].title = inp.value; });
|
inp.addEventListener('input', () => { pendingSteps[i].title = inp.value; });
|
||||||
inp.addEventListener('change', () => { pendingSteps[i].title = inp.value; });
|
inp.addEventListener('change', () => { pendingSteps[i].title = inp.value; });
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.step-req-check').forEach((cb, i) => {
|
list.querySelectorAll('.step-req-check').forEach((cb, i) => {
|
||||||
@@ -136,29 +178,113 @@ class ProfileForm extends HTMLElement {
|
|||||||
};
|
};
|
||||||
renderSteps();
|
renderSteps();
|
||||||
|
|
||||||
|
// ── Type picker ───────────────────────────────────────────────────────
|
||||||
|
const cfgDiv = d.querySelector('#cfg-fields');
|
||||||
|
|
||||||
|
const renderCfgFields = (type) => {
|
||||||
|
if (type === 'work_step') {
|
||||||
|
cfgDiv.style.display = 'none';
|
||||||
|
cfgDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cfgDiv.style.display = 'block';
|
||||||
|
if (type === 'photo') {
|
||||||
|
cfgDiv.innerHTML = `
|
||||||
|
<div class="cfg-fields">
|
||||||
|
<div>
|
||||||
|
<label class="cfg-field-label">Phase</label>
|
||||||
|
<select class="cfg-select" id="cfg-phase">
|
||||||
|
<option value="before">Before</option>
|
||||||
|
<option value="during">During</option>
|
||||||
|
<option value="after">After</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="cfg-field-label">Caption prompt (shown to field tech)</label>
|
||||||
|
<input class="cfg-input" id="cfg-caption" type="text" placeholder="e.g. Photo of existing equipment before work begins" maxlength="200">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (type === 'inspection') {
|
||||||
|
cfgDiv.innerHTML = `
|
||||||
|
<div class="cfg-fields">
|
||||||
|
<div>
|
||||||
|
<label class="cfg-field-label">Criteria (what to inspect)</label>
|
||||||
|
<input class="cfg-input" id="cfg-criteria" type="text" placeholder="e.g. All cable slack coiled and secured per spec" maxlength="300">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (type === 'note') {
|
||||||
|
cfgDiv.innerHTML = `
|
||||||
|
<div class="cfg-fields">
|
||||||
|
<div>
|
||||||
|
<label class="cfg-field-label">Prompt label</label>
|
||||||
|
<input class="cfg-input" id="cfg-prompt" type="text" placeholder="e.g. Record any site hazards or access issues" maxlength="200">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCfgJSON = (type) => {
|
||||||
|
if (type === 'photo') {
|
||||||
|
const phase = cfgDiv.querySelector('#cfg-phase')?.value || 'during';
|
||||||
|
const caption = cfgDiv.querySelector('#cfg-caption')?.value.trim() || '';
|
||||||
|
return JSON.stringify({ phase, caption_prompt: caption });
|
||||||
|
}
|
||||||
|
if (type === 'inspection') {
|
||||||
|
const criteria = cfgDiv.querySelector('#cfg-criteria')?.value.trim() || '';
|
||||||
|
return criteria ? JSON.stringify({ criteria }) : '';
|
||||||
|
}
|
||||||
|
if (type === 'note') {
|
||||||
|
const prompt = cfgDiv.querySelector('#cfg-prompt')?.value.trim() || '';
|
||||||
|
return prompt ? JSON.stringify({ prompt }) : '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
d.querySelectorAll('.type-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
selectedType = btn.dataset.type;
|
||||||
|
d.querySelectorAll('.type-btn').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
|
renderCfgFields(selectedType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Add step ──────────────────────────────────────────────────────────
|
||||||
d.querySelector('#add-step-btn').addEventListener('click', () => {
|
d.querySelector('#add-step-btn').addEventListener('click', () => {
|
||||||
const inp = d.querySelector('#new-step-title');
|
const inp = d.querySelector('#new-step-title');
|
||||||
const title = inp.value.trim();
|
const title = inp.value.trim();
|
||||||
if (!title) { inp.focus(); return; }
|
if (!title) { inp.focus(); return; }
|
||||||
pendingSteps.push({ id: null, step_order: pendingSteps.length + 1, title, description: '', required: true });
|
pendingSteps.push({
|
||||||
|
id: null,
|
||||||
|
step_order: pendingSteps.length + 1,
|
||||||
|
title,
|
||||||
|
description: '',
|
||||||
|
required: true,
|
||||||
|
step_type: selectedType,
|
||||||
|
type_config: getCfgJSON(selectedType),
|
||||||
|
});
|
||||||
inp.value = '';
|
inp.value = '';
|
||||||
|
// Reset type picker to work_step after adding
|
||||||
|
selectedType = 'work_step';
|
||||||
|
d.querySelectorAll('.type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'work_step'));
|
||||||
|
renderCfgFields('work_step');
|
||||||
renderSteps();
|
renderSteps();
|
||||||
});
|
});
|
||||||
d.querySelector('#new-step-title').addEventListener('keydown', e => {
|
d.querySelector('#new-step-title').addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); d.querySelector('#add-step-btn').click(); }
|
if (e.key === 'Enter') { e.preventDefault(); d.querySelector('#add-step-btn').click(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
// ── Dialog controls ───────────────────────────────────────────────────
|
||||||
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|
||||||
d.querySelector('#dlg-save').addEventListener('click', async () => {
|
d.querySelector('#dlg-save').addEventListener('click', async () => {
|
||||||
const name = d.querySelector('#p-name').value.trim();
|
const name = d.querySelector('#p-name').value.trim();
|
||||||
const errEl = d.querySelector('#form-error');
|
const errEl = d.querySelector('#form-error');
|
||||||
if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#p-name').focus(); return; }
|
if (!name) { errEl.textContent = 'Name is required'; d.querySelector('#p-name').focus(); return; }
|
||||||
errEl.textContent = '';
|
errEl.textContent = '';
|
||||||
|
|
||||||
const saveBtn = d.querySelector('#dlg-save');
|
const saveBtn = d.querySelector('#dlg-save');
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
saveBtn.textContent = 'Saving…';
|
saveBtn.textContent = 'Saving…';
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -179,20 +305,26 @@ class ProfileForm extends HTMLElement {
|
|||||||
savedProfile = await api.post('/profiles', payload);
|
savedProfile = await api.post('/profiles', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync steps: delete removed, update existing, create new
|
|
||||||
const savedId = savedProfile.id;
|
const savedId = savedProfile.id;
|
||||||
const origIds = new Set((profile?.steps || []).map(s => s.id));
|
const origIds = new Set((profile?.steps || []).map(s => s.id));
|
||||||
|
|
||||||
// Delete removed
|
// Delete removed steps
|
||||||
for (const orig of (profile?.steps || [])) {
|
for (const orig of (profile?.steps || [])) {
|
||||||
if (!pendingSteps.find(s => s.id === orig.id)) {
|
if (!pendingSteps.find(s => s.id === orig.id)) {
|
||||||
await api.delete(`/profiles/${savedId}/steps/${orig.id}`);
|
await api.delete(`/profiles/${savedId}/steps/${orig.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update or create, re-assigning order
|
// Update existing / create new
|
||||||
for (let i = 0; i < pendingSteps.length; i++) {
|
for (let i = 0; i < pendingSteps.length; i++) {
|
||||||
const s = pendingSteps[i];
|
const s = pendingSteps[i];
|
||||||
const stepPayload = { title: s.title, description: s.description || '', required: s.required !== false, step_order: i + 1 };
|
const stepPayload = {
|
||||||
|
title: s.title,
|
||||||
|
description: s.description || '',
|
||||||
|
required: s.required !== false,
|
||||||
|
step_order: i + 1,
|
||||||
|
step_type: s.step_type || 'work_step',
|
||||||
|
type_config: s.type_config || '',
|
||||||
|
};
|
||||||
if (s.id && origIds.has(s.id)) {
|
if (s.id && origIds.has(s.id)) {
|
||||||
await api.put(`/profiles/${savedId}/steps/${s.id}`, stepPayload);
|
await api.put(`/profiles/${savedId}/steps/${s.id}`, stepPayload);
|
||||||
} else {
|
} else {
|
||||||
@@ -201,12 +333,14 @@ class ProfileForm extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
d.close();
|
d.close();
|
||||||
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: isEdit ? 'Profile updated' : 'Profile created', type: 'success' } }));
|
window.dispatchEvent(new CustomEvent('wo:toast', {
|
||||||
|
detail: { message: isEdit ? 'Profile updated' : 'Profile created', type: 'success' }
|
||||||
|
}));
|
||||||
this.#onSave?.();
|
this.#onSave?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errEl.textContent = err.message;
|
errEl.textContent = err.message;
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
saveBtn.textContent = isEdit ? 'Save Changes' : 'Create Profile';
|
saveBtn.textContent = isEdit ? 'Save Changes' : 'Create Profile';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,17 +349,28 @@ class ProfileForm extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#stepRowHTML(s, i) {
|
#stepRowHTML(s, i) {
|
||||||
|
const type = s.step_type || 'work_step';
|
||||||
|
const tdef = { work_step: { color: '#0A7EA4', label: 'Step' }, photo: { color: '#8B5CF6', label: 'Photo' }, inspection: { color: '#E07B39', label: 'Inspect' }, note: { color: '#64748B', label: 'Note' } };
|
||||||
|
const td = tdef[type] || tdef.work_step;
|
||||||
|
const cfg = this.#parseCfg(s.type_config);
|
||||||
|
const cfgSummary = type === 'photo' ? (cfg.phase ? cfg.phase + (cfg.caption_prompt ? ' · ' + cfg.caption_prompt : '') : '')
|
||||||
|
: type === 'inspection' ? (cfg.criteria || '')
|
||||||
|
: type === 'note' ? (cfg.prompt || '')
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="step-row">
|
<div class="step-row" data-type="${type}">
|
||||||
<span class="step-order">${i + 1}</span>
|
<span class="step-order">${i + 1}</span>
|
||||||
|
<span class="step-type-pill" style="background:color-mix(in srgb,${td.color} 15%,transparent);color:${td.color}">${td.label}</span>
|
||||||
<input class="step-title-input" value="${this.#esc(s.title)}" placeholder="Step title…">
|
<input class="step-title-input" value="${this.#esc(s.title)}" placeholder="Step title…">
|
||||||
|
${cfgSummary ? `<span class="step-cfg-summary" title="${this.#esc(cfgSummary)}">${this.#esc(cfgSummary)}</span>` : ''}
|
||||||
<label class="step-req">
|
<label class="step-req">
|
||||||
<input type="checkbox" class="step-req-check" ${s.required !== false ? 'checked' : ''}> Required
|
<input type="checkbox" class="step-req-check" ${s.required !== false ? 'checked' : ''}> Req
|
||||||
</label>
|
</label>
|
||||||
<button class="step-del" title="Remove step"><i data-lucide="x" style="width:12px;height:12px"></i></button>
|
<button class="step-del" title="Remove step"><i data-lucide="x" style="width:12px;height:12px"></i></button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#parseCfg(str) { try { return JSON.parse(str || '{}'); } catch { return {}; } }
|
||||||
#esc(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
#esc(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
import './profile-form.mjs';
|
import './profile-form.mjs';
|
||||||
|
|
||||||
const PRIORITY_COLORS = { low: '#64748B', normal: '#0A7EA4', high: '#E07B39', urgent: '#C0392B' };
|
const PRIORITY_COLORS = { low: '#64748B', normal: '#0A7EA4', high: '#E07B39', urgent: '#C0392B' };
|
||||||
@@ -68,7 +68,7 @@ class ProfileList extends HTMLElement {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Work Order Profiles</h1>
|
<h1>Work Order Profiles</h1>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input class="search-input" id="search" placeholder="Search profiles…" value="${this.#esc(this.#search)}">
|
<input class="search-input" id="search" placeholder="Search profiles…" value="${this.#esc(this.#search)}">
|
||||||
<button class="btn-primary" id="new-btn">
|
<button class="btn-primary" id="new-btn">
|
||||||
<i data-lucide="plus" style="width:14px;height:14px"></i> New Profile
|
<i data-lucide="plus" style="width:14px;height:14px"></i> New Profile
|
||||||
</button>
|
</button>
|
||||||
@@ -92,7 +92,7 @@ class ProfileList extends HTMLElement {
|
|||||||
<span class="pri-dot" style="background:${PRIORITY_COLORS[p.default_priority] || PRIORITY_COLORS.normal}"></span>
|
<span class="pri-dot" style="background:${PRIORITY_COLORS[p.default_priority] || PRIORITY_COLORS.normal}"></span>
|
||||||
<span>${p.default_priority}</span>
|
<span>${p.default_priority}</span>
|
||||||
<span class="step-count"><i data-lucide="list-checks" style="width:11px;height:11px;vertical-align:middle"></i> ${p.step_count} step${p.step_count !== 1 ? 's' : ''}</span>
|
<span class="step-count"><i data-lucide="list-checks" style="width:11px;height:11px;vertical-align:middle"></i> ${p.step_count} step${p.step_count !== 1 ? 's' : ''}</span>
|
||||||
${p.default_duration_hours ? `<span>⏱ ${p.default_duration_hours}h</span>` : ''}
|
${p.default_duration_hours ? `<span>â± ${p.default_duration_hours}h</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge ${p.active ? 'status-active' : 'status-inactive'}">${p.active ? 'Active' : 'Inactive'}</span>
|
<span class="status-badge ${p.active ? 'status-active' : 'status-inactive'}">${p.active ? 'Active' : 'Inactive'}</span>
|
||||||
@@ -109,7 +109,7 @@ class ProfileList extends HTMLElement {
|
|||||||
|
|
||||||
<profile-form id="profile-form"></profile-form>`;
|
<profile-form id="profile-form"></profile-form>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
|
||||||
const searchEl = s.querySelector('#search');
|
const searchEl = s.querySelector('#search');
|
||||||
let debounce;
|
let debounce;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class VehicleForm extends HTMLElement {
|
|||||||
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Vehicle'}</button>
|
<button class="btn-primary" id="dlg-save">${isEdit ? 'Save Changes' : 'Add Vehicle'}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [d] });
|
if (window.lucide) lucide.createIcons({ root: d });
|
||||||
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-close').addEventListener('click', () => d.close());
|
||||||
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
d.querySelector('#dlg-cancel').addEventListener('click', () => d.close());
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
import './vehicle-form.mjs';
|
import './vehicle-form.mjs';
|
||||||
|
|
||||||
class VehicleList extends HTMLElement {
|
class VehicleList extends HTMLElement {
|
||||||
@@ -63,7 +63,7 @@ class VehicleList extends HTMLElement {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Vehicles</h1>
|
<h1>Vehicles</h1>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input class="search-input" id="search" placeholder="Search vehicles…" value="${this.#esc(this.#search)}">
|
<input class="search-input" id="search" placeholder="Search vehicles…" value="${this.#esc(this.#search)}">
|
||||||
<button class="btn-primary" id="new-btn">
|
<button class="btn-primary" id="new-btn">
|
||||||
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Vehicle
|
<i data-lucide="plus" style="width:14px;height:14px"></i> Add Vehicle
|
||||||
</button>
|
</button>
|
||||||
@@ -101,7 +101,7 @@ class VehicleList extends HTMLElement {
|
|||||||
|
|
||||||
<vehicle-form id="vehicle-form"></vehicle-form>`;
|
<vehicle-form id="vehicle-form"></vehicle-form>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
|
||||||
const searchEl = s.querySelector('#search');
|
const searchEl = s.querySelector('#search');
|
||||||
let debounce;
|
let debounce;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class UiDialog extends HTMLElement {
|
|||||||
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.close());
|
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.close());
|
||||||
dialog.addEventListener('click', e => { if (e.target === dialog) this.close(); });
|
dialog.addEventListener('click', e => { if (e.target === dialog) this.close(); });
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') this.close(); });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') this.close(); });
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [this.shadowRoot] });
|
if (window.lucide) lucide.createIcons({ root: this.shadowRoot });
|
||||||
}
|
}
|
||||||
|
|
||||||
open() { this.shadowRoot?.querySelector('dialog')?.showModal(); }
|
open() { this.shadowRoot?.querySelector('dialog')?.showModal(); }
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class UiEmpty extends HTMLElement {
|
|||||||
${body ? `<p>${body}</p>` : ''}
|
${body ? `<p>${body}</p>` : ''}
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>`;
|
</div>`;
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [this.shadowRoot] });
|
if (window.lucide) lucide.createIcons({ root: this.shadowRoot });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
customElements.define('ui-empty', UiEmpty);
|
customElements.define('ui-empty', UiEmpty);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class UiToastContainer extends HTMLElement {
|
|||||||
});
|
});
|
||||||
toast.innerHTML = `<i data-lucide="${icons[type] || 'info'}" style="width:16px;height:16px;flex-shrink:0"></i><span>${message}</span>`;
|
toast.innerHTML = `<i data-lucide="${icons[type] || 'info'}" style="width:16px;height:16px;flex-shrink:0"></i><span>${message}</span>`;
|
||||||
this.appendChild(toast);
|
this.appendChild(toast);
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [toast] });
|
if (window.lucide) lucide.createIcons({ root: toast });
|
||||||
|
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.textContent = `@keyframes toastIn{from{opacity:0;transform:translateX(1rem)}to{opacity:1;transform:none}}`;
|
style.textContent = `@keyframes toastIn{from{opacity:0;transform:translateX(1rem)}to{opacity:1;transform:none}}`;
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
|
|
||||||
|
// Step type constants
|
||||||
|
const STEP_TYPES = {
|
||||||
|
work_step: { label: 'Step', icon: 'check-square', color: 'var(--teal)' },
|
||||||
|
photo: { label: 'Photo', icon: 'camera', color: '#8B5CF6' },
|
||||||
|
inspection: { label: 'Inspect', icon: 'clipboard-check', color: '#E07B39' },
|
||||||
|
note: { label: 'Note', icon: 'file-text', color: '#64748B' },
|
||||||
|
};
|
||||||
|
|
||||||
class WoChecklist extends HTMLElement {
|
class WoChecklist extends HTMLElement {
|
||||||
#woId = null;
|
#woId = null;
|
||||||
#steps = [];
|
#steps = [];
|
||||||
@@ -26,9 +34,9 @@ class WoChecklist extends HTMLElement {
|
|||||||
this.#render();
|
this.#render();
|
||||||
}
|
}
|
||||||
|
|
||||||
async #complete(stepId) {
|
async #complete(stepId, notes = '') {
|
||||||
try {
|
try {
|
||||||
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, {});
|
await api.post(`/work-orders/${this.#woId}/steps/${stepId}/complete`, { notes });
|
||||||
await this.#load();
|
await this.#load();
|
||||||
this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
|
this.dispatchEvent(new CustomEvent('steps:updated', { bubbles: true, composed: true }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -48,7 +56,7 @@ class WoChecklist extends HTMLElement {
|
|||||||
|
|
||||||
async #addStep(title) {
|
async #addStep(title) {
|
||||||
try {
|
try {
|
||||||
await api.post(`/work-orders/${this.#woId}/steps`, { title, description: '', required: true });
|
await api.post(`/work-orders/${this.#woId}/steps`, { title, description: '', required: true, step_type: 'work_step' });
|
||||||
await this.#load();
|
await this.#load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
|
||||||
@@ -64,6 +72,95 @@ class WoChecklist extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #uploadPhoto(step, file) {
|
||||||
|
const cfg = this.#cfg(step);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('phase', cfg.phase || 'during');
|
||||||
|
form.append('caption', cfg.caption_prompt || step.title);
|
||||||
|
form.append('step_id', String(step.id));
|
||||||
|
try {
|
||||||
|
await api.upload(`/work-orders/${this.#woId}/attachments`, form);
|
||||||
|
await this.#complete(step.id, 'Photo captured');
|
||||||
|
} catch (err) {
|
||||||
|
window.dispatchEvent(new CustomEvent('wo:toast', { detail: { message: err.message, type: 'error' } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#cfg(step) {
|
||||||
|
try { return JSON.parse(step.type_config || '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-type action HTML ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#workStepHTML(st) {
|
||||||
|
return `
|
||||||
|
<div class="step-check-wrap">
|
||||||
|
<input type="checkbox" class="step-check" data-id="${st.id}" ${st.completed ? 'checked' : ''}>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#photoHTML(st) {
|
||||||
|
const cfg = this.#cfg(st);
|
||||||
|
const prompt = cfg.caption_prompt || 'Capture photo';
|
||||||
|
const phase = cfg.phase ? `<span class="phase-badge phase-${cfg.phase}">${cfg.phase}</span>` : '';
|
||||||
|
if (st.completed) {
|
||||||
|
return `<div class="type-icon photo-done"><i data-lucide="image" style="width:18px;height:18px"></i></div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="photo-action">
|
||||||
|
<button class="photo-btn" data-photo-id="${st.id}" title="${this.#esc(prompt)}">
|
||||||
|
<i data-lucide="camera" style="width:16px;height:16px"></i>
|
||||||
|
</button>
|
||||||
|
${phase}
|
||||||
|
<input type="file" accept="image/*" capture="environment" class="photo-file-input" data-photo-id="${st.id}" style="display:none">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inspectionHTML(st) {
|
||||||
|
if (st.completed) {
|
||||||
|
const result = st.notes || '';
|
||||||
|
const cls = result === 'PASS' ? 'insp-pass' : result === 'FAIL' ? 'insp-fail' : 'insp-na';
|
||||||
|
return `<div class="insp-result ${cls}">${result || 'Done'}</div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="insp-btns">
|
||||||
|
<button class="insp-btn pass" data-insp="${st.id}" data-result="PASS">Pass</button>
|
||||||
|
<button class="insp-btn fail" data-insp="${st.id}" data-result="FAIL">Fail</button>
|
||||||
|
<button class="insp-btn na" data-insp="${st.id}" data-result="N/A">N/A</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#noteHTML(st) {
|
||||||
|
const cfg = this.#cfg(st);
|
||||||
|
const prompt = cfg.prompt || 'Add note…';
|
||||||
|
if (st.completed) {
|
||||||
|
return `<div class="type-icon note-done"><i data-lucide="check" style="width:16px;height:16px"></i></div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="note-action">
|
||||||
|
<textarea class="note-input" data-note-id="${st.id}" placeholder="${this.#esc(prompt)}" rows="2">${this.#esc(st.notes || '')}</textarea>
|
||||||
|
<button class="note-save-btn" data-note-id="${st.id}">Save</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#typeActionHTML(st) {
|
||||||
|
switch (st.step_type || 'work_step') {
|
||||||
|
case 'photo': return this.#photoHTML(st);
|
||||||
|
case 'inspection': return this.#inspectionHTML(st);
|
||||||
|
case 'note': return this.#noteHTML(st);
|
||||||
|
default: return this.#workStepHTML(st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#typeBadgeHTML(st) {
|
||||||
|
const t = STEP_TYPES[st.step_type] || STEP_TYPES.work_step;
|
||||||
|
if (st.step_type === 'work_step' || !st.step_type) return '';
|
||||||
|
return `<span class="type-badge" style="--badge-color:${t.color}">${t.label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#render() {
|
#render() {
|
||||||
const s = this.shadowRoot;
|
const s = this.shadowRoot;
|
||||||
if (this.#loading) {
|
if (this.#loading) {
|
||||||
@@ -89,21 +186,55 @@ class WoChecklist extends HTMLElement {
|
|||||||
.step-row:hover { background: var(--surface-2); }
|
.step-row:hover { background: var(--surface-2); }
|
||||||
.step-row.done { background: var(--surface-2); border-color: var(--border-lt); }
|
.step-row.done { background: var(--surface-2); border-color: var(--border-lt); }
|
||||||
.step-row.done .step-title { text-decoration: line-through; color: var(--text-muted); }
|
.step-row.done .step-title { text-decoration: line-through; color: var(--text-muted); }
|
||||||
.step-check-wrap { display: flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; margin-top: .1rem; }
|
.step-row.type-photo { border-left: 3px solid #8B5CF6; }
|
||||||
|
.step-row.type-inspection { border-left: 3px solid #E07B39; }
|
||||||
|
.step-row.type-note { border-left: 3px solid #64748B; }
|
||||||
|
/* Work step checkbox */
|
||||||
|
.step-check-wrap { display: flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; margin-top: .1rem; flex-shrink: 0; }
|
||||||
.step-check { width: 20px; height: 20px; accent-color: var(--teal); cursor: pointer; }
|
.step-check { width: 20px; height: 20px; accent-color: var(--teal); cursor: pointer; }
|
||||||
.step-check:disabled { cursor: not-allowed; opacity: .6; }
|
/* Photo step */
|
||||||
|
.photo-action { display: flex; align-items: center; gap: .4rem; flex-shrink: 0; }
|
||||||
|
.photo-btn { background: #8B5CF6; color: #fff; border: none; border-radius: var(--radius-sm); padding: .35rem .6rem; cursor: pointer; display: flex; align-items: center; gap: .3rem; font-size: .75rem; font-weight: 600; transition: opacity .15s; }
|
||||||
|
.photo-btn:hover { opacity: .85; }
|
||||||
|
.photo-done { color: #8B5CF6; display: flex; align-items: center; min-width: 24px; flex-shrink: 0; }
|
||||||
|
.phase-badge { font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .4rem; border-radius: 99px; flex-shrink: 0; }
|
||||||
|
.phase-before { background: #FEF3C7; color: #92400E; }
|
||||||
|
.phase-during { background: #DBEAFE; color: #1E40AF; }
|
||||||
|
.phase-after { background: #DCFCE7; color: #15803D; }
|
||||||
|
/* Inspection step */
|
||||||
|
.insp-btns { display: flex; gap: .3rem; flex-shrink: 0; }
|
||||||
|
.insp-btn { border: none; border-radius: var(--radius-sm); padding: .3rem .65rem; font-size: .75rem; font-weight: 700; cursor: pointer; transition: opacity .15s; }
|
||||||
|
.insp-btn:hover { opacity: .8; }
|
||||||
|
.insp-btn.pass { background: #DCFCE7; color: #15803D; }
|
||||||
|
.insp-btn.fail { background: #FEE2E2; color: #991B1B; }
|
||||||
|
.insp-btn.na { background: var(--surface-2); color: var(--text-muted); }
|
||||||
|
.insp-result { font-size: .75rem; font-weight: 700; padding: .3rem .65rem; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||||
|
.insp-pass { background: #DCFCE7; color: #15803D; }
|
||||||
|
.insp-fail { background: #FEE2E2; color: #991B1B; }
|
||||||
|
.insp-na { background: var(--surface-2); color: var(--text-muted); }
|
||||||
|
/* Note step */
|
||||||
|
.note-action { display: flex; flex-direction: column; gap: .35rem; flex: 1; min-width: 0; }
|
||||||
|
.note-input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .45rem .6rem; font-size: .813rem; background: var(--surface); color: var(--text); resize: vertical; font-family: inherit; width: 100%; box-sizing: border-box; }
|
||||||
|
.note-input:focus { outline: none; border-color: var(--teal); }
|
||||||
|
.note-save-btn { align-self: flex-end; background: var(--teal); color: #fff; border: none; border-radius: var(--radius-sm); padding: .3rem .85rem; font-size: .813rem; font-weight: 600; cursor: pointer; transition: opacity .15s; }
|
||||||
|
.note-save-btn:hover { opacity: .88; }
|
||||||
|
.note-done { color: var(--teal); display: flex; align-items: center; min-width: 24px; flex-shrink: 0; }
|
||||||
|
/* Step body */
|
||||||
.step-body { flex: 1; min-width: 0; }
|
.step-body { flex: 1; min-width: 0; }
|
||||||
.step-header { display: flex; align-items: baseline; gap: .4rem; }
|
.step-header { display: flex; align-items: baseline; gap: .4rem; flex-wrap: wrap; }
|
||||||
.step-num { font-size: .75rem; color: var(--text-muted); font-weight: 700; min-width: 1.5rem; }
|
.step-num { font-size: .75rem; color: var(--text-muted); font-weight: 700; min-width: 1.5rem; }
|
||||||
.step-title { font-weight: 500; font-size: .875rem; color: var(--text); line-height: 1.4; }
|
.step-title { font-weight: 500; font-size: .875rem; color: var(--text); line-height: 1.4; }
|
||||||
.step-desc { font-size: .813rem; color: var(--text-muted); margin-top: .2rem; line-height: 1.5; }
|
.step-desc { font-size: .813rem; color: var(--text-muted); margin-top: .2rem; line-height: 1.5; }
|
||||||
.step-meta { font-size: .75rem; color: var(--text-muted); margin-top: .3rem; display: flex; align-items: center; gap: .3rem; }
|
.step-meta { font-size: .75rem; color: var(--text-muted); margin-top: .3rem; display: flex; align-items: center; gap: .3rem; }
|
||||||
.step-note { font-style: italic; margin-top: .2rem; font-size: .75rem; color: var(--text-muted); }
|
.step-note { font-style: italic; margin-top: .2rem; font-size: .75rem; color: var(--text-muted); }
|
||||||
|
.type-badge { font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .4rem; border-radius: 99px; background: color-mix(in srgb, var(--badge-color) 15%, transparent); color: var(--badge-color); flex-shrink: 0; }
|
||||||
|
/* Step actions */
|
||||||
.step-actions { display: flex; gap: .25rem; align-items: center; flex-shrink: 0; }
|
.step-actions { display: flex; gap: .25rem; align-items: center; flex-shrink: 0; }
|
||||||
.icon-btn { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .3rem; border-radius: var(--radius-sm); display: flex; align-items: center; transition: color .15s, background .15s; opacity: 0; }
|
.icon-btn { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: .3rem; border-radius: var(--radius-sm); display: flex; align-items: center; transition: color .15s, background .15s; opacity: 0; }
|
||||||
.step-row:hover .icon-btn { opacity: 1; }
|
.step-row:hover .icon-btn { opacity: 1; }
|
||||||
.icon-btn:hover { color: var(--danger); background: #FEF2F2; }
|
.icon-btn:hover { color: var(--danger); background: #FEF2F2; }
|
||||||
.icon-btn.undo-btn:hover { color: var(--teal); background: var(--surface-2); }
|
.icon-btn.undo-btn:hover { color: var(--teal); background: var(--surface-2); }
|
||||||
|
/* Add section */
|
||||||
.empty-steps { text-align: center; padding: 2rem; color: var(--text-muted); font-size: .875rem; }
|
.empty-steps { text-align: center; padding: 2rem; color: var(--text-muted); font-size: .875rem; }
|
||||||
.add-section { border-top: 1px solid var(--border); padding-top: 1.25rem; }
|
.add-section { border-top: 1px solid var(--border); padding-top: 1.25rem; }
|
||||||
.add-section-label { font-size: .75rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: .75rem; }
|
.add-section-label { font-size: .75rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: .75rem; }
|
||||||
@@ -112,9 +243,7 @@ class WoChecklist extends HTMLElement {
|
|||||||
.add-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
|
.add-input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.12); }
|
||||||
.add-btn { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .55rem 1.1rem; font-weight: 600; font-size: .875rem; cursor: pointer; white-space: nowrap; transition: opacity .15s; }
|
.add-btn { background: var(--teal); color: #fff; border: none; border-radius: var(--radius); padding: .55rem 1.1rem; font-weight: 600; font-size: .875rem; cursor: pointer; white-space: nowrap; transition: opacity .15s; }
|
||||||
.add-btn:hover { opacity: .88; }
|
.add-btn:hover { opacity: .88; }
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) { .icon-btn { opacity: 1; } }
|
||||||
.icon-btn { opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
${allDone ? `
|
${allDone ? `
|
||||||
@@ -134,36 +263,46 @@ class WoChecklist extends HTMLElement {
|
|||||||
<div class="steps-list">
|
<div class="steps-list">
|
||||||
${total === 0
|
${total === 0
|
||||||
? '<div class="empty-steps">No steps yet — add your first step below</div>'
|
? '<div class="empty-steps">No steps yet — add your first step below</div>'
|
||||||
: this.#steps.map(st => `
|
: this.#steps.map(st => {
|
||||||
<div class="step-row${st.completed ? ' done' : ''}">
|
const type = st.step_type || 'work_step';
|
||||||
<div class="step-check-wrap">
|
const cfg = this.#cfg(st);
|
||||||
<input type="checkbox" class="step-check" data-id="${st.id}"
|
// For note steps, the action takes flex:1 so body goes before it
|
||||||
${st.completed ? 'checked' : ''}>
|
const noteType = type === 'note' && !st.completed;
|
||||||
</div>
|
return `
|
||||||
<div class="step-body">
|
<div class="step-row${st.completed ? ' done' : ''} type-${type}">
|
||||||
<div class="step-header">
|
${noteType ? '' : this.#typeActionHTML(st)}
|
||||||
<span class="step-num">${st.step_order}.</span>
|
<div class="step-body" style="${noteType ? 'flex:0 0 auto;max-width:45%' : ''}">
|
||||||
<span class="step-title">${this.#esc(st.title)}</span>
|
<div class="step-header">
|
||||||
</div>
|
<span class="step-num">${st.step_order}.</span>
|
||||||
${st.description ? `<div class="step-desc">${this.#esc(st.description)}</div>` : ''}
|
<span class="step-title">${this.#esc(st.title)}</span>
|
||||||
${st.completed && st.completed_by ? `
|
${this.#typeBadgeHTML(st)}
|
||||||
<div class="step-meta">
|
</div>
|
||||||
<i data-lucide="user-check" style="width:12px;height:12px"></i>
|
${st.description ? `<div class="step-desc">${this.#esc(st.description)}</div>` : ''}
|
||||||
${this.#esc(st.completed_by)}
|
${type === 'inspection' && !st.completed && cfg.criteria ? `<div class="step-desc">${this.#esc(cfg.criteria)}</div>` : ''}
|
||||||
${st.completed_at ? '· ' + new Date(st.completed_at).toLocaleString() : ''}
|
${st.completed && st.completed_by ? `
|
||||||
</div>` : ''}
|
<div class="step-meta">
|
||||||
${st.notes ? `<div class="step-note">"${this.#esc(st.notes)}"</div>` : ''}
|
<i data-lucide="user-check" style="width:12px;height:12px"></i>
|
||||||
</div>
|
${this.#esc(st.completed_by)}
|
||||||
<div class="step-actions">
|
${st.completed_at ? '· ' + new Date(st.completed_at).toLocaleString() : ''}
|
||||||
${st.completed
|
</div>` : ''}
|
||||||
? `<button class="icon-btn undo-btn" data-undo="${st.id}" title="Mark incomplete">
|
${st.notes && type !== 'note' ? `<div class="step-note">"${this.#esc(st.notes)}"</div>` : ''}
|
||||||
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
</div>
|
||||||
</button>`
|
${noteType ? this.#typeActionHTML(st) : ''}
|
||||||
: `<button class="icon-btn" data-del="${st.id}" title="Remove step">
|
<div class="step-actions">
|
||||||
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
|
${st.completed
|
||||||
</button>`}
|
? `<button class="icon-btn undo-btn" data-undo="${st.id}" title="Mark incomplete">
|
||||||
</div>
|
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
||||||
</div>`).join('')}
|
</button>`
|
||||||
|
: type === 'work_step' || !type
|
||||||
|
? `<button class="icon-btn" data-del="${st.id}" title="Remove step">
|
||||||
|
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
|
||||||
|
</button>`
|
||||||
|
: `<button class="icon-btn" data-del="${st.id}" title="Remove step">
|
||||||
|
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
|
||||||
|
</button>`}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="add-section">
|
<div class="add-section">
|
||||||
@@ -176,8 +315,9 @@ class WoChecklist extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
|
|
||||||
|
// Work step checkboxes
|
||||||
s.querySelectorAll('.step-check').forEach(cb => {
|
s.querySelectorAll('.step-check').forEach(cb => {
|
||||||
cb.addEventListener('change', () => {
|
cb.addEventListener('change', () => {
|
||||||
if (cb.checked) this.#complete(+cb.dataset.id);
|
if (cb.checked) this.#complete(+cb.dataset.id);
|
||||||
@@ -185,12 +325,42 @@ class WoChecklist extends HTMLElement {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Photo capture buttons
|
||||||
|
s.querySelectorAll('.photo-btn').forEach(btn => {
|
||||||
|
const stepId = +btn.dataset.photoId;
|
||||||
|
const fileInput = s.querySelector(`.photo-file-input[data-photo-id="${stepId}"]`);
|
||||||
|
btn.addEventListener('click', () => fileInput?.click());
|
||||||
|
fileInput?.addEventListener('change', e => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const step = this.#steps.find(st => st.id === stepId);
|
||||||
|
if (step) this.#uploadPhoto(step, file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inspection buttons
|
||||||
|
s.querySelectorAll('.insp-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.#complete(+btn.dataset.insp, btn.dataset.result));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note save buttons
|
||||||
|
s.querySelectorAll('.note-save-btn').forEach(btn => {
|
||||||
|
const stepId = +btn.dataset.noteId;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const textarea = s.querySelector(`.note-input[data-note-id="${stepId}"]`);
|
||||||
|
const text = textarea?.value.trim() || '';
|
||||||
|
if (!text) { textarea?.focus(); return; }
|
||||||
|
this.#complete(stepId, text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete / undo
|
||||||
s.querySelectorAll('[data-del]').forEach(btn =>
|
s.querySelectorAll('[data-del]').forEach(btn =>
|
||||||
btn.addEventListener('click', () => this.#deleteStep(+btn.dataset.del)));
|
btn.addEventListener('click', () => this.#deleteStep(+btn.dataset.del)));
|
||||||
|
|
||||||
s.querySelectorAll('[data-undo]').forEach(btn =>
|
s.querySelectorAll('[data-undo]').forEach(btn =>
|
||||||
btn.addEventListener('click', () => this.#uncomplete(+btn.dataset.undo)));
|
btn.addEventListener('click', () => this.#uncomplete(+btn.dataset.undo)));
|
||||||
|
|
||||||
|
// Add step
|
||||||
const titleInput = s.querySelector('#new-step-title');
|
const titleInput = s.querySelector('#new-step-title');
|
||||||
const addBtn = s.querySelector('#add-step-btn');
|
const addBtn = s.querySelector('#add-step-btn');
|
||||||
addBtn.addEventListener('click', () => {
|
addBtn.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class WoDetail extends HTMLElement {
|
|||||||
const s = this.shadowRoot;
|
const s = this.shadowRoot;
|
||||||
s.innerHTML = `<style>${this.#css()}</style>${this.#html()}`;
|
s.innerHTML = `<style>${this.#css()}</style>${this.#html()}`;
|
||||||
this.#bind();
|
this.#bind();
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
}
|
}
|
||||||
|
|
||||||
#css() { return `
|
#css() { return `
|
||||||
@@ -297,7 +297,7 @@ class WoDetail extends HTMLElement {
|
|||||||
this.#tab = tab.dataset.tab;
|
this.#tab = tab.dataset.tab;
|
||||||
s.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === this.#tab));
|
s.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === this.#tab));
|
||||||
s.querySelector('#tab-content').innerHTML = this.#tabContent(this.#wo);
|
s.querySelector('#tab-content').innerHTML = this.#tabContent(this.#wo);
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s.querySelector('#tab-content')] });
|
if (window.lucide) lucide.createIcons({ root: s.querySelector('#tab-content') });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Status dropdown
|
// Status dropdown
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
import { toLocalDatetime } from '../../lib/format.mjs';
|
import { toLocalDatetime } from '../../lib/format.mjs';
|
||||||
|
|
||||||
const STATUSES = ['draft','assigned','scheduled','in_progress','pending_review','closed'];
|
const STATUSES = ['draft','assigned','scheduled','in_progress','pending_review','closed'];
|
||||||
@@ -30,7 +30,7 @@ class WoForm extends HTMLElement {
|
|||||||
const s = this.shadowRoot;
|
const s = this.shadowRoot;
|
||||||
s.innerHTML = `<style>${this.#css()}</style>${this.#html(wo, isNew)}`;
|
s.innerHTML = `<style>${this.#css()}</style>${this.#html(wo, isNew)}`;
|
||||||
this.#bind();
|
this.#bind();
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
}
|
}
|
||||||
|
|
||||||
#css() { return `
|
#css() { return `
|
||||||
@@ -97,14 +97,14 @@ class WoForm extends HTMLElement {
|
|||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<label>Title *<input name="title" required value="${v('title')}" placeholder="Brief description of the work"></label>
|
<label>Title *<input name="title" required value="${v('title')}" placeholder="Brief description of the work"></label>
|
||||||
<label>Description<textarea name="description" rows="3" placeholder="What needs to be done…">${v('description')}</textarea></label>
|
<label>Description<textarea name="description" rows="3" placeholder="What needs to be done…">${v('description')}</textarea></label>
|
||||||
<label>Instructions<textarea name="instructions" rows="5" placeholder="Step-by-step instructions for the field crew…">${v('instructions')}</textarea></label>
|
<label>Instructions<textarea name="instructions" rows="5" placeholder="Step-by-step instructions for the field crew…">${v('instructions')}</textarea></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">Location</div>
|
<div class="card-title">Location</div>
|
||||||
<label>Site Name<input name="site_name" value="${v('site_name')}" placeholder="e.g. Downtown Office"></label>
|
<label>Site Name<input name="site_name" value="${v('site_name')}" placeholder="e.g. Downtown Office"></label>
|
||||||
<label>Address<input name="address" value="${v('address')}" placeholder="Street address"></label>
|
<label>Address<input name="address" value="${v('address')}" placeholder="Street address"></label>
|
||||||
<label>Access Notes<textarea name="access_notes" rows="2" placeholder="Gate codes, road conditions, parking info…">${v('access_notes')}</textarea></label>
|
<label>Access Notes<textarea name="access_notes" rows="2" placeholder="Gate codes, road conditions, parking info…">${v('access_notes')}</textarea></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,8 +164,8 @@ class WoForm extends HTMLElement {
|
|||||||
<button class="lp-close" id="lp-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
|
<button class="lp-close" id="lp-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lp-body">
|
<div class="lp-body">
|
||||||
<input class="lp-search" id="lp-search" placeholder="Search profiles…" autocomplete="off">
|
<input class="lp-search" id="lp-search" placeholder="Search profiles…" autocomplete="off">
|
||||||
<div class="lp-list" id="lp-list"><div class="lp-empty">Loading…</div></div>
|
<div class="lp-list" id="lp-list"><div class="lp-empty">Loading…</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lp-footer">
|
<div class="lp-footer">
|
||||||
<button class="btn btn-ghost" id="lp-cancel">Cancel</button>
|
<button class="btn btn-ghost" id="lp-cancel">Cancel</button>
|
||||||
@@ -262,7 +262,7 @@ class WoForm extends HTMLElement {
|
|||||||
listEl.innerHTML = items.map(p => `
|
listEl.innerHTML = items.map(p => `
|
||||||
<div class="lp-item${selectedProfile?.id === p.id ? ' selected' : ''}" data-id="${p.id}">
|
<div class="lp-item${selectedProfile?.id === p.id ? ' selected' : ''}" data-id="${p.id}">
|
||||||
<div class="lp-item-name">${this.#esc(p.name)}</div>
|
<div class="lp-item-name">${this.#esc(p.name)}</div>
|
||||||
<div class="lp-item-meta">${p.category ? `${p.category} · ` : ''}${p.step_count} step${p.step_count !== 1 ? 's' : ''} · ${p.default_priority} priority${p.default_duration_hours ? ` · ${p.default_duration_hours}h` : ''}</div>
|
<div class="lp-item-meta">${p.category ? `${p.category} · ` : ''}${p.step_count} step${p.step_count !== 1 ? 's' : ''} · ${p.default_priority} priority${p.default_duration_hours ? ` · ${p.default_duration_hours}h` : ''}</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
listEl.querySelectorAll('.lp-item').forEach(item => {
|
listEl.querySelectorAll('.lp-item').forEach(item => {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
@@ -277,7 +277,7 @@ class WoForm extends HTMLElement {
|
|||||||
selectedProfile = null;
|
selectedProfile = null;
|
||||||
confirmBtn.disabled = true;
|
confirmBtn.disabled = true;
|
||||||
searchEl.value = '';
|
searchEl.value = '';
|
||||||
listEl.innerHTML = '<div class="lp-empty">Loading…</div>';
|
listEl.innerHTML = '<div class="lp-empty">Loading…</div>';
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
try {
|
try {
|
||||||
allProfiles = await api.get('/profiles') || [];
|
allProfiles = await api.get('/profiles') || [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
import { formatDateTime } from '../../lib/format.mjs';
|
import { formatDateTime } from '../../lib/format.mjs';
|
||||||
|
|
||||||
const STATUSES = ['draft','assigned','scheduled','in_progress','pending_review','closed'];
|
const STATUSES = ['draft','assigned','scheduled','in_progress','pending_review','closed'];
|
||||||
@@ -38,7 +38,7 @@ class WoList extends HTMLElement {
|
|||||||
const s = this.shadowRoot;
|
const s = this.shadowRoot;
|
||||||
s.innerHTML = `<style>${this.#css()}</style>${this.#html()}`;
|
s.innerHTML = `<style>${this.#css()}</style>${this.#html()}`;
|
||||||
this.#bind();
|
this.#bind();
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
}
|
}
|
||||||
|
|
||||||
#css() { return `
|
#css() { return `
|
||||||
@@ -139,7 +139,7 @@ class WoList extends HTMLElement {
|
|||||||
return `<div class="filter-bar">
|
return `<div class="filter-bar">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<i data-lucide="search"></i>
|
<i data-lucide="search"></i>
|
||||||
<input class="search-input" id="search" type="search" placeholder="Search work orders…" value="${this.#filters.search}">
|
<input class="search-input" id="search" type="search" placeholder="Search work orders…" value="${this.#filters.search}">
|
||||||
</div>
|
</div>
|
||||||
<select class="filter" id="filter-status">
|
<select class="filter" id="filter-status">
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
@@ -167,7 +167,7 @@ class WoList extends HTMLElement {
|
|||||||
<tr class="wo-row" data-id="${wo.id}">
|
<tr class="wo-row" data-id="${wo.id}">
|
||||||
<td class="wo-num">${wo.wo_number}</td>
|
<td class="wo-num">${wo.wo_number}</td>
|
||||||
<td class="wo-title">${this.#esc(wo.title)}</td>
|
<td class="wo-title">${this.#esc(wo.title)}</td>
|
||||||
<td>${this.#esc(wo.site_name) || '<span style="color:var(--text-muted)">—</span>'}</td>
|
<td>${this.#esc(wo.site_name) || '<span style="color:var(--text-muted)">—</span>'}</td>
|
||||||
<td><ui-badge type="status" value="${wo.status}"></ui-badge></td>
|
<td><ui-badge type="status" value="${wo.status}"></ui-badge></td>
|
||||||
<td>
|
<td>
|
||||||
<span class="priority-dot" style="background:${PRI_COLOR[wo.priority]}"></span>
|
<span class="priority-dot" style="background:${PRI_COLOR[wo.priority]}"></span>
|
||||||
@@ -200,7 +200,7 @@ class WoList extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
<ui-badge type="status" value="${wo.status}"></ui-badge>
|
<ui-badge type="status" value="${wo.status}"></ui-badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-meta">${this.#esc(wo.site_name) || '—'} · ${wo.steps_done}/${wo.step_count} steps</div>
|
<div class="card-meta">${this.#esc(wo.site_name) || '—'} · ${wo.steps_done}/${wo.step_count} steps</div>
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class WoMap extends HTMLElement {
|
|||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [this] });
|
if (window.lucide) lucide.createIcons({ root: this });
|
||||||
|
|
||||||
if (lat && lng) {
|
if (lat && lng) {
|
||||||
this.#initMap(lat, lng, siteName, woNumber);
|
this.#initMap(lat, lng, siteName, woNumber);
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ class WoPhotoPanel extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</dialog>`;
|
</dialog>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
this.#bindEvents(filtered);
|
this.#bindEvents(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ class WoPhotoPanel extends HTMLElement {
|
|||||||
preview.innerHTML = Array.from(files).map(f =>
|
preview.innerHTML = Array.from(files).map(f =>
|
||||||
`<div class="preview-item"><i data-lucide="image" style="width:13px;height:13px"></i> ${this.#esc(f.name)}</div>`
|
`<div class="preview-item"><i data-lucide="image" style="width:13px;height:13px"></i> ${this.#esc(f.name)}</div>`
|
||||||
).join('');
|
).join('');
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [preview] });
|
if (window.lucide) lucide.createIcons({ root: preview });
|
||||||
btn.disabled = files.length === 0;
|
btn.disabled = files.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ class WoPhotoPanel extends HTMLElement {
|
|||||||
<button class="lightbox-next"><i data-lucide="chevron-right" style="width:20px;height:20px"></i></button>` : ''}
|
<button class="lightbox-next"><i data-lucide="chevron-right" style="width:20px;height:20px"></i></button>` : ''}
|
||||||
${p.caption ? `<div class="lightbox-caption">${this.#esc(p.caption)}</div>` : ''}`;
|
${p.caption ? `<div class="lightbox-caption">${this.#esc(p.caption)}</div>` : ''}`;
|
||||||
s.appendChild(lb);
|
s.appendChild(lb);
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [lb] });
|
if (window.lucide) lucide.createIcons({ root: lb });
|
||||||
lb.querySelector('.lightbox-close').addEventListener('click', () => lb.remove());
|
lb.querySelector('.lightbox-close').addEventListener('click', () => lb.remove());
|
||||||
lb.addEventListener('click', e => { if (e.target === lb) lb.remove(); });
|
lb.addEventListener('click', e => { if (e.target === lb) lb.remove(); });
|
||||||
lb.querySelector('.lightbox-prev')?.addEventListener('click', e => { e.stopPropagation(); show(i - 1); });
|
lb.querySelector('.lightbox-prev')?.addEventListener('click', e => { e.stopPropagation(); show(i - 1); });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ type: 'person', label: 'People', icon: 'users', endpoint: 'people' },
|
{ type: 'person', label: 'People', icon: 'users', endpoint: 'people' },
|
||||||
@@ -67,8 +67,8 @@ class WoResourcePanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#resourceName(item, type) {
|
#resourceName(item, type) {
|
||||||
if (type === 'vehicle') return `${item.unit_number}${item.description ? ' – ' + item.description : ''}`;
|
if (type === 'vehicle') return `${item.unit_number}${item.description ? ' – ' + item.description : ''}`;
|
||||||
return item.name || item.unit_number || '—';
|
return item.name || item.unit_number || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
#sectionHTML(section) {
|
#sectionHTML(section) {
|
||||||
@@ -166,7 +166,7 @@ class WoResourcePanel extends HTMLElement {
|
|||||||
<button class="dialog-close" id="picker-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
|
<button class="dialog-close" id="picker-close"><i data-lucide="x" style="width:16px;height:16px"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<input class="picker-search" id="picker-search" placeholder="Search…" autocomplete="off">
|
<input class="picker-search" id="picker-search" placeholder="Search…" autocomplete="off">
|
||||||
<div class="picker-list" id="picker-list"></div>
|
<div class="picker-list" id="picker-list"></div>
|
||||||
<div class="qty-row" id="qty-row" style="display:none">
|
<div class="qty-row" id="qty-row" style="display:none">
|
||||||
<label class="qty-label" for="qty-input">Quantity:</label>
|
<label class="qty-label" for="qty-input">Quantity:</label>
|
||||||
@@ -179,7 +179,7 @@ class WoResourcePanel extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</dialog>`;
|
</dialog>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
this.#bindEvents();
|
this.#bindEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ class WoResourcePanel extends HTMLElement {
|
|||||||
|
|
||||||
container.innerHTML = available.map(item => {
|
container.innerHTML = available.map(item => {
|
||||||
const name = type === 'vehicle'
|
const name = type === 'vehicle'
|
||||||
? `${item.unit_number}${item.description ? ' – ' + item.description : ''}`
|
? `${item.unit_number}${item.description ? ' – ' + item.description : ''}`
|
||||||
: item.name;
|
: item.name;
|
||||||
const sub = item.role || item.vehicle_type || item.category || (item.part_number ? `#${item.part_number}` : '') || '';
|
const sub = item.role || item.vehicle_type || item.category || (item.part_number ? `#${item.part_number}` : '') || '';
|
||||||
return `
|
return `
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from '../../lib/api.mjs';
|
import { api } from '../../lib/api.mjs';
|
||||||
|
|
||||||
const ACTION_ICONS = {
|
const ACTION_ICONS = {
|
||||||
status_change: 'refresh-cw',
|
status_change: 'refresh-cw',
|
||||||
@@ -122,7 +122,7 @@ class WoTimeline extends HTMLElement {
|
|||||||
${this.#detailHTML(e)}
|
${this.#detailHTML(e)}
|
||||||
<div class="entry-meta">
|
<div class="entry-meta">
|
||||||
<span class="entry-user">${this.#esc(e.performed_by)}</span>
|
<span class="entry-user">${this.#esc(e.performed_by)}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span title="${new Date(e.performed_at).toLocaleString()}">${this.#relativeTime(e.performed_at)}</span>
|
<span title="${new Date(e.performed_at).toLocaleString()}">${this.#relativeTime(e.performed_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +134,7 @@ class WoTimeline extends HTMLElement {
|
|||||||
<i data-lucide="rotate-cw" style="width:13px;height:13px"></i> Refresh
|
<i data-lucide="rotate-cw" style="width:13px;height:13px"></i> Refresh
|
||||||
</button>`;
|
</button>`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [s] });
|
if (window.lucide) lucide.createIcons({ root: s });
|
||||||
s.querySelector('#refresh-btn').addEventListener('click', () => this.#load());
|
s.querySelector('#refresh-btn').addEventListener('click', () => this.#load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ class WoTimeline extends HTMLElement {
|
|||||||
return `<div class="entry-detail">
|
return `<div class="entry-detail">
|
||||||
<span class="status-change">
|
<span class="status-change">
|
||||||
<span class="status-badge">${this.#esc(e.old_value.replace('_',' '))}</span>
|
<span class="status-badge">${this.#esc(e.old_value.replace('_',' '))}</span>
|
||||||
<span class="status-arrow">→</span>
|
<span class="status-arrow">→</span>
|
||||||
<span class="status-badge" style="background:var(--surface-2)">${this.#esc(e.new_value.replace('_',' '))}</span>
|
<span class="status-badge" style="background:var(--surface-2)">${this.#esc(e.new_value.replace('_',' '))}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user