Files
SGF/resources/js/WorkshopWorkspace.jsx
T
2026-10-29 22:42:37 +02:00

59 lines
14 KiB
React

import React, { useEffect, useRef, useState } from 'react';
import {visibleWorkshopSections} from './workshop-menu.js';
const kindLabels={assignment:'Responsável atribuído',task:'Tarefa',authorization:'Autorização registada',start:'Início autorizado',time:'Tempo relatado',material:'Material observado'};
const eventLabels={required:'Requisitado',delivered:'Entregue',consumed:'Aplicado/consumido',returned:'Devolvido'};
const decisionLabels={verified:'Verificado',refused:'Recusado',acknowledged:'Ocorrência reconhecida para auditoria — continua fora do âmbito'};
const iso=v=>new Date(v).toISOString();
export default function WorkshopWorkspace({context,can,api,busy,initialOrderId=null,navigate}) {
const [data,setData]=useState(null),[orderId,setOrderId]=useState(initialOrderId??''),[error,setError]=useState(''),[working,setWorking]=useState(false),[taskId,setTaskId]=useState('');
const attempt=useRef(null);
const allowed=r=>can(`workshop.${r}`,context.unit?.id??null);
const load=async()=>{const d=await api('/api/workshop');setData(d);return d;};
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
const order=data?.orders.find(o=>String(o.id)===String(orderId))??data?.orders[0];
const tasks=order?.entries.filter(e=>e.kind==='task'&&e.current)??[];
const task=tasks.find(e=>String(e.id)===String(taskId))??tasks[0];
const authorizations=order?.entries.filter(e=>e.kind==='authorization'&&e.current&&e.task_id===task?.id)??[];
const auth=authorizations.at(-1);
const starts=order?.entries.filter(e=>e.kind==='start'&&!order.entries.some(t=>t.start_id===e.id))??[];
const disabled=busy||working;
async function send(path,input) {
setWorking(true);setError(''); const canonical=JSON.stringify([path,input]);
if(attempt.current?.canonical!==canonical)attempt.current={canonical,key:crypto.randomUUID()};
try{await api('/api/workshop'+path,'POST',input,context.token,attempt.current.key);await load();attempt.current=null;}
catch(e){setError(e.message);}finally{setWorking(false);}
}
function entry(kind,values) {
const d={...values};
if(d.previous_id) {
const previous=order.entries.find(e=>e.id===Number(d.previous_id));
if(!previous){setError('Seleccione uma origem existente desta ordem.');return;}
d.previous_id=previous.id; d.origin=previous.origin; d.version=previous.version+1;
} else {delete d.previous_id;d.version=1;}
return send(`/orders/${order.id}/entries`,{kind,revision:order.revision,...d});
}
const correctionFields=kind=><><label>Versão anterior a corrigir (opcional)<select name="previous_id"><option value="">Novo acontecimento</option>{order.entries.filter(e=>e.kind===kind&&e.current).map(e=><option key={e.id} value={e.id}>#{e.id} · {e.origin} v{e.version}</option>)}</select></label><label>Motivo de correcção (se aplicável)<input name="correction_reason"/></label></>;
return <>
{navigate&&<nav className="billing-section-tabs" aria-label="Submenus de oficinas">{visibleWorkshopSections(can,context.unit?.id??null).map(s=><button key={s.id} aria-current={s.id==='workshop'?'page':undefined} onClick={()=>{if(s.id!=='workshop')navigate(s.id);}}>{s.label}</button>)}</nav>}
<section className="panel"><p className="eyebrow">WORKSHOP-RESOURCE-RECORD-v1</p><h1>Oficina — registo de recursos</h1><div className="alert error"><strong>TESTE — SEM UTILIZAÇÃO OPERACIONAL</strong><p>Sem stock, custos, facturação ou recebimentos.</p></div>{error&&<p role="alert" className="alert error">{error}</p>}<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar revisão explicitamente</button><p className="help">Abertura não autoriza trabalho. Relatar uma ocorrência passada não lhe dá autorização retroactiva. Alterações concorrentes exigem nova revisão.</p></section>
{!data?<p>A carregar…</p>:data.blocked?<section className="panel"><h2>Utilização operacional bloqueada</h2><p>Disponível apenas na instância isolada ligada à base de testes.</p></section>:<>
{allowed('open')&&<section className="panel"><h2>Abrir ordem de teste</h2><Form label="Abrir ordem" disabled={disabled} save={v=>send('/orders',{...v,client_id:Number(v.client_id)})}><label>Referência operacional<input name="reference" required/></label><label>Cliente existente<select name="client_id">{data.clients.map(c=><option key={c.id} value={c.id}>#{c.id} · {c.name}</option>)}</select></label><label>Identificação individual do equipamento<input name="equipment_identifier" required/></label><label>Descrição / marca / modelo<input name="equipment_description" required/></label><label>Número de série (se conhecido)<input name="serial"/></label><label>Pedido / avaria<textarea name="request" required/></label><label>Localização<input name="location" required/></label><label>Evidência do pedido<input name="evidence" required/></label></Form><p className="help">Para reutilizar equipamento já identificado, a API aceita o seu ID; não criar outro só por existir uma nova ordem.</p></section>}
<section className="panel"><h2>Ordens autorizadas para consulta</h2><label>Ordem<select value={order?.id??''} onChange={e=>{setOrderId(e.target.value);setTaskId('');}}>{data.orders.map(o=><option key={o.id} value={o.id}>{o.reference} · #{o.id}</option>)}</select></label>{order?<><p>Cliente #{order.client_id} · {order.equipment.identifier} · {order.equipment.description} · {order.location}</p><p>{order.request}</p><p>Revisão {order.revision} · Responsável #{order.assigned_to??'por atribuir'} · {({open:'Aberta',in_review:'Apontamentos em revisão',exception_review:'Ocorrências fora do âmbito para revisão',records_verified:'Registos verificados — sem conclusão comercial'})[order.state]}</p><div className="summary-grid"><div><span>Tempo efectivo relatado</span><h2>{order.minutes_reported} MIN</h2></div><div><span>Tempo efectivo verificado</span><h2>{order.minutes_verified} MIN</h2></div></div></>:<p>Sem ordens visíveis neste âmbito/atribuição.</p>}</section>
{order&&<>
{allowed('assign')&&<section className="panel"><h2>Atribuir responsável</h2><Form label="Atribuir técnico" disabled={disabled} save={v=>entry('assignment',{origin:v.origin,technician_id:Number(v.technician_id)})}><label>Referência da atribuição<input name="origin" required/></label><label>Técnico com direito próprio<select name="technician_id">{data.technicians.map(t=><option key={t.id} value={t.id}>#{t.id} · {t.name}</option>)}</select></label></Form></section>}
<section className="panel"><h2>Tarefa e âmbito</h2><label>Versão da tarefa<select value={task?.id??''} onChange={e=>setTaskId(e.target.value)}>{tasks.map(t=><option key={t.id} value={t.id}>#{t.id} · {t.data.description} · v{t.version}</option>)}</select></label><p>{auth?`Autorização #${auth.id}: ${auth.data.authorized_by} · ${auth.data.authorized_at} · ${auth.data.scope}`:'Sem autorização demonstrada para esta versão.'}</p></section>
{allowed('record')&&<section className="panel"><h2>Registar tarefa ou nova versão</h2><Form label="Guardar tarefa" disabled={disabled} save={v=>entry('task',v)}><label>Identificador de origem da tarefa<input name="origin" required/></label><label>Descrição da tarefa<textarea name="description" required/></label>{correctionFields('task')}</Form></section>}
{allowed('authorize')&&task&&<section className="panel"><h2>Registar autorização do cliente</h2><Form label="Registar autorização" disabled={disabled} save={v=>entry('authorization',{...v,task_id:task.id,authorized_at:iso(v.authorized_at)})}><label>Identificador da evidência de autorização<input name="origin" required/></label><label>Quem autorizou<input name="authorized_by" required/></label><label>Qualidade / representação do cliente<input name="capacity" required/></label><label>Data/hora da autorização do cliente<input type="datetime-local" name="authorized_at" required/></label><label>Canal<input name="channel" required/></label><label>Trabalho abrangido nesta versão<textarea name="scope" required/></label><label>Referência do comprovativo ou contrato<input name="evidence" required/></label>{correctionFields('authorization')}</Form><p className="help">O autor deste registo é distinto da pessoa do cliente que autorizou. Nova autorização não limpa ocorrências anteriores fora do âmbito.</p></section>}
{allowed('record')&&task&&<>
<section className="panel"><h2>Início de trabalho</h2><Form label="Iniciar trabalho autorizado" disabled={disabled} save={v=>entry('start',{...v,task_id:task.id,...(auth?{authorization_id:auth.id}:{})})}><label>Referência do início<input name="origin" required/></label><label>Evidência do início<input name="evidence" required/></label></Form><p>Sem autorização compatível, o servidor recusa iniciar. Uma ocorrência passada pode ser relatada no formulário seguinte, sem se tornar autorizada.</p></section>
<section className="panel"><h2>Relatar tempo efectivo</h2><Form label="Guardar apontamento de tempo" disabled={disabled} save={v=>{if(Boolean(v.pause_start)!==Boolean(v.pause_end)){setError('Indique início e fim da pausa; não será ignorada.');return;} const d={origin:v.origin,task_id:task.id,...(auth?{authorization_id:auth.id}:{}),mode:v.start_id?'started':'reported',time_type:v.time_type,starts_at:iso(v.starts_at),ends_at:iso(v.ends_at),evidence:v.evidence,previous_id:v.previous_id,correction_reason:v.correction_reason,pauses:[]}; if(v.pause_start&&v.pause_end)d.pauses=[{start:iso(v.pause_start),end:iso(v.pause_end)}]; if(v.start_id)d.start_id=Number(v.start_id);return entry('time',d);}}><label>Identificador da ocorrência de tempo<input name="origin" required/></label><label>Início registado (opcional)<select name="start_id"><option value="">Relato de ocorrência já acontecida</option>{starts.map(s=><option key={s.id} value={s.id}>#{s.id} · {s.starts_at} · tarefa #{s.task_id}</option>)}</select></label><label>Tipo de tempo<select name="time_type"><option value="work">Trabalho</option><option value="travel">Deslocação</option></select></label><label>Início efectivo<input type="datetime-local" name="starts_at" required/></label><label>Fim efectivo<input type="datetime-local" name="ends_at" required/></label><label>Início da pausa (opcional)<input type="datetime-local" name="pause_start"/></label><label>Fim da pausa (opcional)<input type="datetime-local" name="pause_end"/></label><label>Evidência da ocorrência<textarea name="evidence" required/></label>{correctionFields('time')}</Form><p className="help">A API suporta várias pausas; este formulário inicial recebe uma. Intervalos incompletos ou não representáveis exactamente em minutos são recusados.</p></section>
<section className="panel"><h2>Registar material observado</h2><Form label="Guardar apontamento de material" disabled={disabled} save={v=>entry('material',{...v,task_id:task.id,...(auth?{authorization_id:auth.id}:{}),starts_at:iso(v.starts_at)})}><label>Identificador da ocorrência material<input name="origin" required/></label><label>Referência estável do material<input name="resource" required/></label><label>Ocorrência<select name="event">{Object.entries(eventLabels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Propriedade<select name="owner"><option value="company">Empresa</option><option value="client">Cliente</option></select></label><label>Unidade de medida<select name="unit">{['UN','KG','M','L'].map(u=><option key={u}>{u}</option>)}</select></label><label>Quantidade observada<input name="quantity" inputMode="decimal" required/></label><label>Data/hora da ocorrência<input name="starts_at" type="datetime-local" required/></label><label>Evidência de origem<textarea name="evidence" required/></label>{correctionFields('material')}</Form><p>Sem reserva, armazém, custo ou saída de stock. Divergência física fica visível para revisão.</p></section>
</>}
<section className="panel"><h2>Materiais por referência, propriedade e unidade</h2>{order.materials.map(m=><article key={m.resource+m.owner+m.unit}><h3>{m.resource} · {m.unit} · {m.owner==='client'?'Cliente':'Empresa'}</h3><p>Requisitado {m.required} · Entregue {m.delivered} · Aplicado {m.consumed} · Devolvido {m.returned} · Aplicação verificada {m.verified_consumed}</p><p>Diferença física a explicar: {m.unexplained} {m.unit}. São observações, não saldo de armazém.</p></article>)}</section>
<section className="panel"><h2>Apontamentos, versões e verificação</h2>{order.entries.map(e=><article className="panel" key={e.id}><h3>#{e.id} · {kindLabels[e.kind]} · {e.origin} v{e.version}{!e.current?' · Histórico substituído':''}</h3><p>{e.data.description||e.data.scope||e.data.evidence} · Autor #{e.actor_id}</p>{e.minutes!=null&&<p>{e.starts_at} a {e.ends_at} · {e.minutes} MIN efectivos · {e.data.time_type==='travel'?'Deslocação':'Trabalho'}</p>}{e.quantity!=null&&<p>{eventLabels[e.data.event]} · {e.quantity} {e.data.unit} · {e.data.resource}</p>}{e.outside_authority&&<div className="alert error">FORA DO ÂMBITO AUTORIZADO — relato para auditoria. Não autoriza execução nem ganha cobertura retroactiva.</div>}<p>{e.decision?`${decisionLabels[e.decision.decision]} · ${e.decision.reason} · Verificador #${e.decision.actor_id}`:e.current&&['task','time','material'].includes(e.kind)?'Aguarda verificação explícita.':''}</p>{allowed('verify')&&e.current&&!e.decision&&['task','time','material'].includes(e.kind)&&<Form label={e.outside_authority?'Reconhecer ocorrência para auditoria':'Verificar apontamento'} disabled={disabled} save={v=>send(`/entries/${e.id}/verification`,{version:e.version,revision:order.revision,decision:e.outside_authority?'acknowledged':'verified',reason:v.reason})}><label>Fundamento da revisão<input name="reason" required/></label><button type="button" disabled={disabled} onClick={event=>{const form=event.currentTarget.form;if(!form.reportValidity())return;send(`/entries/${e.id}/verification`,{version:e.version,revision:order.revision,decision:'refused',reason:String(new FormData(form).get('reason'))});}}>Recusar apontamento</button></Form>}</article>)}</section>
</>}
</>}
</>;
}
function Form({children,label,disabled,save}) {return <form onSubmit={e=>{e.preventDefault();save(Object.fromEntries(new FormData(e.currentTarget)));}}>{children}<button className="primary" disabled={disabled}>{label}</button></form>;}