chore: initial commit — UdyamLinc UI, phases A-E
Vite/React 19 console and enterprise PWA. Officer console screens, offline ledger and sync, data feeds and trip-wire rules screens with freshness chip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { LedgerEntry, LedgerKind } from './api'
|
||||
import { dayOffsetIso, pickInt, pickOne, rng, seedOf, todayIso } from './demo/seed'
|
||||
|
||||
/*
|
||||
* Offline-first ledger.
|
||||
*
|
||||
* Every entry is written to localStorage first and marked PENDING; a sync pass
|
||||
* flips it to SYNCED. This is what makes the deck's "works with zero
|
||||
* connectivity" claim true in the prototype — the owner never sees a spinner
|
||||
* and never loses a tap.
|
||||
*/
|
||||
|
||||
const KEY = 'ul-ledger'
|
||||
const SYNC_KEY = 'ul-last-sync'
|
||||
|
||||
export const CATEGORIES: Record<LedgerKind, { code: string; en: string; hi: string }[]> = {
|
||||
IN: [
|
||||
{ code: 'SALE', en: 'Sale', hi: 'बिक्री' },
|
||||
{ code: 'UPI', en: 'UPI received', hi: 'UPI आया' },
|
||||
{ code: 'RECOVERY', en: 'Udhaar recovered', hi: 'उधारी वसूली' },
|
||||
{ code: 'OTHER_IN', en: 'Other', hi: 'अन्य' },
|
||||
],
|
||||
OUT: [
|
||||
{ code: 'INPUT', en: 'Raw material / feed', hi: 'कच्चा माल / दाना' },
|
||||
{ code: 'WAGES', en: 'Wages', hi: 'मज़दूरी' },
|
||||
{ code: 'TRANSPORT', en: 'Transport', hi: 'भाड़ा' },
|
||||
{ code: 'EMI', en: 'Instalment', hi: 'किस्त' },
|
||||
{ code: 'OTHER_OUT', en: 'Other', hi: 'अन्य' },
|
||||
],
|
||||
STOCK: [
|
||||
{ code: 'STOCK_IN', en: 'Stock added', hi: 'स्टॉक आया' },
|
||||
{ code: 'STOCK_OUT', en: 'Stock sold', hi: 'स्टॉक गया' },
|
||||
],
|
||||
}
|
||||
|
||||
function read(): LedgerEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
return raw ? (JSON.parse(raw) as LedgerEntry[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function write(entries: LedgerEntry[]) {
|
||||
localStorage.setItem(KEY, JSON.stringify(entries))
|
||||
window.dispatchEvent(new Event('ul-ledger-changed'))
|
||||
}
|
||||
|
||||
/** 30 days of plausible history, generated once per enterprise so the app is never empty. */
|
||||
function seedHistory(enterpriseId: string): LedgerEntry[] {
|
||||
const r = rng(seedOf(`${enterpriseId}:ledger`))
|
||||
const out: LedgerEntry[] = []
|
||||
for (let d = 30; d >= 0; d--) {
|
||||
const entries = pickInt(r, 1, 3)
|
||||
for (let i = 0; i < entries; i++) {
|
||||
const kind: LedgerKind = r() > 0.45 ? 'IN' : 'OUT'
|
||||
const cat = pickOne(r, CATEGORIES[kind])
|
||||
out.push({
|
||||
id: `seed-${enterpriseId}-${d}-${i}`,
|
||||
enterpriseId,
|
||||
kind,
|
||||
amount: kind === 'IN' ? pickInt(r, 6, 52) * 100 : pickInt(r, 4, 38) * 100,
|
||||
category: cat.code,
|
||||
note: null,
|
||||
entryDate: dayOffsetIso(-d),
|
||||
syncState: 'SYNCED',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function loadLedger(enterpriseId: string): LedgerEntry[] {
|
||||
const all = read()
|
||||
const mine = all.filter((e) => e.enterpriseId === enterpriseId)
|
||||
if (mine.length) return mine.sort((a, b) => b.entryDate.localeCompare(a.entryDate))
|
||||
const seeded = seedHistory(enterpriseId)
|
||||
write([...all, ...seeded])
|
||||
return seeded.sort((a, b) => b.entryDate.localeCompare(a.entryDate))
|
||||
}
|
||||
|
||||
/** Three-tap write: amount → category → save. Always succeeds, online or not. */
|
||||
export function addEntry(input: {
|
||||
enterpriseId: string
|
||||
kind: LedgerKind
|
||||
amount: number
|
||||
category: string
|
||||
note?: string
|
||||
}): LedgerEntry {
|
||||
const entry: LedgerEntry = {
|
||||
id: `local-${Date.now()}-${Math.round(Math.random() * 1e6)}`,
|
||||
enterpriseId: input.enterpriseId,
|
||||
kind: input.kind,
|
||||
amount: input.amount,
|
||||
category: input.category,
|
||||
note: input.note ?? null,
|
||||
entryDate: todayIso(),
|
||||
syncState: 'PENDING',
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
write([...read(), entry])
|
||||
return entry
|
||||
}
|
||||
|
||||
export function pendingCount(enterpriseId: string): number {
|
||||
return read().filter((e) => e.enterpriseId === enterpriseId && e.syncState === 'PENDING').length
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the queue. The backend endpoint lands in a later pass — until then this
|
||||
* flips PENDING → SYNCED locally so the UI's sync states are exercisable.
|
||||
*/
|
||||
export function syncPending(enterpriseId: string): number {
|
||||
const all = read()
|
||||
let n = 0
|
||||
const next = all.map((e) => {
|
||||
if (e.enterpriseId === enterpriseId && e.syncState === 'PENDING') {
|
||||
n++
|
||||
return { ...e, syncState: 'SYNCED' as const }
|
||||
}
|
||||
return e
|
||||
})
|
||||
if (n) {
|
||||
write(next)
|
||||
localStorage.setItem(SYNC_KEY, new Date().toISOString())
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export function lastSyncedAt(): string | null {
|
||||
return localStorage.getItem(SYNC_KEY)
|
||||
}
|
||||
|
||||
export function dayTotals(entries: LedgerEntry[], date = todayIso()) {
|
||||
const today = entries.filter((e) => e.entryDate === date)
|
||||
return {
|
||||
in: today.filter((e) => e.kind === 'IN').reduce((a, e) => a + e.amount, 0),
|
||||
out: today.filter((e) => e.kind === 'OUT').reduce((a, e) => a + e.amount, 0),
|
||||
count: today.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** Live ledger for a given enterprise, re-reading whenever an entry is written. */
|
||||
export function useLedger(enterpriseId: string) {
|
||||
const [entries, setEntries] = useState<LedgerEntry[]>(() => loadLedger(enterpriseId))
|
||||
|
||||
const refresh = useCallback(() => setEntries(loadLedger(enterpriseId)), [enterpriseId])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
window.addEventListener('ul-ledger-changed', refresh)
|
||||
return () => window.removeEventListener('ul-ledger-changed', refresh)
|
||||
}, [refresh])
|
||||
|
||||
return { entries, refresh }
|
||||
}
|
||||
Reference in New Issue
Block a user