js // Granite ERP demo application logic: Pinecone Router settings, the shared
// "erp" Alpine store (the only owner of dataset mutations, so actions ripple
// across pages), the app shell controller, and one controller per page.
// Page fragments in pages/*.html are pure markup that reference these
// controllers by name; they hold local UI state only and read data from the store.
document. addEventListener ( 'alpine:init' , () => {
window.PineconeRouter. settings ({
// Hash routing keeps deep links and reloads working on static hosting.
hash: true ,
// Every route template renders into the <div id="page-outlet"> in index.html.
targetID: 'page-outlet' ,
// Fetch all page fragments at low priority after the first page renders.
preload: true ,
// Inside the docs homepage iframe, skip history.pushState() so demo
// navigation does not pollute the parent page's browser history.
pushState: window.self === window.top,
});
const data = window.GraniteData;
const toastIcons = { positive: 'circle-success' , negative: 'circle-error' , warning: 'circle-warning' , information: 'circle-info' };
const statusVariants = {
Paid: 'positive' ,
Sent: 'information' ,
Draft: 'outline' ,
Overdue: 'negative' ,
Due: 'warning' ,
Scheduled: 'outline' ,
Pending: 'warning' ,
Approved: 'positive' ,
Rejected: 'negative' ,
};
const currencyCodes = { usd: 'USD' , eur: 'EUR' , gbp: 'GBP' };
const moneyFormatters = {};
Alpine. store ( 'erp' , {
invoices: [],
bills: [],
customers: [],
vendors: [],
inventory: [],
documents: [],
messages: [],
approvals: [],
notifications: [],
activity: [],
searchSeed: '' ,
settings: {},
_toaster: undefined ,
init () {
this . reset ( false );
},
reset ( notify = true ) {
this .invoices = structuredClone (data.invoices);
this .bills = structuredClone (data.bills);
this .customers = structuredClone (data.customers);
this .vendors = structuredClone (data.vendors);
this .inventory = structuredClone (data.inventory);
this .documents = structuredClone (data.documents);
this .messages = structuredClone (data.messages);
this .approvals = structuredClone (data.approvals);
this .notifications = structuredClone (data.notifications);
this .activity = structuredClone (data.activity);
this .settings = {
company: {
name: 'Granite Industrial Ltd' ,
email: 'finance@granite.example' ,
taxId: 'BG204411358' ,
address: '12 Foundry Street, Sofia, Bulgaria' ,
about: 'Industrial monitoring hardware and services.' ,
},
currency: 'usd' ,
dateFormat: 'mdy' ,
notifications: { invoiceEmails: true , approvalAlerts: true , weeklyDigest: false , lowStockAlerts: true },
// Seed from Harmonia's current color scheme ("auto" | "light" | "dark").
theme: Harmonia. getColorScheme (),
};
if (notify) this . toast ( 'Workspace reset' , 'All demo data has been restored to its initial state.' , 'information' );
},
// ---- notifications (toasts) ----
attachToaster ( notificationsMagic ) {
this ._toaster = notificationsMagic;
},
toast ( title , description = '' , variant = 'information' ) {
this ._toaster?. add ({
template: 'toast' ,
position: 'bottom-right' ,
timeout: 4500 ,
data: { title, description, variant, icon: toastIcons[variant] || toastIcons.information },
});
},
// ---- shared formatting helpers ----
money ( value ) {
const code = currencyCodes[ this .settings.currency] || 'USD' ;
const kind = Number. isInteger (value) ? 'whole' : 'cents' ;
const key = code + kind;
moneyFormatters[key] ??= new Intl. NumberFormat ( 'en-US' , { style: 'currency' , currency: code, minimumFractionDigits: kind === 'whole' ? 0 : 2 , maximumFractionDigits: kind === 'whole' ? 0 : 2 });
return moneyFormatters[key]. format (value);
},
date ( iso ) {
if ( ! iso) return '' ;
if ( this .settings.dateFormat === 'iso' ) return iso;
const locale = this .settings.dateFormat === 'dmy' ? 'en-GB' : 'en-US' ;
return new Intl. DateTimeFormat (locale, { dateStyle: 'medium' }). format ( new Date (iso. replace ( ' ' , 'T' )));
},
statusVariant ( status ) {
return statusVariants[status] || 'outline' ;
},
// ---- derived state (drives badges and KPIs, so actions ripple everywhere) ----
get unreadCount () {
return this .messages. filter (( m ) => m.unread). length ;
},
get pendingApprovals () {
return this .approvals. filter (( a ) => a.status === 'Pending' );
},
get openInvoices () {
return this .invoices. filter (( i ) => i.status === 'Sent' || i.status === 'Overdue' );
},
get openInvoiceTotal () {
return this .openInvoices. reduce (( sum , i ) => sum + i.amount, 0 );
},
get overdueInvoices () {
return this .invoices. filter (( i ) => i.status === 'Overdue' );
},
get overdueBillCount () {
return this .bills. filter (( b ) => b.status === 'Overdue' ). length ;
},
get lowStockItems () {
return this .inventory. filter (( i ) => i.stock <= i.reorderLevel);
},
logActivity ( icon , text ) {
this .activity. unshift ({ id: 'A-' + ( this .activity. length + 7 ), icon, text, time: 'Just now' });
},
// ---- inbox ----
markRead ( id ) {
const message = this .messages. find (( m ) => m.id === id);
if (message) message.unread = false ;
},
markAllRead () {
this .messages. forEach (( m ) => (m.unread = false ));
},
archiveMessage ( id ) {
this .messages = this .messages. filter (( m ) => m.id !== id);
this . toast ( 'Message archived' , '' , 'information' );
},
// ---- approvals ----
approve ( id ) {
const approval = this .approvals. find (( a ) => a.id === id);
if ( ! approval) return ;
approval.status = 'Approved' ;
approval.currentStep = approval.steps. length ;
this . logActivity ( 'stamp' , `${ approval . id } ${ approval . title } approved.` );
this . toast ( 'Request approved' , `${ approval . id } has been approved and the requester was notified.` , 'positive' );
},
reject ( id ) {
const approval = this .approvals. find (( a ) => a.id === id);
if ( ! approval) return ;
approval.status = 'Rejected' ;
this . logActivity ( 'circle-x' , `${ approval . id } ${ approval . title } rejected.` );
this . toast ( 'Request rejected' , `${ approval . id } has been rejected and the requester was notified.` , 'negative' );
},
// ---- invoices ----
invoiceById ( id ) {
return this .invoices. find (( i ) => i.id === id);
},
addInvoice ( draft , send ) {
const nextNumber = Math. max ( ... this .invoices. map (( i ) => Number (i.id. slice ( 4 )))) + 1 ;
const subtotal = draft.qty * draft.unitPrice;
const invoice = {
id: 'INV-' + nextNumber,
customer: draft.customer,
issued: '2026-07-03' ,
due: draft.due,
amount: Math. round (subtotal * 1.1 * 100 ) / 100 ,
status: send ? 'Sent' : 'Draft' ,
items: [{ description: draft.description, qty: draft.qty, unitPrice: draft.unitPrice }],
activity: [{ date: '2026-07-03' , text: 'Draft created.' }],
};
if (send) invoice.activity. push ({ date: '2026-07-03' , text: `Sent to ${ draft . customer }.` });
this .invoices. unshift (invoice);
this . logActivity ( 'file-plus-2' , `${ invoice . id } created for ${ invoice . customer }.` );
this . toast (send ? 'Invoice sent' : 'Draft saved' , `${ invoice . id } for ${ this . money ( invoice . amount ) } was ${ send ? 'created and sent to ' + invoice . customer : 'saved as a draft'}.` , 'positive' );
return invoice.id;
},
setInvoiceStatus ( id , status ) {
const invoice = this . invoiceById (id);
if ( ! invoice) return ;
invoice.status = status;
if (status === 'Paid' ) {
invoice.activity. push ({ date: '2026-07-03' , text: 'Payment received, invoice closed.' });
this . logActivity ( 'circle-check' , `Payment received for ${ invoice . id } from ${ invoice . customer }.` );
this . toast ( 'Invoice paid' , `${ invoice . id } (${ this . money ( invoice . amount ) }) has been marked as paid.` , 'positive' );
} else if (status === 'Sent' ) {
invoice.activity. push ({ date: '2026-07-03' , text: `Sent to ${ invoice . customer }.` });
this . logActivity ( 'send' , `${ invoice . id } sent to ${ invoice . customer }.` );
this . toast ( 'Invoice sent' , `${ invoice . id } was sent to ${ invoice . customer }.` , 'positive' );
}
},
remindInvoice ( id ) {
const invoice = this . invoiceById (id);
if ( ! invoice) return ;
invoice.activity. push ({ date: '2026-07-03' , text: 'Payment reminder sent.' });
this . toast ( 'Reminder sent' , `A payment reminder for ${ invoice . id } was sent to ${ invoice . customer }.` , 'information' );
},
// ---- bills ----
payBill ( id ) {
const bill = this .bills. find (( b ) => b.id === id);
if ( ! bill) return ;
bill.status = 'Paid' ;
this . logActivity ( 'banknote' , `${ bill . id } (${ bill . vendor }) paid.` );
this . toast ( 'Bill paid' , `${ bill . id } from ${ bill . vendor } (${ this . money ( bill . amount ) }) was scheduled for payment today.` , 'positive' );
},
// ---- vendors ----
addVendor ( draft ) {
this .vendors. unshift ({ id: 'V-' + String ( this .vendors. length + 1 ). padStart ( 2 , '0' ), rating: 0 , active: true , ... draft });
this . logActivity ( 'user-plus' , `Vendor ${ draft . name } added to the vendor list.` );
this . toast ( 'Vendor added' , `${ draft . name } is now available for purchase orders and bills.` , 'positive' );
},
toggleVendor ( id ) {
const vendor = this .vendors. find (( v ) => v.id === id);
if ( ! vendor) return ;
vendor.active = ! vendor.active;
this . toast (vendor.active ? 'Vendor activated' : 'Vendor deactivated' , `${ vendor . name } is now ${ vendor . active ? 'active' : 'inactive'}.` , 'information' );
},
// ---- inventory ----
adjustStock ( sku , quantity ) {
const item = this .inventory. find (( i ) => i.sku === sku);
if ( ! item || ! quantity) return ;
item.stock += quantity;
this . logActivity ( 'package' , `${ item . name } restocked at ${ item . warehouse } warehouse (+${ quantity }).` );
this . toast ( 'Restock ordered' , `${ quantity } x ${ item . name } added to the ${ item . warehouse } warehouse.` , 'positive' );
},
// ---- documents ----
addDocument ( file ) {
const extension = (file.name. split ( '.' ). pop () || '' ). toLowerCase ();
const types = { pdf: 'Report' , xlsx: 'Report' , csv: 'Report' , docx: 'Contract' , doc: 'Contract' , png: 'Image' , jpg: 'Image' , svg: 'Image' };
const size = file.size >= 1048576 ? (file.size / 1048576 ). toFixed ( 1 ) + ' MB' : Math. max ( 1 , Math. round (file.size / 1024 )) + ' KB' ;
this .documents. unshift ({ id: 'DOC-' + ( 32 + this .documents. length ), name: file.name, type: types[extension] || 'File' , size, linkedTo: 'Uploads' , uploaded: '2026-07-03' , tags: [ 'uploaded' ] });
},
removeDocument ( id ) {
const document = this .documents. find (( d ) => d.id === id);
this .documents = this .documents. filter (( d ) => d.id !== id);
if (document) this . toast ( 'Document deleted' , `${ document . name } was removed from the library.` , 'information' );
},
// ---- appearance ----
setTheme ( mode ) {
this .settings.theme = mode;
Harmonia. setColorScheme (mode);
},
});
// ---------------------------------------------------------------------------
// App shell: sidebar, toolbar, breadcrumb, routing chrome.
// ---------------------------------------------------------------------------
const routeTitles = {
'/' : 'Dashboard' ,
'/inbox' : 'Inbox' ,
'/approvals' : 'Approvals' ,
'/invoices' : 'Invoices' ,
'/bills' : 'Bills' ,
'/customers' : 'Customers' ,
'/vendors' : 'Vendors' ,
'/inventory' : 'Inventory' ,
'/documents' : 'Documents' ,
'/reports' : 'Reports' ,
'/settings' : 'Settings' ,
};
Alpine. data ( 'AppShell' , () => ({
routeLoading: false ,
showSidebarSheet: false ,
isSmallScreen: false ,
notificationListShown: false ,
searchQuery: '' ,
sidebarBreakpointListener: undefined ,
get path () {
return this .$router.context.path;
},
get crumbs () {
if ( this .path. startsWith ( '/invoices/' )) {
return [{ label: 'Invoices' , path: '/invoices' }, { label: this .$router.context.params.id || 'Invoice' }];
}
return [{ label: routeTitles[ this .path] || 'Not found' }];
},
isActive ( path ) {
return this .path === path || this .path. startsWith (path + '/' );
},
go ( path ) {
this .showSidebarSheet = false ;
this .notificationListShown = false ;
this .$router. navigate (path);
},
globalSearch () {
const query = this .searchQuery. trim ();
if ( ! query) return ;
this .$store.erp.searchSeed = query;
this .searchQuery = '' ;
this . go ( '/invoices' );
},
onRouteStart () {
this .routeLoading = true ;
},
onRouteEnd () {
this .routeLoading = false ;
this .showSidebarSheet = false ;
if ( this .$refs.pageScroll) this .$refs.pageScroll.scrollTop = 0 ;
const title = this .crumbs[ this .crumbs. length - 1 ]?.label;
document.title = (title ? title + ' | ' : '' ) + 'Granite ERP' ;
},
onFetchError () {
this .routeLoading = false ;
this .$store.erp. toast ( 'Page failed to load' , 'The page template could not be fetched. Check the network connection and try again.' , 'negative' );
},
// Replaces Pinecone Router's default notfound handler, which logs a console
// error for every unknown path; the 404 page is all the feedback we need.
notFound () {},
removeNotification ( id ) {
this .$store.erp.notifications = this .$store.erp.notifications. filter (( n ) => n.id !== id);
},
clearNotifications () {
this .$store.erp.notifications = [];
this .notificationListShown = false ;
},
init () {
this .$store.erp. attachToaster ( this .$notifications);
this .sidebarBreakpointListener = Harmonia. getBreakpointListener (( matches ) => {
this .isSmallScreen = matches;
if (matches) {
this .$refs.sidebarSheet. appendChild ( this .$refs.sidebar);
} else if ( this .$refs.sidebarSheet.firstElementChild) {
this .showSidebarSheet = false ;
this .$el. appendChild ( this .$refs.sidebar);
}
}, 1024 );
},
destroy () {
this .sidebarBreakpointListener. remove ();
},
}));
// ---------------------------------------------------------------------------
// Page controllers. One per route; fragments reference them via x-data.
// Local UI state (filters, sort, dialogs) lives here and intentionally resets
// when the user navigates away, while real data lives in the erp store.
// ---------------------------------------------------------------------------
Alpine. data ( 'pageDashboard' , () => ({
period: '6m' ,
get revenueChart () {
return this .period === '12m' ? window.GraniteData.charts.revenueFullYear : window.GraniteData.charts.revenueHalfYear;
},
spendChart: window.GraniteData.charts.spendByCategory,
get kpis () {
const erp = this .$store.erp;
return [
{ label: 'Outstanding' , value: erp. money (erp.openInvoiceTotal), hint: erp.openInvoices. length + ' open invoices' , icon: 'wallet' },
{ label: 'Overdue invoices' , value: String (erp.overdueInvoices. length ), hint: erp. money (erp.overdueInvoices. reduce (( sum , i ) => sum + i.amount, 0 )) + ' past due' , icon: 'clock-alert' },
{ label: 'Pending approvals' , value: String (erp.pendingApprovals. length ), hint: 'waiting on you' , icon: 'stamp' },
{ label: 'Low stock items' , value: String (erp.lowStockItems. length ), hint: 'below reorder level' , icon: 'package-open' },
];
},
get recentInvoices () {
return this .$store.erp.invoices. slice ( 0 , 5 );
},
}));
Alpine. data ( 'pageInbox' , () => ({
selectedId: null ,
filter: 'all' ,
// Below the breakpoint the inbox split shows either the message list or the
// reading pane (data-hidden bindings in inbox.html), not both side by side.
isCompact: false ,
init () {
this .compactBreakpointListener = Harmonia. getBreakpointListener (( matches ) => {
this .isCompact = matches;
}, 1024 );
},
destroy () {
this .compactBreakpointListener. remove ();
},
get list () {
const messages = this .$store.erp.messages;
return this .filter === 'unread' ? messages. filter (( m ) => m.unread) : messages;
},
get selected () {
return this .$store.erp.messages. find (( m ) => m.id === this .selectedId);
},
open ( id ) {
this .selectedId = id;
this .$store.erp. markRead (id);
},
archive ( id ) {
if ( this .selectedId === id) this .selectedId = null ;
this .$store.erp. archiveMessage (id);
},
}));
Alpine. data ( 'pageApprovals' , () => ({
filter: 'Pending' ,
expandedId: null ,
get list () {
const approvals = this .$store.erp.approvals;
if ( this .filter === 'Pending' ) return approvals. filter (( a ) => a.status === 'Pending' );
if ( this .filter === 'Resolved' ) return approvals. filter (( a ) => a.status !== 'Pending' );
return approvals;
},
toggle ( id ) {
this .expandedId = this .expandedId === id ? null : id;
},
}));
Alpine. data ( 'pageInvoices' , () => ({
search: '' ,
status: 'All' ,
statuses: [ 'All' , 'Draft' , 'Sent' , 'Paid' , 'Overdue' ],
sortKey: 'issued' ,
sortAsc: false ,
page: 1 ,
pageSize: 8 ,
showNew: false ,
newInvoice: { customer: '' , due: '' , description: '' , qty: 1 , unitPrice: null },
newInvoiceValid: false ,
init () {
const erp = this .$store.erp;
if (erp.searchSeed) {
this .search = erp.searchSeed;
erp.searchSeed = '' ;
}
},
get filtered () {
const query = this .search. trim (). toLowerCase ();
return this .$store.erp.invoices. filter (( invoice ) => {
const matchesQuery = ! query || invoice.id. toLowerCase (). includes (query) || invoice.customer. toLowerCase (). includes (query);
const matchesStatus = this .status === 'All' || invoice.status === this .status;
return matchesQuery && matchesStatus;
});
},
get sorted () {
const direction = this .sortAsc ? 1 : - 1 ;
return [ ... this .filtered]. sort (( a , b ) => {
const left = a[ this .sortKey];
const right = b[ this .sortKey];
return ( typeof left === 'number' ? left - right : String (left). localeCompare ( String (right))) * direction;
});
},
get pageCount () {
return Math. max ( 1 , Math. ceil ( this .sorted. length / this .pageSize));
},
get paged () {
const page = Math. min ( this .page, this .pageCount);
return this .sorted. slice ((page - 1 ) * this .pageSize, page * this .pageSize);
},
sortBy ( key ) {
if ( this .sortKey === key) this .sortAsc = ! this .sortAsc;
else {
this .sortKey = key;
this .sortAsc = true ;
}
},
ariaSort ( key ) {
if ( this .sortKey !== key) return 'none' ;
return this .sortAsc ? 'ascending' : 'descending' ;
},
get newInvoiceTotal () {
return Math. round (( this .newInvoice.qty || 0 ) * ( this .newInvoice.unitPrice || 0 ) * 1.1 * 100 ) / 100 ;
},
openNew () {
this .newInvoice = { customer: '' , due: '' , description: '' , qty: 1 , unitPrice: null };
// Clear any :user-invalid state from a previous attempt so errors do not
// show on a freshly opened, empty form.
this .$refs.newInvoiceForm?. reset ();
this .newInvoiceValid = this .$refs.newInvoiceForm?. checkValidity () ?? false ;
this .showNew = true ;
},
submitNew ( send ) {
const id = this .$store.erp. addInvoice ({ ... this .newInvoice, qty: Number ( this .newInvoice.qty), unitPrice: Number ( this .newInvoice.unitPrice) }, send);
this .showNew = false ;
this .$router. navigate ( '/invoices/' + id);
},
}));
Alpine. data ( 'pageInvoiceDetail' , () => ({
get invoice () {
return this .$store.erp. invoiceById ( this .$params.id);
},
get customer () {
return this .$store.erp.customers. find (( c ) => c.name === this .invoice?.customer);
},
get lifecycleStep () {
return { Draft: 1 , Sent: 2 , Overdue: 2 , Paid: 3 }[ this .invoice?.status] || 1 ;
},
get subtotal () {
return ( this .invoice?.items || []). reduce (( sum , item ) => sum + item.qty * item.unitPrice, 0 );
},
get tax () {
return Math. round ( this .subtotal * 0.1 * 100 ) / 100 ;
},
}));
Alpine. data ( 'pageBills' , () => ({
status: 'All' ,
statuses: [ 'All' , 'Due' , 'Scheduled' , 'Overdue' , 'Paid' ],
vendor: 'all' ,
payTarget: null ,
get vendorNames () {
return [ ...new Set ( this .$store.erp.bills. map (( b ) => b.vendor))];
},
get list () {
return this .$store.erp.bills. filter (( bill ) => ( this .status === 'All' || bill.status === this .status) && ( this .vendor === 'all' || bill.vendor === this .vendor));
},
confirmPay () {
this .$store.erp. payBill ( this .payTarget.id);
this .payTarget = null ;
},
}));
Alpine. data ( 'pageCustomers' , () => ({
search: '' ,
segment: 'all' ,
view: 'grid' ,
selected: null ,
get list () {
const query = this .search. trim (). toLowerCase ();
return this .$store.erp.customers. filter (( customer ) => {
const matchesQuery = ! query || customer.name. toLowerCase (). includes (query) || customer.contact. toLowerCase (). includes (query) || customer.city. toLowerCase (). includes (query);
const matchesSegment = this .segment === 'all' || customer.segment === this .segment;
return matchesQuery && matchesSegment;
});
},
invoicesFor ( name ) {
return this .$store.erp.invoices. filter (( invoice ) => invoice.customer === name);
},
openInvoice ( id ) {
this .selected = null ;
this .$router. navigate ( '/invoices/' + id);
},
}));
Alpine. data ( 'pageVendors' , () => ({
search: '' ,
showAdd: false ,
newVendor: { name: '' , category: '' , contact: '' , email: '' , city: '' },
newVendorValid: false ,
get list () {
const query = this .search. trim (). toLowerCase ();
return this .$store.erp.vendors. filter (( vendor ) => ! query || vendor.name. toLowerCase (). includes (query) || vendor.category. toLowerCase (). includes (query) || vendor.contact. toLowerCase (). includes (query));
},
openAdd () {
this .newVendor = { name: '' , category: '' , contact: '' , email: '' , city: '' };
this .$refs.newVendorForm?. reset ();
this .newVendorValid = this .$refs.newVendorForm?. checkValidity () ?? false ;
this .showAdd = true ;
},
submitAdd () {
this .$store.erp. addVendor ({ ... this .newVendor });
this .showAdd = false ;
},
}));
Alpine. data ( 'pageInventory' , () => ({
warehouse: null ,
category: null ,
restockTarget: null ,
restockQty: 25 ,
// Below the breakpoint the inventory split shows either the warehouse tree
// or the stock table (data-hidden bindings in inventory.html); treeOpen
// switches between them.
isCompact: false ,
treeOpen: false ,
init () {
this .compactBreakpointListener = Harmonia. getBreakpointListener (( matches ) => {
this .isCompact = matches;
}, 1024 );
},
destroy () {
this .compactBreakpointListener. remove ();
},
get tree () {
const warehouses = new Map ();
this .$store.erp.inventory. forEach (( item ) => {
if ( ! warehouses. has (item.warehouse)) warehouses. set (item.warehouse, new Set ());
warehouses. get (item.warehouse). add (item.category);
});
return [ ... warehouses. entries ()]. map (([ name , categories ]) => ({ name, categories: [ ... categories]. sort () }));
},
get list () {
return this .$store.erp.inventory. filter (( item ) => ( ! this .warehouse || item.warehouse === this .warehouse) && ( ! this .category || item.category === this .category));
},
get filterLabel () {
if ( ! this .warehouse) return 'All warehouses' ;
return this .category ? this .warehouse + ' / ' + this .category : this .warehouse;
},
pick ( warehouse , category = null ) {
this .warehouse = warehouse;
this .category = category;
this .treeOpen = false ;
},
stockPercent ( item ) {
return Math. min ( 100 , Math. round ((item.stock / (item.reorderLevel * 2 )) * 100 ));
},
stockVariant ( item ) {
if (item.stock <= item.reorderLevel) return 'negative' ;
return item.stock <= item.reorderLevel * 1.5 ? 'warning' : 'positive' ;
},
askRestock ( item ) {
this .restockQty = 25 ;
this .restockTarget = item;
},
confirmRestock () {
this .$store.erp. adjustStock ( this .restockTarget.sku, Number ( this .restockQty) || 0 );
this .restockTarget = null ;
},
}));
Alpine. data ( 'pageDocuments' , () => ({
type: 'all' ,
scanning: false ,
scanTimeout: undefined ,
removeTarget: null ,
get types () {
return [ ...new Set ( this .$store.erp.documents. map (( d ) => d.type))]. sort ();
},
get list () {
return this .$store.erp.documents. filter (( d ) => this .type === 'all' || d.type === this .type);
},
typeIcon ( type ) {
return { Contract: 'file-signature' , Report: 'file-chart-column' , Invoice: 'receipt-text' , Policy: 'shield-check' , Checklist: 'list-checks' , Image: 'file-image' }[type] || 'file' ;
},
upload ( event ) {
const files = [ ... event.target.files];
if ( ! files. length ) return ;
files. forEach (( file ) => this .$store.erp. addDocument (file));
this .$store.erp. toast (files. length === 1 ? 'Document uploaded' : files. length + ' documents uploaded' , 'Available in the library under the Uploads tag.' , 'positive' );
event.target.value = '' ;
},
scan () {
this .scanning = true ;
this .scanTimeout = setTimeout (() => {
this .scanning = false ;
this .$store.erp. toast ( 'Library scan complete' , 'All documents are indexed and searchable.' , 'positive' );
}, 1400 );
},
confirmRemove () {
this .$store.erp. removeDocument ( this .removeTarget.id);
this .removeTarget = null ;
},
destroy () {
clearTimeout ( this .scanTimeout);
},
}));
Alpine. data ( 'pageReports' , () => ({
tab: 'revenue' ,
range: '6m' ,
agingChart: window.GraniteData.charts.invoiceAging,
cashflowChart: window.GraniteData.charts.cashflow,
daysToPayChart: window.GraniteData.charts.daysToPay,
get revenueChart () {
return this .range === '12m' ? window.GraniteData.charts.revenueFullYear : window.GraniteData.charts.revenueHalfYear;
},
get stats () {
const erp = this .$store.erp;
return [
{ label: 'Invoiced, last 6 months' , value: erp. money ( 1236000 ) },
{ label: 'Outstanding right now' , value: erp. money (erp.openInvoiceTotal) },
{ label: 'Average days to pay' , value: '19.5' },
{ label: 'Open approval requests' , value: String (erp.pendingApprovals. length ) },
];
},
exportReport () {
this .$store.erp. toast ( 'Export started' , 'The report will be emailed to finance@granite.example as a spreadsheet.' , 'information' );
},
}));
Alpine. data ( 'pageSettings' , () => ({
showReset: false ,
save () {
this .$store.erp. toast ( 'Settings saved' , 'Company profile and preferences have been updated.' , 'positive' );
},
confirmReset () {
this .showReset = false ;
this .$store.erp. reset ();
},
}));
}); js // Granite ERP demo dataset. Static and seeded so every visit shows the same
// believable workspace; the Alpine store in app.js clones these collections
// and mutates the copies as the user interacts with the demo.
window.GraniteData = {
invoices: [
{
id: 'INV-1052' ,
customer: 'Northwind Retail' ,
issued: '2026-07-01' ,
due: '2026-07-31' ,
amount: 10208 ,
status: 'Sent' ,
items: [
{ description: 'GX-200 controller unit' , qty: 4 , unitPrice: 1450 },
{ description: 'Sensor array S-12' , qty: 6 , unitPrice: 380 },
{ description: 'Commissioning service' , qty: 1 , unitPrice: 1200 },
],
activity: [
{ date: '2026-07-01' , text: 'Invoice created from sales order SO-3311.' },
{ date: '2026-07-01' , text: 'Sent to billing@northwindretail.example.' },
],
},
{
id: 'INV-1051' ,
customer: 'Summit Medical Group' ,
issued: '2026-06-28' ,
due: '2026-07-28' ,
amount: 8360 ,
status: 'Sent' ,
items: [
{ description: 'Environment monitor EM-5' , qty: 10 , unitPrice: 520 },
{ description: 'Annual support plan' , qty: 1 , unitPrice: 2400 },
],
activity: [
{ date: '2026-06-28' , text: 'Invoice created.' },
{ date: '2026-06-28' , text: 'Sent to accounts@summitmedical.example.' },
],
},
{
id: 'INV-1050' ,
customer: 'Aurora Foods' ,
issued: '2026-06-25' ,
due: '2026-07-25' ,
amount: 4070 ,
status: 'Sent' ,
items: [
{ description: 'Cold chain logger' , qty: 20 , unitPrice: 145 },
{ description: 'Calibration service' , qty: 2 , unitPrice: 400 },
],
activity: [
{ date: '2026-06-25' , text: 'Invoice created.' },
{ date: '2026-06-25' , text: 'Sent to finance@aurorafoods.example.' },
],
},
{
id: 'INV-1049' ,
customer: 'Kite Analytics' ,
issued: '2026-06-21' ,
due: '2026-07-05' ,
amount: 3091 ,
status: 'Paid' ,
items: [
{ description: 'Edge gateway EG-1' , qty: 2 , unitPrice: 980 },
{ description: 'Training day' , qty: 1 , unitPrice: 850 },
],
activity: [
{ date: '2026-06-21' , text: 'Invoice created.' },
{ date: '2026-06-21' , text: 'Sent to ap@kiteanalytics.example.' },
{ date: '2026-07-02' , text: 'Payment received by bank transfer.' },
],
},
{
id: 'INV-1048' ,
customer: 'Helios Energy' ,
issued: '2026-06-18' ,
due: '2026-07-18' ,
amount: 25542 ,
status: 'Sent' ,
items: [
{ description: 'GX-200 controller unit' , qty: 12 , unitPrice: 1450 },
{ description: 'Enclosure IP67' , qty: 12 , unitPrice: 210 },
{ description: 'On-site installation' , qty: 3 , unitPrice: 1100 },
],
activity: [
{ date: '2026-06-18' , text: 'Invoice created from project HE-Substation-4.' },
{ date: '2026-06-18' , text: 'Sent to invoices@heliosenergy.example.' },
],
},
{
id: 'INV-1047' ,
customer: 'Cedar & Stone Interiors' ,
issued: '2026-06-15' ,
due: '2026-06-29' ,
amount: 1650 ,
status: 'Overdue' ,
items: [
{ description: 'Smart meter kit' , qty: 3 , unitPrice: 340 },
{ description: 'Support plan (basic)' , qty: 1 , unitPrice: 480 },
],
activity: [
{ date: '2026-06-15' , text: 'Invoice created.' },
{ date: '2026-06-15' , text: 'Sent to hello@cedarandstone.example.' },
{ date: '2026-06-30' , text: 'Payment due date passed, invoice marked overdue.' },
],
},
{
id: 'INV-1046' ,
customer: 'Mistral Apparel' ,
issued: '2026-06-10' ,
due: '2026-07-10' ,
amount: 4422 ,
status: 'Paid' ,
items: [
{ description: 'Sensor array S-12' , qty: 8 , unitPrice: 380 },
{ description: 'Edge gateway EG-1' , qty: 1 , unitPrice: 980 },
],
activity: [
{ date: '2026-06-10' , text: 'Invoice created.' },
{ date: '2026-06-10' , text: 'Sent to compta@mistralapparel.example.' },
{ date: '2026-06-30' , text: 'Payment received by card.' },
],
},
{
id: 'INV-1045' ,
customer: 'Bluepeak Logistics' ,
issued: '2026-06-05' ,
due: '2026-06-19' ,
amount: 5940 ,
status: 'Overdue' ,
items: [
{ description: 'Fleet tracker FT-2' , qty: 15 , unitPrice: 260 },
{ description: 'Dashboard license (annual)' , qty: 1 , unitPrice: 1500 },
],
activity: [
{ date: '2026-06-05' , text: 'Invoice created.' },
{ date: '2026-06-05' , text: 'Sent to invoicing@bluepeak.example.' },
{ date: '2026-06-20' , text: 'Payment due date passed, invoice marked overdue.' },
{ date: '2026-06-26' , text: 'First payment reminder sent.' },
],
},
{
id: 'INV-1044' ,
customer: 'Verdant Farms' ,
issued: '2026-06-02' ,
due: '2026-07-02' ,
amount: 2024 ,
status: 'Overdue' ,
items: [
{ description: 'Soil probe' , qty: 10 , unitPrice: 120 },
{ description: 'Weather station' , qty: 1 , unitPrice: 640 },
],
activity: [
{ date: '2026-06-02' , text: 'Invoice created.' },
{ date: '2026-06-02' , text: 'Sent to office@verdantfarms.example.' },
{ date: '2026-07-03' , text: 'Payment due date passed, invoice marked overdue.' },
],
},
{
id: 'INV-1043' ,
customer: 'Northwind Retail' ,
issued: '2026-05-26' ,
due: '2026-06-25' ,
amount: 4180 ,
status: 'Paid' ,
items: [
{ description: 'Shelf monitor' , qty: 25 , unitPrice: 96 },
{ description: 'Integration service' , qty: 2 , unitPrice: 700 },
],
activity: [
{ date: '2026-05-26' , text: 'Invoice created.' },
{ date: '2026-05-26' , text: 'Sent to billing@northwindretail.example.' },
{ date: '2026-06-20' , text: 'Payment received by bank transfer.' },
],
},
{
id: 'INV-1042' ,
customer: 'Helios Energy' ,
issued: '2026-05-20' ,
due: '2026-06-19' ,
amount: 10846 ,
status: 'Paid' ,
items: [
{ description: 'GX-100 controller unit' , qty: 6 , unitPrice: 1150 },
{ description: 'Enclosure IP67' , qty: 6 , unitPrice: 210 },
{ description: 'Training day' , qty: 2 , unitPrice: 850 },
],
activity: [
{ date: '2026-05-20' , text: 'Invoice created from project HE-Substation-3.' },
{ date: '2026-05-20' , text: 'Sent to invoices@heliosenergy.example.' },
{ date: '2026-06-12' , text: 'Payment received by bank transfer.' },
],
},
{
id: 'INV-1041' ,
customer: 'Summit Medical Group' ,
issued: '2026-05-14' ,
due: '2026-06-13' ,
amount: 4752 ,
status: 'Paid' ,
items: [
{ description: 'Environment monitor EM-5' , qty: 6 , unitPrice: 520 },
{ description: 'Calibration service' , qty: 3 , unitPrice: 400 },
],
activity: [
{ date: '2026-05-14' , text: 'Invoice created.' },
{ date: '2026-05-14' , text: 'Sent to accounts@summitmedical.example.' },
{ date: '2026-06-01' , text: 'Payment received by bank transfer.' },
],
},
{
id: 'INV-1040' ,
customer: 'Aurora Foods' ,
issued: '2026-05-08' ,
due: '2026-06-07' ,
amount: 6380 ,
status: 'Paid' ,
items: [{ description: 'Cold chain logger' , qty: 40 , unitPrice: 145 }],
activity: [
{ date: '2026-05-08' , text: 'Invoice created.' },
{ date: '2026-05-08' , text: 'Sent to finance@aurorafoods.example.' },
{ date: '2026-05-28' , text: 'Payment received by bank transfer.' },
],
},
{
id: 'INV-1039' ,
customer: 'Kite Analytics' ,
issued: '2026-07-02' ,
due: '2026-08-01' ,
amount: 2728 ,
status: 'Draft' ,
items: [
{ description: 'Dashboard license (annual)' , qty: 1 , unitPrice: 1500 },
{ description: 'Edge gateway EG-1' , qty: 1 , unitPrice: 980 },
],
activity: [{ date: '2026-07-02' , text: 'Draft created.' }],
},
{
id: 'INV-1038' ,
customer: 'Bluepeak Logistics' ,
issued: '2026-07-02' ,
due: '2026-08-01' ,
amount: 2860 ,
status: 'Draft' ,
items: [{ description: 'Fleet tracker FT-2' , qty: 10 , unitPrice: 260 }],
activity: [{ date: '2026-07-02' , text: 'Draft created.' }],
},
{
id: 'INV-1037' ,
customer: 'Mistral Apparel' ,
issued: '2026-07-03' ,
due: '2026-08-02' ,
amount: 2398 ,
status: 'Draft' ,
items: [
{ description: 'Smart meter kit' , qty: 5 , unitPrice: 340 },
{ description: 'Support plan (basic)' , qty: 1 , unitPrice: 480 },
],
activity: [{ date: '2026-07-03' , text: 'Draft created.' }],
},
],
bills: [
{ id: 'BILL-2110' , vendor: 'Atlas Freight' , category: 'Logistics' , due: '2026-07-08' , amount: 1840 , status: 'Due' },
{ id: 'BILL-2109' , vendor: 'Nimbus Cloud Services' , category: 'Software' , due: '2026-07-15' , amount: 1290 , status: 'Scheduled' },
{ id: 'BILL-2108' , vendor: 'Ironwood Materials' , category: 'Raw materials' , due: '2026-07-06' , amount: 7420 , status: 'Due' },
{ id: 'BILL-2107' , vendor: 'Beacon Utilities' , category: 'Utilities' , due: '2026-06-28' , amount: 960 , status: 'Overdue' },
{ id: 'BILL-2106' , vendor: 'PaperTrail Office Supply' , category: 'Office' , due: '2026-07-20' , amount: 312 , status: 'Scheduled' },
{ id: 'BILL-2105' , vendor: 'Vantage Media' , category: 'Marketing' , due: '2026-07-25' , amount: 2500 , status: 'Scheduled' },
{ id: 'BILL-2104' , vendor: 'Quartzline Components' , category: 'Raw materials' , due: '2026-06-20' , amount: 5980 , status: 'Paid' },
{ id: 'BILL-2103' , vendor: 'Skyline Facilities' , category: 'Facilities' , due: '2026-06-15' , amount: 1750 , status: 'Paid' },
{ id: 'BILL-2102' , vendor: 'Atlas Freight' , category: 'Logistics' , due: '2026-06-10' , amount: 2210 , status: 'Paid' },
{ id: 'BILL-2101' , vendor: 'Nimbus Cloud Services' , category: 'Software' , due: '2026-06-15' , amount: 1290 , status: 'Paid' },
],
customers: [
{ id: 'C-001' , name: 'Northwind Retail' , initials: 'NR' , contact: 'Petra Nilsson' , email: 'billing@northwindretail.example' , city: 'Seattle' , country: 'United States' , segment: 'Enterprise' , revenue: 64180 },
{ id: 'C-002' , name: 'Aurora Foods' , initials: 'AF' , contact: 'Mads Sorensen' , email: 'finance@aurorafoods.example' , city: 'Copenhagen' , country: 'Denmark' , segment: 'Mid-market' , revenue: 28450 },
{ id: 'C-003' , name: 'Helios Energy' , initials: 'HE' , contact: 'Diego Ramos' , email: 'invoices@heliosenergy.example' , city: 'Madrid' , country: 'Spain' , segment: 'Enterprise' , revenue: 91320 },
{ id: 'C-004' , name: 'Bluepeak Logistics' , initials: 'BL' , contact: 'Rik van Dam' , email: 'invoicing@bluepeak.example' , city: 'Rotterdam' , country: 'Netherlands' , segment: 'Mid-market' , revenue: 17890 },
{ id: 'C-005' , name: 'Cedar & Stone Interiors' , initials: 'CS' , contact: 'June Park' , email: 'hello@cedarandstone.example' , city: 'Portland' , country: 'United States' , segment: 'Small business' , revenue: 6240 },
{ id: 'C-006' , name: 'Kite Analytics' , initials: 'KA' , contact: 'Lena Hoffmann' , email: 'ap@kiteanalytics.example' , city: 'Berlin' , country: 'Germany' , segment: 'Small business' , revenue: 9870 },
{ id: 'C-007' , name: 'Mistral Apparel' , initials: 'MA' , contact: 'Claire Fontaine' , email: 'compta@mistralapparel.example' , city: 'Lyon' , country: 'France' , segment: 'Mid-market' , revenue: 21510 },
{ id: 'C-008' , name: 'Summit Medical Group' , initials: 'SM' , contact: 'Alan Reyes' , email: 'accounts@summitmedical.example' , city: 'Boston' , country: 'United States' , segment: 'Enterprise' , revenue: 47660 },
{ id: 'C-009' , name: 'Verdant Farms' , initials: 'VF' , contact: 'Elena Georgieva' , email: 'office@verdantfarms.example' , city: 'Plovdiv' , country: 'Bulgaria' , segment: 'Small business' , revenue: 4980 },
],
vendors: [
{ id: 'V-01' , name: 'Atlas Freight' , category: 'Logistics' , contact: 'Omar Haddad' , email: 'billing@atlasfreight.example' , city: 'Hamburg' , rating: 4.5 , active: true },
{ id: 'V-02' , name: 'Nimbus Cloud Services' , category: 'Software' , contact: 'Priya Raman' , email: 'ar@nimbuscloud.example' , city: 'Dublin' , rating: 5 , active: true },
{ id: 'V-03' , name: 'Ironwood Materials' , category: 'Raw materials' , contact: 'Stefan Weiss' , email: 'sales@ironwood.example' , city: 'Graz' , rating: 4 , active: true },
{ id: 'V-04' , name: 'Beacon Utilities' , category: 'Utilities' , contact: 'Service desk' , email: 'accounts@beaconutilities.example' , city: 'Sofia' , rating: 3.5 , active: true },
{ id: 'V-05' , name: 'PaperTrail Office Supply' , category: 'Office' , contact: 'Nina Kova' , email: 'orders@papertrail.example' , city: 'Vienna' , rating: 4 , active: true },
{ id: 'V-06' , name: 'Vantage Media' , category: 'Marketing' , contact: 'Tom Ellis' , email: 'billing@vantagemedia.example' , city: 'London' , rating: 3.5 , active: false },
{ id: 'V-07' , name: 'Quartzline Components' , category: 'Raw materials' , contact: 'Maria Chen' , email: 'invoices@quartzline.example' , city: 'Eindhoven' , rating: 4.5 , active: true },
{ id: 'V-08' , name: 'Skyline Facilities' , category: 'Facilities' , contact: 'Georgi Dimitrov' , email: 'office@skylinefm.example' , city: 'Sofia' , rating: 4 , active: true },
],
inventory: [
{ sku: 'GRN-1001' , name: 'GX-200 controller unit' , category: 'Controllers' , warehouse: 'Central' , stock: 24 , reorderLevel: 10 , unitPrice: 1450 },
{ sku: 'GRN-1002' , name: 'GX-100 controller unit' , category: 'Controllers' , warehouse: 'Central' , stock: 8 , reorderLevel: 12 , unitPrice: 1150 },
{ sku: 'GRN-1003' , name: 'Sensor array S-12' , category: 'Sensors' , warehouse: 'Central' , stock: 96 , reorderLevel: 40 , unitPrice: 380 },
{ sku: 'GRN-1004' , name: 'Cold chain logger' , category: 'Sensors' , warehouse: 'Coastal' , stock: 210 , reorderLevel: 100 , unitPrice: 145 },
{ sku: 'GRN-1005' , name: 'Soil probe' , category: 'Sensors' , warehouse: 'Coastal' , stock: 34 , reorderLevel: 50 , unitPrice: 120 },
{ sku: 'GRN-1006' , name: 'Edge gateway EG-1' , category: 'Gateways' , warehouse: 'Central' , stock: 18 , reorderLevel: 8 , unitPrice: 980 },
{ sku: 'GRN-1007' , name: 'Fleet tracker FT-2' , category: 'Gateways' , warehouse: 'Coastal' , stock: 42 , reorderLevel: 25 , unitPrice: 260 },
{ sku: 'GRN-1008' , name: 'Enclosure IP67' , category: 'Enclosures' , warehouse: 'Central' , stock: 56 , reorderLevel: 20 , unitPrice: 210 },
{ sku: 'GRN-1009' , name: 'Enclosure IP54' , category: 'Enclosures' , warehouse: 'Central' , stock: 11 , reorderLevel: 15 , unitPrice: 160 },
{ sku: 'GRN-1010' , name: 'Smart meter kit' , category: 'Accessories' , warehouse: 'Coastal' , stock: 27 , reorderLevel: 10 , unitPrice: 340 },
{ sku: 'GRN-1011' , name: 'Weather station' , category: 'Sensors' , warehouse: 'Coastal' , stock: 9 , reorderLevel: 6 , unitPrice: 640 },
{ sku: 'GRN-1012' , name: 'Mounting rail set' , category: 'Accessories' , warehouse: 'Central' , stock: 130 , reorderLevel: 40 , unitPrice: 18 },
],
documents: [
{ id: 'DOC-31' , name: 'Helios Energy master agreement.pdf' , type: 'Contract' , size: '1.2 MB' , linkedTo: 'Helios Energy' , uploaded: '2026-06-30' , tags: [ 'legal' , 'signed' ] },
{ id: 'DOC-30' , name: 'Q2 revenue report.xlsx' , type: 'Report' , size: '486 KB' , linkedTo: 'Reports' , uploaded: '2026-06-29' , tags: [ 'finance' , 'quarterly' ] },
{ id: 'DOC-29' , name: 'INV-1048 (Helios Energy).pdf' , type: 'Invoice' , size: '112 KB' , linkedTo: 'INV-1048' , uploaded: '2026-06-18' , tags: [ 'outgoing' ] },
{ id: 'DOC-28' , name: 'Travel expense policy.pdf' , type: 'Policy' , size: '298 KB' , linkedTo: 'Company' , uploaded: '2026-06-12' , tags: [ 'hr' ] },
{ id: 'DOC-27' , name: 'Northwind renewal 2026.pdf' , type: 'Contract' , size: '842 KB' , linkedTo: 'Northwind Retail' , uploaded: '2026-06-05' , tags: [ 'legal' , 'renewal' ] },
{ id: 'DOC-26' , name: 'Vendor onboarding checklist.docx' , type: 'Checklist' , size: '64 KB' , linkedTo: 'Procurement' , uploaded: '2026-05-28' , tags: [ 'process' ] },
{ id: 'DOC-25' , name: 'Warehouse audit summary.pdf' , type: 'Report' , size: '523 KB' , linkedTo: 'Inventory' , uploaded: '2026-05-21' , tags: [ 'operations' ] },
{ id: 'DOC-24' , name: 'BILL-2104 (Quartzline).pdf' , type: 'Invoice' , size: '98 KB' , linkedTo: 'BILL-2104' , uploaded: '2026-05-16' , tags: [ 'incoming' ] },
],
messages: [
{
id: 'M-07' ,
from: 'Maya Lindqvist' ,
initials: 'ML' ,
subject: 'PO-2209 waiting on finance approval' ,
preview: 'Quick heads up that the Quartzline restock is still sitting in the approval queue...' ,
body: [
'Quick heads up that the Quartzline restock (PO-2209) is still sitting in the approval queue. Production needs the controller PCBs by mid July to stay on schedule for the Q3 run.' ,
'Could you take a look today? The request is in Approvals with all quotes attached.' ,
],
date: '2026-07-03 08:42' ,
unread: true ,
},
{
id: 'M-06' ,
from: 'Jonas Weber' ,
initials: 'JW' ,
subject: 'Expense claim receipts attached' ,
preview: 'Receipts for the Helios site visit are attached to the claim, including the...' ,
body: [ 'Receipts for the Helios site visit are attached to the claim, including the train tickets and the hotel invoice. Total comes to $642.80.' , 'Let me know if anything else is needed for reimbursement.' ],
date: '2026-07-02 16:10' ,
unread: true ,
},
{
id: 'M-05' ,
from: 'Petra Nilsson' ,
initials: 'PN' ,
subject: 'Question about INV-1052' ,
preview: 'We received invoice INV-1052 this morning. Could you confirm whether the...' ,
body: [
'We received invoice INV-1052 this morning. Could you confirm whether the commissioning service line covers both store locations or only the flagship site?' ,
'If it is only one site we may need a revised quote for the second rollout.' ,
],
date: '2026-07-02 09:35' ,
unread: true ,
},
{
id: 'M-04' ,
from: 'Atlas Freight billing' ,
initials: 'AF' ,
subject: 'Payment reminder for BILL-2110' ,
preview: 'This is a friendly reminder that invoice BILL-2110 for June shipments is due...' ,
body: [ 'This is a friendly reminder that invoice BILL-2110 for June shipments is due on July 8. Please disregard this message if payment has already been scheduled.' ],
date: '2026-07-01 11:02' ,
unread: false ,
},
{
id: 'M-03' ,
from: 'Ivana Petrova' ,
initials: 'IP' ,
subject: 'Warehouse audit summary' ,
preview: 'The Central warehouse audit is complete. Stock counts matched the system for...' ,
body: [
'The Central warehouse audit is complete. Stock counts matched the system for all but two SKUs; the corrections are already in the summary document under Documents.' ,
'Note that GX-100 controllers and IP54 enclosures are both below their reorder levels.' ,
],
date: '2026-06-30 14:48' ,
unread: false ,
},
{
id: 'M-02' ,
from: 'Diego Ramos' ,
initials: 'DR' ,
subject: 'Installation complete at Substation 4' ,
preview: 'The Granite team wrapped up the installation this morning. All twelve...' ,
body: [ 'The Granite team wrapped up the installation this morning. All twelve controllers are online and reporting. Great work from the field crew, please pass along our thanks.' ],
date: '2026-06-27 17:21' ,
unread: false ,
},
{
id: 'M-01' ,
from: 'Granite ERP digest' ,
initials: 'GE' ,
subject: 'Weekly digest: 4 invoices sent, 2 paid' ,
preview: 'Last week you sent 4 invoices totaling $48,180 and received 2 payments...' ,
body: [ 'Last week you sent 4 invoices totaling $48,180 and received 2 payments totaling $7,513. Three approvals are waiting in your queue.' ],
date: '2026-06-26 07:00' ,
unread: false ,
},
],
approvals: [
{
id: 'AP-118' ,
type: 'Purchase order' ,
title: 'PO-2209 Quartzline Components restock' ,
requester: 'Maya Lindqvist' ,
initials: 'ML' ,
submitted: '2026-07-01' ,
amount: 8140 ,
status: 'Pending' ,
currentStep: 2 ,
steps: [ 'Requested' , 'Manager review' , 'Finance approval' ],
note: 'Restock of GX-100 controller PCBs ahead of the Q3 production run. Quotes from two suppliers attached; Quartzline is 6% cheaper with a shorter lead time.' ,
linkedTo: 'GRN-1002' ,
},
{
id: 'AP-117' ,
type: 'Expense' ,
title: 'Client visit travel, Helios Energy' ,
requester: 'Jonas Weber' ,
initials: 'JW' ,
submitted: '2026-06-30' ,
amount: 642.8 ,
status: 'Pending' ,
currentStep: 2 ,
steps: [ 'Submitted' , 'Manager review' , 'Reimbursement' ],
note: 'Two-day site visit to the Substation 4 installation: train tickets, one hotel night, and client dinner. Receipts attached to the claim.' ,
linkedTo: 'INV-1048' ,
},
{
id: 'AP-116' ,
type: 'Discount' ,
title: 'Loyalty discount for Northwind Retail' ,
requester: 'Sofia Marinova' ,
initials: 'SM' ,
submitted: '2026-06-29' ,
amount: 510 ,
status: 'Pending' ,
currentStep: 3 ,
steps: [ 'Proposed' , 'Sales director' , 'Finance approval' ],
note: 'A 5% loyalty discount on the upcoming second rollout order. Northwind has been a customer for four years with consistent on-time payments.' ,
linkedTo: 'C-001' ,
},
{
id: 'AP-115' ,
type: 'Time off' ,
title: 'Annual leave, August 10 to August 21' ,
requester: 'Ivana Petrova' ,
initials: 'IP' ,
submitted: '2026-06-24' ,
amount: 0 ,
status: 'Approved' ,
currentStep: 3 ,
steps: [ 'Requested' , 'Manager review' , 'Recorded' ],
note: 'Two weeks of annual leave. Warehouse coverage arranged with the Coastal team.' ,
linkedTo: 'HR' ,
},
{
id: 'AP-114' ,
type: 'Purchase order' ,
title: 'PO-2196 Office chairs replacement' ,
requester: 'Boris Kolev' ,
initials: 'BK' ,
submitted: '2026-06-20' ,
amount: 1980 ,
status: 'Rejected' ,
currentStep: 2 ,
steps: [ 'Requested' , 'Manager review' , 'Finance approval' ],
note: 'Replacement of 12 office chairs. Rejected: over the facilities budget for this quarter, resubmit in October.' ,
linkedTo: 'V-05' ,
},
{
id: 'AP-113' ,
type: 'Expense' ,
title: 'Team offsite catering' ,
requester: 'Maya Lindqvist' ,
initials: 'ML' ,
submitted: '2026-06-18' ,
amount: 420 ,
status: 'Approved' ,
currentStep: 3 ,
steps: [ 'Submitted' , 'Manager review' , 'Reimbursement' ],
note: 'Catering for the June planning offsite, 14 attendees.' ,
linkedTo: 'HR' ,
},
],
notifications: [
{
id: 1 ,
unread: true ,
media: { kind: 'text' , text: 'PN' },
title: 'New message from Petra Nilsson' ,
description: 'Question about INV-1052.' ,
action: { label: 'Open inbox' , path: '/inbox' },
},
{
id: 2 ,
unread: true ,
media: { kind: 'badge' , icon: 'circle-warning' , variant: 'warning' },
title: 'Invoice INV-1047 is overdue' ,
description: 'Cedar & Stone Interiors, $1,650 was due on June 29.' ,
action: { label: 'View invoice' , path: '/invoices/INV-1047' },
},
{
id: 3 ,
unread: false ,
media: { kind: 'icon' , icon: 'circle-success' , tone: 'text-positive' },
title: 'Q2 revenue report is ready' ,
action: { label: 'Open reports' , path: '/reports' },
},
],
activity: [
{ id: 'A-06' , icon: 'circle-check' , text: 'Payment received for INV-1049 from Kite Analytics.' , time: 'Today, 09:12' },
{ id: 'A-05' , icon: 'send' , text: 'INV-1052 sent to Northwind Retail.' , time: 'Jul 1, 14:05' },
{ id: 'A-04' , icon: 'stamp' , text: 'Expense claim AP-113 (team offsite catering) approved.' , time: 'Jun 30, 16:40' },
{ id: 'A-03' , icon: 'package' , text: 'Cold chain logger restocked at Coastal warehouse (+120).' , time: 'Jun 30, 11:20' },
{ id: 'A-02' , icon: 'file-text' , text: 'Q2 revenue report generated.' , time: 'Jun 29, 08:00' },
{ id: 'A-01' , icon: 'user-plus' , text: 'Vendor Skyline Facilities added to the vendor list.' , time: 'Jun 27, 10:15' },
],
charts: {
// Amounts are in thousands of dollars.
revenueHalfYear: {
labels: [ 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' ],
series: [
{ name: 'Revenue' , color: 'blue' , data: [ 182 , 196 , 175 , 214 , 228 , 241 ] },
{ name: 'Expenses' , color: 'gray' , data: [ 121 , 128 , 117 , 136 , 142 , 151 ] },
],
},
revenueFullYear: {
labels: [ 'Jul' , 'Aug' , 'Sep' , 'Oct' , 'Nov' , 'Dec' , 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' ],
series: [
{ name: 'Revenue' , color: 'blue' , data: [ 148 , 141 , 166 , 171 , 189 , 204 , 182 , 196 , 175 , 214 , 228 , 241 ] },
{ name: 'Expenses' , color: 'gray' , data: [ 103 , 99 , 112 , 118 , 124 , 139 , 121 , 128 , 117 , 136 , 142 , 151 ] },
],
},
cashflow: {
labels: [ 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' ],
series: [
{ name: 'Cash in' , color: 'green' , data: [ 164 , 178 , 169 , 197 , 210 , 224 ] },
{ name: 'Cash out' , color: 'orange' , data: [ 131 , 138 , 127 , 149 , 155 , 161 ] },
],
},
// Doughnut slice values are percentage shares (they sum to 100).
spendByCategory: {
slices: [
{ label: 'Raw materials' , value: 48 , color: 'blue' },
{ label: 'Logistics' , value: 21 , color: 'teal' },
{ label: 'Marketing' , value: 9 , color: 'purple' },
{ label: 'Software' , value: 9 , color: 'indigo' },
{ label: 'Utilities' , value: 7 , color: 'orange' },
{ label: 'Facilities' , value: 6 , color: 'gray' },
],
},
invoiceAging: {
slices: [
{ label: 'Current' , value: 83 , color: 'green' },
{ label: '1-30 days late' , value: 10 , color: 'yellow' },
{ label: '31-60 days late' , value: 4 , color: 'orange' },
{ label: '60+ days late' , value: 3 , color: 'red' },
],
},
daysToPay: {
labels: [ 'INV-1040' , 'INV-1041' , 'INV-1042' , 'INV-1043' , 'INV-1046' , 'INV-1049' ],
series: [{ name: 'Days to pay' , color: 'blue' , data: [ 20 , 18 , 23 , 25 , 20 , 11 ] }],
},
},
};