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:
LincPay
2026-08-06 13:30:24 +05:30
co-authored by Claude Opus 5
commit f430f85202
55 changed files with 8691 additions and 0 deletions
+159
View File
@@ -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 }
}