<?php
/**
* Plugin Name: Ridgeway Work Orders
* Description: Work order post type, a REST API for reading and rescheduling orders, and a [ridgeway_board] shortcode that loads the weekly scheduling board.
* Version: 1.0.0
* Author: Shafi
* Text Domain: ridgeway-wo
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Ridgeway_Work_Orders {
const CPT = 'rw_work_order';
const NAMESPACE = 'ridgeway/v1';
public static function init() {
add_action( 'init', array( __CLASS__, 'register' ) );
add_action( 'rest_api_init', array( __CLASS__, 'routes' ) );
add_shortcode( 'ridgeway_board', array( __CLASS__, 'board_shortcode' ) );
}
/** Days shown on the board. Filterable for sites that schedule weekends. */
public static function days() {
return apply_filters( 'ridgeway_board_days', array( 'backlog', 'mon', 'tue', 'wed', 'thu', 'fri' ) );
}
public static function register() {
register_post_type(
self::CPT,
array(
'label' => __( 'Work Orders', 'ridgeway-wo' ),
'public' => false,
'show_ui' => true,
'show_in_rest' => false, // We expose a slimmer custom route instead.
'menu_icon' => 'dashicons-hammer',
'supports' => array( 'title' ),
)
);
$meta = array(
'asset' => 'string',
'crew' => 'string',
'day' => 'string',
'priority' => 'integer',
'hours' => 'number',
);
foreach ( $meta as $key => $type ) {
register_post_meta(
self::CPT,
'_rw_' . $key,
array(
'type' => $type,
'single' => true,
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
}
}
public static function routes() {
register_rest_route(
self::NAMESPACE,
'/work-orders',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'list_orders' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
register_rest_route(
self::NAMESPACE,
'/work-orders/(?P<id>\d+)',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( __CLASS__, 'move_order' ),
'permission_callback' => function ( WP_REST_Request $request ) {
return current_user_can( 'edit_post', (int) $request['id'] );
},
'args' => array(
'day' => array(
'required' => true,
'validate_callback' => function ( $value ) {
return in_array( $value, Ridgeway_Work_Orders::days(), true );
},
),
),
)
);
}
private static function shape( WP_Post $post ) {
return array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'asset' => get_post_meta( $post->ID, '_rw_asset', true ),
'crew' => get_post_meta( $post->ID, '_rw_crew', true ),
'day' => get_post_meta( $post->ID, '_rw_day', true ) ? get_post_meta( $post->ID, '_rw_day', true ) : 'backlog',
'priority' => (int) get_post_meta( $post->ID, '_rw_priority', true ),
'hours' => (float) get_post_meta( $post->ID, '_rw_hours', true ),
);
}
public static function list_orders() {
$posts = get_posts(
array(
'post_type' => self::CPT,
'posts_per_page' => 200,
'no_found_rows' => true,
)
);
$orders = array_map( array( __CLASS__, 'shape' ), $posts );
return rest_ensure_response( apply_filters( 'ridgeway_work_orders', $orders ) );
}
public static function move_order( WP_REST_Request $request ) {
$post = get_post( (int) $request['id'] );
if ( ! $post || self::CPT !== $post->post_type ) {
return new WP_Error( 'rw_not_found', __( 'Work order not found.', 'ridgeway-wo' ), array( 'status' => 404 ) );
}
update_post_meta( $post->ID, '_rw_day', sanitize_key( $request['day'] ) );
do_action( 'ridgeway_work_order_moved', $post->ID, $request['day'] );
return rest_ensure_response( self::shape( $post ) );
}
public static function board_shortcode() {
if ( ! current_user_can( 'edit_posts' ) ) {
return '<p>' . esc_html__( 'Sign in to view the schedule.', 'ridgeway-wo' ) . '</p>';
}
$url = plugin_dir_url( __FILE__ );
wp_enqueue_style( 'ridgeway-board', $url . 'assets/css/board.css', array(), '1.0.0' );
wp_enqueue_script( 'ridgeway-board', $url . 'assets/js/ridgeway-board.js', array(), '1.0.0', true );
// Hand the script its endpoint and a REST nonce instead of hard-coding URLs.
wp_add_inline_script(
'ridgeway-board',
'window.RidgewayBoard = ' . wp_json_encode(
array(
'restUrl' => esc_url_raw( rest_url( self::NAMESPACE . '/work-orders' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'days' => self::days(),
)
) . ';',
'before'
);
return '<div id="rw-board" class="rw-board" aria-live="polite"></div>';
}
}
Ridgeway_Work_Orders::init();
/**
* Ridgeway weekly scheduling board.
* Reads work orders from the plugin's REST route when it runs inside
* WordPress, or falls back to sample data for the stand-alone demo.
*/
(function () {
'use strict';
const root = document.getElementById('rw-board');
if (!root) return;
const config = window.RidgewayBoard || {};
const DAYS = config.days || ['backlog', 'mon', 'tue', 'wed', 'thu', 'fri'];
const LABELS = { backlog: 'Backlog', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' };
const PRIORITY = { 1: 'Emergency', 2: 'Urgent', 3: 'Routine', 4: 'Planned' };
const CAPACITY = 16; // crew hours available per day
const SAMPLE = [
{ id: 10421, title: 'Replace mechanical seal', asset: 'Pump P-101', crew: 'Mechanical', day: 'mon', priority: 2, hours: 6 },
{ id: 10422, title: 'Thermal scan of MCC-4', asset: 'Motor control centre', crew: 'Electrical', day: 'mon', priority: 3, hours: 3 },
{ id: 10425, title: 'Belt tension and alignment', asset: 'Conveyor C-3', crew: 'Mechanical', day: 'tue', priority: 3, hours: 4 },
{ id: 10428, title: 'Calibrate pressure transmitter', asset: 'PT-210', crew: 'Instrumentation', day: 'tue', priority: 4, hours: 2 },
{ id: 10430, title: 'Burner inspection', asset: 'Boiler B-2', crew: 'Mechanical', day: 'wed', priority: 3, hours: 8 },
{ id: 10431, title: 'Replace VFD cooling fan', asset: 'Compressor K-7', crew: 'Electrical', day: 'wed', priority: 2, hours: 3 },
{ id: 10433, title: 'Filter change and coil clean', asset: 'AHU-2', crew: 'Mechanical', day: 'wed', priority: 4, hours: 6 },
{ id: 10436, title: 'Loop check after shutdown', asset: 'FIC-305', crew: 'Instrumentation', day: 'thu', priority: 3, hours: 5 },
{ id: 10437, title: 'Gearbox oil sample', asset: 'Mixer M-12', crew: 'Mechanical', day: 'fri', priority: 4, hours: 1 },
{ id: 10440, title: 'Leak at discharge flange', asset: 'Pump P-104', crew: 'Mechanical', day: 'backlog', priority: 1, hours: 4 },
{ id: 10441, title: 'Lighting retrofit, bay 3', asset: 'Warehouse', crew: 'Electrical', day: 'backlog', priority: 4, hours: 6 },
{ id: 10443, title: 'Replace level switch', asset: 'Tank T-6', crew: 'Instrumentation', day: 'backlog', priority: 2, hours: 3 }
];
// Work order text comes from the database, so escape it before it touches innerHTML.
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
let orders = [];
let crewFilter = 'all';
async function load() {
if (config.restUrl) {
const res = await fetch(config.restUrl, { headers: { 'X-WP-Nonce': config.nonce } });
if (!res.ok) throw new Error('Could not load work orders (' + res.status + ').');
orders = await res.json();
} else {
orders = SAMPLE.map((o) => ({ ...o }));
}
render();
}
async function move(id, day) {
const order = orders.find((o) => o.id === id);
if (!order || order.day === day) return;
const previous = order.day;
order.day = day;
render();
if (!config.restUrl) return;
try {
const res = await fetch(config.restUrl + '/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
body: JSON.stringify({ day })
});
if (!res.ok) throw new Error();
} catch (e) {
order.day = previous; // roll back so the board matches the server
render();
announce('That move did not save. The card is back where it was.');
}
}
function announce(msg) {
const live = root.querySelector('.rw-status');
if (live) live.textContent = msg;
}
function hoursFor(day) {
return orders.filter((o) => o.day === day).reduce((sum, o) => sum + Number(o.hours), 0);
}
function card(o) {
const options = DAYS.map((d) => `<option value="${d}"${d === o.day ? ' selected' : ''}>${LABELS[d]}</option>`).join('');
return `
<article class="rw-card rw-card--p${o.priority}" draggable="true" data-id="${o.id}">
<header class="rw-card__head">
<span class="rw-card__id">WO-${o.id}</span>
<span class="rw-pill rw-pill--p${o.priority}">${PRIORITY[o.priority]}</span>
</header>
<h4 class="rw-card__title">${esc(o.title)}</h4>
<p class="rw-card__meta">${esc(o.asset)} · ${esc(o.crew)} · ${o.hours} h</p>
<label class="rw-card__move">Move to
<select data-move="${o.id}" aria-label="Move WO-${o.id} to">${options}</select>
</label>
</article>`;
}
function column(day) {
const list = orders.filter((o) => o.day === day && (crewFilter === 'all' || o.crew === crewFilter))
.sort((a, b) => a.priority - b.priority);
const hours = hoursFor(day);
const isBacklog = day === 'backlog';
const pct = Math.min(100, Math.round((hours / CAPACITY) * 100));
const state = hours > CAPACITY ? 'over' : hours > CAPACITY * 0.8 ? 'tight' : 'ok';
const load = isBacklog
? `<p class="rw-col__load">${hours} h waiting</p>`
: `<div class="rw-col__load rw-load rw-load--${state}" title="${hours} of ${CAPACITY} crew hours">
<span class="rw-load__bar"><span style="width:${pct}%"></span></span>
<span class="rw-load__text">${hours} / ${CAPACITY} h</span>
</div>`;
return `
<section class="rw-col${isBacklog ? ' rw-col--backlog' : ''}" data-day="${day}">
<header class="rw-col__head"><h3>${LABELS[day]}</h3>${load}</header>
<div class="rw-col__list">${list.map(card).join('') || '<p class="rw-empty">Nothing scheduled</p>'}</div>
</section>`;
}
function render() {
const crews = [...new Set(orders.map((o) => o.crew))].sort();
const scheduled = DAYS.filter((d) => d !== 'backlog').reduce((s, d) => s + hoursFor(d), 0);
const capacity = (DAYS.length - 1) * CAPACITY;
const over = DAYS.filter((d) => d !== 'backlog' && hoursFor(d) > CAPACITY).length;
const emergencies = orders.filter((o) => o.priority === 1 && o.day === 'backlog').length;
root.innerHTML = `
<div class="rw-toolbar">
<dl class="rw-stats">
<div><dt>Scheduled</dt><dd>${scheduled} / ${capacity} h</dd></div>
<div><dt>Overloaded days</dt><dd class="${over ? 'is-alert' : ''}">${over}</dd></div>
<div><dt>Emergencies in backlog</dt><dd class="${emergencies ? 'is-alert' : ''}">${emergencies}</dd></div>
</dl>
<label class="rw-filter">Crew
<select id="rw-crew">
<option value="all">All crews</option>
${crews.map((c) => `<option${c === crewFilter ? ' selected' : ''}>${esc(c)}</option>`).join('')}
</select>
</label>
</div>
<div class="rw-grid">${DAYS.map(column).join('')}</div>
<p class="rw-status" role="status"></p>`;
}
// Event delegation: one set of listeners survives every re-render.
root.addEventListener('change', (e) => {
if (e.target.id === 'rw-crew') {
crewFilter = e.target.value;
render();
} else if (e.target.dataset.move) {
move(Number(e.target.dataset.move), e.target.value);
announce(`WO-${e.target.dataset.move} moved to ${LABELS[e.target.value]}.`);
}
});
root.addEventListener('dragstart', (e) => {
const el = e.target.closest('.rw-card');
if (!el) return;
e.dataTransfer.setData('text/plain', el.dataset.id);
el.classList.add('is-dragging');
});
root.addEventListener('dragend', (e) => {
const el = e.target.closest('.rw-card');
if (el) el.classList.remove('is-dragging');
});
root.addEventListener('dragover', (e) => {
const col = e.target.closest('.rw-col');
if (!col) return;
e.preventDefault();
root.querySelectorAll('.rw-col.is-over').forEach((c) => c !== col && c.classList.remove('is-over'));
col.classList.add('is-over');
});
root.addEventListener('dragleave', (e) => {
const col = e.target.closest('.rw-col');
if (col && !col.contains(e.relatedTarget)) col.classList.remove('is-over');
});
root.addEventListener('drop', (e) => {
const col = e.target.closest('.rw-col');
if (!col) return;
e.preventDefault();
const id = Number(e.dataTransfer.getData('text/plain'));
move(id, col.dataset.day);
announce(`WO-${id} moved to ${LABELS[col.dataset.day]}.`);
});
root.resetBoard = () => { crewFilter = 'all'; load(); };
load().catch((err) => {
root.innerHTML = `<p class="rw-error">${esc(err.message)}</p>`;
});
})();