Files
baiyue-logistics-chat/static/js/customer_orders.js
T

178 lines
9.6 KiB
JavaScript

// A private, read-only order panel. It never replaces chat drafts or sends AI requests.
var CustomerOrders = {
create(options){
const node = name => document.getElementById('customer-orders'+(name ? '-'+name : ''));
const root=node(''), toggle=node('toggle'), body=node('body'), list=node('list');
const detail=node('detail'), message=node('message'), more=node('more');
const refresh=node('refresh'), back=node('back'), retry=node('retry');
const overview=node('summary'), progress=node('progress');
const groups=['all','processing','completed','pending','cancelled','other'];
const filters=groups.map(function(group){
const button=document.createElement('button');
button.type='button';button.className='customer-order-filter';button.dataset.filter=group;
button.onclick=function(){
if(activeFilter === group && selected === null && !errorKey) return;
activeFilter=group;loadList(false);
};
overview.appendChild(button);
return button;
});
let summary=null, activeFilter='all';
let user=null, rows=[], cursor=null, card=null, selected=null;
let expanded=false, loading=false, errorKey='', retryAction=null, version=0, controller=null, listScroll=0;
function cancel(){
version++;
if(controller) controller.abort();
controller=null;
loading=false;
}
function render(){
root.hidden=!user;
toggle.textContent=options.text('myOrders')+(summary ? ' · '+summary.total : '');
toggle.setAttribute('aria-expanded',String(expanded));
body.hidden=!expanded;
refresh.hidden=!expanded;
refresh.textContent=options.text('ordersRefresh');
back.textContent=options.text('ordersBack');
back.hidden=selected === null;
list.hidden=selected !== null;
detail.hidden=selected === null;
overview.hidden=!summary || selected !== null;
overview.setAttribute('aria-label',options.text('ordersOverview'));
filters.forEach(function(button){
const group=button.dataset.filter;
button.textContent='';
button.setAttribute('aria-pressed',String(activeFilter === group));
if(!summary) return;
const label=document.createElement('span');label.textContent=options.text('ordersGroup_'+group);
const count=document.createElement('strong');count.textContent=String(summary[group === 'all' ? 'total' : group]);
button.append(label,count);
});
progress.hidden=!summary || selected !== null || !rows.length || loading;
progress.textContent=summary ? options.text('ordersProgress').replace('{loaded}',rows.length)
.replace('{total}',summary[activeFilter === 'all' ? 'total' : activeFilter]) : '';
more.hidden=selected !== null || !cursor;
more.disabled=loading;
more.textContent=options.text('ordersMore');
retry.hidden=!errorKey || !retryAction;
retry.textContent=options.text('ordersRetry');
message.textContent=errorKey ? options.text(errorKey) : loading ? options.text('ordersLoading')
: !rows.length ? options.text(activeFilter === 'all' ? 'ordersEmpty' : 'ordersFilterEmpty') : '';
message.hidden=!message.textContent;
body.setAttribute('aria-busy',String(loading));
}
function renderRows(){
list.textContent='';
rows.forEach(function(row){
const button=document.createElement('button');
button.type='button';button.className='customer-order-row';
button.dataset.orderId=String(row.id);
const identity=document.createElement('span');identity.className='customer-order-identity';
const label=document.createElement('span');label.className='customer-order-label';
label.textContent=options.text(row.inbound_code ? 'order_inbound' : 'orderNo');
const number=document.createElement('strong');number.textContent=row.inbound_code || row.order_no;
identity.append(label,number);
const route=document.createElement('span');route.className='customer-order-route';
route.textContent=[row.country_code,row.warehouse_code,
row.transport_mode && (options.text('orderMode_'+row.transport_mode) || row.transport_mode)].filter(Boolean).join(' · ');
const status=document.createElement('span');status.className='customer-order-status';
status.textContent=options.text('orderStatus_'+row.status) || row.status;
const action=document.createElement('span');action.className='customer-order-action';
action.textContent=options.text('ordersTrack')+' →';
button.append(identity,route,status,action);
button.onclick=()=>loadDetail(row.id);
list.appendChild(button);
});
}
function renderCard(){
detail.textContent='';
if(card) options.renderCard(detail,card);
}
async function request(url,apply,retryRequest){
cancel();
const token=version;
const active=new AbortController();controller=active;
loading=true;errorKey='';retryAction=retryRequest;render();
let timedOut=false, receivedResponse=false;
const timer=setTimeout(()=>{timedOut=true;active.abort();},10000);
try{
const response=await options.fetch(url,{signal:active.signal,cache:'no-store'});
if(token !== version) return;
if(response.status === 401){
setUser(null);
if(options.onUnauthorized) options.onUnauthorized();
return;
}
receivedResponse=true;
if(!response.ok){
const errors={400:'ordersInvalidRequest',403:'ordersForbidden',408:'ordersTimeout',
429:'ordersRateLimited',504:'ordersTimeout'};
errorKey=response.status === 404 && /\/orders\/\d+$/.test(url) ? 'ordersUnavailable'
: errors[response.status] || (response.status >= 500 ? 'ordersServerError' : 'ordersFailed');
if([400,403,404].includes(response.status)) retryAction=null;
return;
}
const result=await response.json();
if(token !== version) return;
apply(result);
}catch(error){
if(token === version) errorKey=timedOut ? 'ordersTimeout'
: receivedResponse ? 'ordersInvalidResponse' : 'ordersNetworkError';
}finally{
clearTimeout(timer);
if(token === version){ controller=null;loading=false;render(); }
}
}
function loadList(append, preserveView){
if(!user || (append && (loading || !cursor))) return;
if(!append && !preserveView){ selected=null;card=null;rows=[];cursor=null;body.scrollTop=0;renderCard();renderRows(); }
const params=[];
if(activeFilter !== 'all') params.push('status='+activeFilter);
if(append) params.push('before='+cursor);
const suffix=params.length ? '?'+params.join('&') : '';
return request('/api/customer/orders'+suffix,function(result){
if(!Array.isArray(result.orders)) throw new Error('invalid order list');
// Older responses may lack an overview; never infer totals from one page.
summary=result.summary || null;
rows=append ? rows.concat(result.orders) : result.orders;
cursor=result.next_before || null;
renderRows();
},()=>loadList(append,preserveView));
}
function loadDetail(id){
if(!user) return;
if(selected === null) listScroll=body.scrollTop;
selected=id;card=null;renderCard();
const pending=request('/api/customer/orders/'+id,function(result){
if(!result.card) throw new Error('invalid order detail');
card=result.card;renderCard();
},()=>loadDetail(id));
back.focus({preventScroll:true});
body.scrollTop=0;
return pending;
}
function setUser(next){
if(user && next && user.id === next.id) return;
cancel();user=next;rows=[];cursor=null;card=null;selected=null;errorKey='';retryAction=null;
summary=null;activeFilter='all';
expanded=Boolean(user);renderRows();renderCard();render();
if(user) loadList(false);
}
toggle.onclick=function(){ expanded=!expanded;render(); };
refresh.onclick=()=>loadList(false);
more.onclick=()=>loadList(true);
retry.onclick=()=>retryAction && retryAction();
back.onclick=function(){
const id=selected;
cancel();selected=null;card=null;errorKey='';renderCard();render();
const button=Array.from(list.children).find(item=>item.dataset.orderId === String(id));
body.scrollTop=listScroll;
if(button) button.focus({preventScroll:true});
};
render();
return {setUser:setUser,refresh:function(){
if(user && !loading) return loadList(false,true);
},render:function(){renderRows();renderCard();render();}};
}
};