Compare commits
No commits in common. "master" and "ui-navigation" have entirely different histories.
master
...
ui-navigat
@ -68,8 +68,8 @@ Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) wit
|
||||
`ThemeProvider` that wraps the app in `main.jsx`.
|
||||
|
||||
- **Storage key:** `df_dark` in `localStorage`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')` on first visit
|
||||
- **Toggle:** button at the foot of the sidebar (`Sidebar.jsx`), and in `BottomNav.jsx` on mobile; the effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
||||
- **CSS:** `ui/src/index.css` declares semantic tokens under `@theme` (`bg-surface`, `text-ink`, `text-muted`, `border-line`, `text-danger`, …) that resolve to CSS custom properties redefined by `.dark`. **Write components against the tokens, never against literal shades like `bg-white` or `text-gray-400`** — the old per-utility `.dark .bg-white { … }` overrides are gone and must not come back
|
||||
- **Toggle:** button in the sidebar header in `App.jsx`; effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
||||
- **CSS:** `ui/src/index.css` defines CSS custom properties under `:root` (light) and `.dark`. All Tailwind color overrides are written as `.dark .bg-white { ... }` etc.
|
||||
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`)
|
||||
- **Perspective viewer:** `Pivot.jsx` calls `viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')` on initial load and in a `useEffect([dark])` so the viewer stays in sync when the toggle fires
|
||||
- **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()`
|
||||
|
||||
49
docs/spec.md
49
docs/spec.md
@ -59,14 +59,10 @@ api/
|
||||
ui/
|
||||
src/
|
||||
api.js — fetch wrapper, credential management
|
||||
App.jsx — root: login gate, routing, stale/reprocess banners
|
||||
index.css — semantic colour tokens for light and dark
|
||||
App.jsx — root: login gate, sidebar, source selector, routing
|
||||
pages/
|
||||
Login.jsx — username/password form
|
||||
SourceList.jsx — source list and the create dialog
|
||||
SourceDetail.jsx — one source: connection, fields, view, maintenance
|
||||
Bridge.jsx — SimpleFIN accounts, balances, and subtotals
|
||||
ImportHub.jsx — all sources with sync / upload actions
|
||||
Sources.jsx — source CRUD, field config, view generation
|
||||
Import.jsx — CSV upload, SimpleFIN sync, and import log
|
||||
Rules.jsx — rule CRUD with live pattern preview
|
||||
Mappings.jsx — mapping table with TSV import/export
|
||||
@ -75,7 +71,7 @@ ui/
|
||||
Stacks.jsx — multi-source union views with running balance
|
||||
Remap.jsx — bulk remap of an output field value across mappings
|
||||
Log.jsx — global import log across all sources
|
||||
components/ — Sidebar, BottomNav, navItems, SourceTabs, Section, SampleTable
|
||||
components/ — Sidebar, StatusBar
|
||||
theme.jsx — light/dark context provider
|
||||
public/ — compiled UI (output of npm run build in ui/)
|
||||
docs/ — this file, tutorial, UI and Perspective references
|
||||
@ -331,43 +327,11 @@ Built with React + Vite + Tailwind CSS. Compiled output goes to `public/`. The s
|
||||
3. On 401 response, credentials are cleared and the login screen is shown.
|
||||
4. `localStorage` persists the selected source name across sessions.
|
||||
|
||||
**Navigation.** The selected source is a route parameter, not global state: `/sources`
|
||||
lists sources, `/sources/:name` owns one, and Import, Rules, Mappings, Records, and Pivot
|
||||
are tabs beneath it (`components/SourceTabs.jsx`). The sidebar holds only top-level
|
||||
destinations — Sources, Import, Bridge, Remap, Stacks, Log — defined once in
|
||||
`components/navItems.jsx` and rendered by `Sidebar.jsx` on desktop and `BottomNav.jsx`
|
||||
below the `md:` breakpoint. Out-of-sync and reprocess banners render above every page from
|
||||
`App.jsx`.
|
||||
|
||||
**Colour.** Components use semantic tokens (`bg-surface`, `text-ink`, `text-muted`,
|
||||
`border-line`, `text-danger`) declared in `index.css` under `@theme`, which resolve to CSS
|
||||
variables redefined by `.dark`. There are no per-utility `.dark` override rules; a new
|
||||
component gets both themes for free.
|
||||
|
||||
**Bundle.** `Pivot` is loaded with `React.lazy`, keeping Perspective (~4.7 MB gzipped) out
|
||||
of the initial download and in a chunk fetched only when a pivot is opened.
|
||||
|
||||
**Pages:**
|
||||
|
||||
- **Sources** (`SourceList.jsx`) — Lists every source with its constraint fields and a
|
||||
badge for bank feeds; clicking opens it. "New source" opens the create dialog, which can
|
||||
seed fields from a CSV sample or from a linked SimpleFIN account.
|
||||
- **Sources** — View and edit source configuration. Shows all known field names and their origins (raw data, schema, rules, mappings). Checkboxes control which fields are constraint fields and which appear in the output view. Supports CSV upload to auto-detect fields.
|
||||
|
||||
- **Source detail** (`SourceDetail.jsx`) — The Setup tab, grouped into titled panels:
|
||||
Connection (link/unlink a SimpleFIN account, warns when constraint fields aren't `id`),
|
||||
Fields and view (all known field names and their origins, with checkboxes for constraint
|
||||
fields and view columns), Sample rows, Maintenance (reprocess), and Delete source.
|
||||
|
||||
- **Bridge** (`Bridge.jsx`) — Every account behind the SimpleFIN credential with balances,
|
||||
the source each maps to, and subtotals split into banking versus retirement (recognised by
|
||||
keyword on the account and institution names). Queries SimpleFIN only when Refresh is
|
||||
pressed. Balances render in accounting style with negatives in parentheses.
|
||||
|
||||
- **Import** (`ImportHub.jsx`) — Top-level entry point for the frequent job. Lists every
|
||||
source with record counts and last import date, a Sync button for bank feeds, and an
|
||||
upload link for CSV sources.
|
||||
|
||||
- **Source › Import tab** — Upload a CSV to import records into the selected source. Transformations run automatically on new records. Shows import log with inserted/duplicate counts, expandable key detail, checkbox selection, and delete with confirmation. Sources with `config.simplefin.account_id` also get a Sync panel — a window selector (10/30/45 days or a full backfill) and a "Sync now" button that pulls from the bank API through the same import path.
|
||||
- **Import** — Upload a CSV to import records into the selected source. Transformations run automatically on new records. Shows import log with inserted/duplicate counts, expandable key detail, checkbox selection, and delete with confirmation. Sources with `config.simplefin.account_id` also get a Sync panel — a window selector (10/30/90 days or everything) and a "Sync now" button that pulls from the bank API through the same import path.
|
||||
|
||||
- **Rules** — Create and manage regex rules. Live preview fires automatically (debounced 500ms) as pattern/field/flags are edited, showing match results against real records. Rules can be enabled/disabled by toggle.
|
||||
|
||||
@ -394,8 +358,7 @@ of the initial download and in a chunk fetched only when a pivot is opened.
|
||||
|
||||
See `docs/perspective.md` for the full technical reference on controlling Perspective programmatically.
|
||||
|
||||
- **Stacks** — Named unions of multiple sources, each chip linking to its pivot at
|
||||
`/stacks/:name/pivot`. Each stack defines a field mapping (how source fields map to common output columns), an amount field, a date field, and an optional balance offset. The view-data endpoint unions the underlying source views and computes a running balance sorted by date. The Pivot page supports stacks as well as individual sources, with layouts stored in the same `pivot_layouts` table.
|
||||
- **Stacks** — Named unions of multiple sources. Each stack defines a field mapping (how source fields map to common output columns), an amount field, a date field, and an optional balance offset. The view-data endpoint unions the underlying source views and computes a running balance sorted by date. The Pivot page supports stacks as well as individual sources, with layouts stored in the same `pivot_layouts` table.
|
||||
|
||||
- **Log** — Global import log across all sources. Same expandable key detail and delete capability as the Import page, plus a source name column.
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dataflow</title>
|
||||
<title>ui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useState, useEffect, createElement, lazy, Suspense } from 'react'
|
||||
import { useState, useEffect, createElement } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'
|
||||
import { api, setCredentials, clearCredentials } from './api'
|
||||
import Sidebar from './components/Sidebar.jsx'
|
||||
import BottomNav from './components/BottomNav.jsx'
|
||||
import SourceTabs from './components/SourceTabs.jsx'
|
||||
import Login from './pages/Login'
|
||||
import SourceList from './pages/SourceList'
|
||||
@ -14,7 +13,7 @@ import Rules from './pages/Rules'
|
||||
import Mappings from './pages/Mappings'
|
||||
import Records from './pages/Records'
|
||||
import Log from './pages/Log'
|
||||
const Pivot = lazy(() => import('./pages/Pivot'))
|
||||
import Pivot from './pages/Pivot'
|
||||
import Remap from './pages/Remap'
|
||||
import Stacks from './pages/Stacks'
|
||||
|
||||
@ -135,20 +134,17 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<div className="flex h-screen">
|
||||
|
||||
<div className="hidden md:flex">
|
||||
<Sidebar
|
||||
expanded={sidebarExpanded}
|
||||
setExpanded={setSidebarExpanded}
|
||||
loginUser={loginUser}
|
||||
onLogout={handleLogout}
|
||||
sources={sources}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
|
||||
{(staleSources.size > 0 || staleStacks.size > 0) && (
|
||||
<div className="bg-warn-soft border-b border-warn-line px-4 py-1.5 text-xs text-warn flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<div className="bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="font-medium">View out of sync:</span>
|
||||
{[...staleSources].map(name => (
|
||||
<span key={name} className="flex items-center gap-1">
|
||||
@ -156,20 +152,20 @@ export default function App() {
|
||||
<button
|
||||
onClick={() => handleGenerateSource(name)}
|
||||
disabled={generating[`src:${name}`]}
|
||||
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
|
||||
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{generating[`src:${name}`] ? '…' : 'Generate'}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-warn">|</span>}
|
||||
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-amber-400">|</span>}
|
||||
{[...staleStacks].map(name => (
|
||||
<span key={name} className="flex items-center gap-1">
|
||||
stack: {name}
|
||||
<button
|
||||
onClick={() => handleGenerateStack(name)}
|
||||
disabled={generating[`stk:${name}`]}
|
||||
className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
|
||||
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{generating[`stk:${name}`] ? '…' : 'Generate'}
|
||||
</button>
|
||||
@ -178,7 +174,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{reprocessSources.size > 0 && (
|
||||
<div className="bg-accent-soft border-b border-accent-line px-4 py-1.5 text-xs text-accent flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<div className="bg-blue-50 border-b border-blue-200 px-4 py-1.5 text-xs text-blue-800 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="font-medium">Mappings updated:</span>
|
||||
{[...reprocessSources].map(name => (
|
||||
<span key={name} className="flex items-center gap-1">
|
||||
@ -195,15 +191,13 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto pb-14 md:pb-0">
|
||||
<Suspense fallback={<div className="p-6 text-sm text-muted">Loading…</div>}>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/sources" replace />} />
|
||||
|
||||
<Route path="/sources" element={<SourceList sources={sources} setSources={setSources} setSource={setSource} />} />
|
||||
<Route path="/sources/:name" element={<SourceTabs sources={sources} />}>
|
||||
<Route index element={<Navigate to="records" replace />} />
|
||||
<Route path="setup" element={<SourceDetail sources={sources} setSources={setSources} />} />
|
||||
<Route index element={<SourceDetail sources={sources} setSources={setSources} />} />
|
||||
<Route path="import" element={<ScopedToSource component={Import} />} />
|
||||
<Route path="rules" element={<ScopedToSource component={Rules} onStale={markSourceStale} />} />
|
||||
<Route path="mappings" element={<ScopedToSource component={Mappings} onNeedsReprocess={markNeedsReprocess} />} />
|
||||
@ -218,11 +212,9 @@ export default function App() {
|
||||
<Route path="/remap" element={<Remap />} />
|
||||
<Route path="/log" element={<Log />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import useTheme from '../theme.jsx'
|
||||
import { NAV } from './navItems.jsx'
|
||||
|
||||
// Phone-sized replacement for the sidebar: same destinations, thumb-reachable.
|
||||
// Hidden from md: upward, where the sidebar takes over.
|
||||
export default function BottomNav() {
|
||||
const { dark, setDark } = useTheme()
|
||||
|
||||
return (
|
||||
<nav className="md:hidden fixed bottom-0 inset-x-0 z-20 bg-surface border-t border-line flex justify-around items-stretch h-14 pb-[env(safe-area-inset-bottom)]">
|
||||
{NAV.map(({ to, label, icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] ${
|
||||
isActive ? 'text-accent' : 'text-muted'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setDark(d => !d)}
|
||||
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] text-muted"
|
||||
title={dark ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{dark
|
||||
? <><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/></>
|
||||
: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>}
|
||||
</svg>
|
||||
Theme
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@ -4,19 +4,19 @@ export default function SampleTable({ rows }) {
|
||||
if (!rows || rows.length === 0) return null
|
||||
const cols = Object.keys(rows[0])
|
||||
return (
|
||||
<div className="overflow-auto border border-line-soft rounded bg-raised max-h-36">
|
||||
<div className="overflow-auto border border-gray-100 rounded bg-gray-50 max-h-36">
|
||||
<table className="text-xs w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0">
|
||||
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
|
||||
{cols.map(c => <th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-line-soft">
|
||||
<tr key={i} className="border-t border-gray-100">
|
||||
{cols.map(c => (
|
||||
<td key={c} className="px-2 py-1 whitespace-nowrap text-ink-soft max-w-32 truncate font-mono">
|
||||
{row[c] == null ? <span className="text-muted">—</span> : String(row[c])}
|
||||
<td key={c} className="px-2 py-1 whitespace-nowrap text-gray-600 max-w-32 truncate font-mono">
|
||||
{row[c] == null ? <span className="text-gray-300">—</span> : String(row[c])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
// separate things rather than one flat form
|
||||
export default function Section({ title, description, children }) {
|
||||
return (
|
||||
<section className="bg-surface border border-line rounded p-4">
|
||||
<h2 className="text-sm font-semibold text-ink-soft">{title}</h2>
|
||||
<section className="bg-white border border-gray-200 rounded p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700">{title}</h2>
|
||||
{description
|
||||
? <p className="text-xs text-muted mt-0.5 mb-3">{description}</p>
|
||||
? <p className="text-xs text-gray-400 mt-0.5 mb-3">{description}</p>
|
||||
: <div className="mb-3" />}
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@ -1,47 +1,89 @@
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import useTheme from '../theme.jsx'
|
||||
import { NAV } from './navItems.jsx'
|
||||
|
||||
// Same distinction the Import page makes: a source is either on a bank feed or
|
||||
// it gets CSVs uploaded to it.
|
||||
const feedIcon = (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M4 10.5a4.5 4.5 0 0 1 4.5 4.5"/>
|
||||
<path d="M4 6a9 9 0 0 1 9 9"/>
|
||||
<circle cx="4.2" cy="14.8" r="1.2" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
)
|
||||
const NAV = [
|
||||
{
|
||||
to: '/sources',
|
||||
label: 'Sources',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
|
||||
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
|
||||
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/import',
|
||||
label: 'Import',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="10" y1="3" x2="10" y2="14"/>
|
||||
<polyline points="6,10 10,14 14,10"/>
|
||||
<line x1="3" y1="18" x2="17" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/bridge',
|
||||
label: 'Bridge',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 13a8 8 0 0 1 16 0"/>
|
||||
<line x1="2" y1="13" x2="18" y2="13"/>
|
||||
<line x1="7" y1="13" x2="7" y2="9"/>
|
||||
<line x1="13" y1="13" x2="13" y2="9"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/remap',
|
||||
label: 'Remap',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="2,8 2,4 6,4"/>
|
||||
<path d="M2 4a8 8 0 0 1 14 2"/>
|
||||
<polyline points="18,12 18,16 14,16"/>
|
||||
<path d="M18 16a8 8 0 0 1-14-2"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/stacks',
|
||||
label: 'Stacks',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="10,2 18,6 10,10 2,6"/>
|
||||
<polyline points="2,10 10,14 18,10"/>
|
||||
<polyline points="2,14 10,18 18,14"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/log',
|
||||
label: 'Log',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="10" cy="10" r="8"/>
|
||||
<polyline points="10,5 10,10 14,12"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const csvIcon = (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 1.5h5l3 3v10H4z"/>
|
||||
<polyline points="9,1.5 9,4.5 12,4.5"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, sources = [] }) {
|
||||
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) {
|
||||
const { dark, setDark } = useTheme()
|
||||
|
||||
// Bank feeds first, then CSV sources, alphabetical within each group
|
||||
const navSources = useMemo(() => (
|
||||
sources
|
||||
.map(s => ({ ...s, isFeed: !!s.config?.simplefin?.account_id }))
|
||||
.sort((a, b) =>
|
||||
(b.isFeed - a.isFeed) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||
)
|
||||
), [sources])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-surface border-r border-line flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
||||
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
||||
style={{ width: expanded ? 200 : 48 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-12 flex items-center px-3 border-b border-line-soft gap-2 shrink-0">
|
||||
<div className="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded hover:bg-raised text-muted shrink-0"
|
||||
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0"
|
||||
title="Toggle sidebar"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
@ -51,7 +93,7 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, so
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
className="text-xs font-semibold text-ink-soft tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
|
||||
className="text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
|
||||
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
|
||||
>
|
||||
Dataflow
|
||||
@ -59,61 +101,37 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, so
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex flex-col gap-0.5 p-2 flex-1 overflow-y-auto">
|
||||
<nav className="flex flex-col gap-0.5 p-2 flex-1">
|
||||
{NAV.map(({ to, label, icon }) => (
|
||||
<Fragment key={to}>
|
||||
<NavLink
|
||||
to={to}
|
||||
end={to === '/sources'}
|
||||
title={!expanded ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-muted hover:bg-raised hover:text-ink'
|
||||
}`
|
||||
}
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
title={!expanded ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span
|
||||
className="text-sm whitespace-nowrap transition-opacity duration-100"
|
||||
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span
|
||||
className="text-sm whitespace-nowrap transition-opacity duration-100"
|
||||
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
{/* Jump straight to a source. Collapsed there is no room for names,
|
||||
so the shortcut list only exists when the sidebar is open. */}
|
||||
{to === '/sources' && expanded && navSources.map(({ isFeed, ...s }) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={s.name}
|
||||
to={`/sources/${encodeURIComponent(s.name)}`}
|
||||
title={`${s.name} — ${isFeed ? 'bank feed' : 'CSV'}`}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 ml-4 pl-2 pr-2 py-1 rounded border-l border-line-soft transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-muted hover:bg-raised hover:text-ink'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="shrink-0 opacity-70">{isFeed ? feedIcon : csvIcon}</span>
|
||||
<span className="text-xs truncate">{s.name}</span>
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
{label}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Theme */}
|
||||
<div className="border-t border-line-soft px-3 py-2 shrink-0">
|
||||
<div className="border-t border-gray-100 px-3 py-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setDark(d => !d)}
|
||||
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
className="flex items-center gap-2.5 w-full rounded px-1 py-1 text-muted hover:bg-raised hover:text-ink"
|
||||
className="flex items-center gap-2.5 w-full rounded px-1 py-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800"
|
||||
>
|
||||
<span className="shrink-0">
|
||||
{dark ? (
|
||||
@ -140,9 +158,9 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, so
|
||||
</div>
|
||||
|
||||
{/* User / logout */}
|
||||
<div className="border-t border-line-soft px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
|
||||
<div className="border-t border-gray-100 px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full bg-raised text-muted flex items-center justify-center shrink-0 text-xs font-medium"
|
||||
className="w-6 h-6 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center shrink-0 text-xs font-medium"
|
||||
title={!expanded ? loginUser : undefined}
|
||||
>
|
||||
{loginUser ? loginUser[0].toUpperCase() : '?'}
|
||||
@ -151,10 +169,10 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, so
|
||||
className="flex-1 flex items-center justify-between min-w-0 transition-opacity duration-100"
|
||||
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
|
||||
>
|
||||
<span className="text-xs text-muted truncate">{loginUser}</span>
|
||||
<span className="text-xs text-gray-400 truncate">{loginUser}</span>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-xs text-muted hover:text-danger ml-2 shrink-0"
|
||||
className="text-xs text-gray-400 hover:text-red-500 ml-2 shrink-0"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
|
||||
@ -2,15 +2,13 @@ import { NavLink, Outlet, useParams, Link } from 'react-router-dom'
|
||||
|
||||
// Everything scoped to one source lives under /sources/:name, so the source is
|
||||
// in the URL rather than in a global selector.
|
||||
// Records is the tab you want nine times out of ten, so it leads and is what
|
||||
// /sources/:name redirects to; Setup is the rare one and sits at the end.
|
||||
const TABS = [
|
||||
{ to: 'records', label: 'Records' },
|
||||
{ to: '', label: 'Setup', end: true },
|
||||
{ to: 'import', label: 'Import' },
|
||||
{ to: 'rules', label: 'Rules' },
|
||||
{ to: 'mappings', label: 'Mappings' },
|
||||
{ to: 'records', label: 'Records' },
|
||||
{ to: 'pivot', label: 'Pivot' },
|
||||
{ to: 'setup', label: 'Setup' },
|
||||
]
|
||||
|
||||
export default function SourceTabs({ sources }) {
|
||||
@ -20,19 +18,19 @@ export default function SourceTabs({ sources }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-4 sm:px-6 pt-4 sm:pt-5 shrink-0">
|
||||
<div className="px-6 pt-5 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/sources" className="text-xs text-muted hover:text-ink-soft">Sources</Link>
|
||||
<span className="text-muted text-xs">/</span>
|
||||
<h1 className="text-xl font-semibold text-ink">{name}</h1>
|
||||
<Link to="/sources" className="text-xs text-gray-400 hover:text-gray-600">Sources</Link>
|
||||
<span className="text-gray-300 text-xs">/</span>
|
||||
<h1 className="text-xl font-semibold text-gray-800">{name}</h1>
|
||||
{sourceObj?.config?.simplefin?.account_id && (
|
||||
<span className="text-xs bg-accent-soft text-accent border border-accent-line rounded px-1.5 py-0.5">
|
||||
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded px-1.5 py-0.5">
|
||||
bank feed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex gap-1 mt-3 border-b border-line overflow-x-auto whitespace-nowrap">
|
||||
<nav className="flex gap-1 mt-3 border-b border-gray-200">
|
||||
{TABS.map(({ to, label, end }) => (
|
||||
<NavLink
|
||||
key={label}
|
||||
@ -41,8 +39,8 @@ export default function SourceTabs({ sources }) {
|
||||
className={({ isActive }) =>
|
||||
`text-sm px-3 py-1.5 -mb-px border-b-2 ${
|
||||
isActive
|
||||
? 'border-accent text-accent font-medium'
|
||||
: 'border-transparent text-muted hover:text-ink-soft'
|
||||
? 'border-blue-500 text-blue-600 font-medium'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`
|
||||
}
|
||||
>
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
// Top-level destinations, shared by the desktop sidebar and the mobile bar
|
||||
export const NAV = [
|
||||
{
|
||||
to: '/sources',
|
||||
label: 'Sources',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
|
||||
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
|
||||
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/import',
|
||||
label: 'Import',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="10" y1="3" x2="10" y2="14"/>
|
||||
<polyline points="6,10 10,14 14,10"/>
|
||||
<line x1="3" y1="18" x2="17" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/bridge',
|
||||
label: 'Bridge',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 13a8 8 0 0 1 16 0"/>
|
||||
<line x1="2" y1="13" x2="18" y2="13"/>
|
||||
<line x1="7" y1="13" x2="7" y2="9"/>
|
||||
<line x1="13" y1="13" x2="13" y2="9"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/remap',
|
||||
label: 'Remap',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="2,8 2,4 6,4"/>
|
||||
<path d="M2 4a8 8 0 0 1 14 2"/>
|
||||
<polyline points="18,12 18,16 14,16"/>
|
||||
<path d="M18 16a8 8 0 0 1-14-2"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/stacks',
|
||||
label: 'Stacks',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="10,2 18,6 10,10 2,6"/>
|
||||
<polyline points="2,10 10,14 18,10"/>
|
||||
<polyline points="2,14 10,18 18,14"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/log',
|
||||
label: 'Log',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="10" cy="10" r="8"/>
|
||||
<polyline points="10,5 10,10 14,12"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
126
ui/src/index.css
126
ui/src/index.css
@ -1,42 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
Semantic colour tokens.
|
||||
|
||||
Components name the *role* of a colour (bg-surface, text-muted, border-line)
|
||||
rather than a literal shade, so light and dark are two sets of variable values
|
||||
instead of two sets of rules. Adding a component no longer means adding a
|
||||
matching `.dark` override.
|
||||
|
||||
The raw palette below is the only place actual colours appear.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--color-canvas: var(--bg-primary);
|
||||
--color-surface: var(--bg-secondary);
|
||||
--color-raised: var(--bg-tertiary);
|
||||
|
||||
--color-ink: var(--text-primary);
|
||||
--color-ink-soft: var(--text-secondary);
|
||||
--color-muted: var(--text-muted);
|
||||
|
||||
--color-line: var(--border-color);
|
||||
--color-line-soft: var(--border-light);
|
||||
|
||||
--color-accent: var(--accent-text);
|
||||
--color-accent-soft: var(--accent-bg);
|
||||
--color-accent-line: var(--accent-line);
|
||||
|
||||
--color-ok: var(--ok-text);
|
||||
--color-ok-soft: var(--ok-bg);
|
||||
--color-warn: var(--warn-text);
|
||||
--color-warn-soft: var(--warn-bg);
|
||||
--color-warn-line: var(--warn-line);
|
||||
--color-danger: var(--danger-text);
|
||||
--color-danger-soft: var(--danger-bg);
|
||||
--color-danger-line: var(--danger-line);
|
||||
}
|
||||
|
||||
:root, .light {
|
||||
--bg-primary: #f3f4f6;
|
||||
--bg-secondary: #ffffff;
|
||||
@ -48,16 +11,6 @@
|
||||
--border-light: #f3f4f6;
|
||||
--accent-bg: #eff6ff;
|
||||
--accent-text: #1d4ed8;
|
||||
--accent-line: #bfdbfe;
|
||||
|
||||
--ok-text: #059669;
|
||||
--ok-bg: #ecfdf5;
|
||||
--warn-text: #b45309;
|
||||
--warn-bg: #fffbeb;
|
||||
--warn-line: #fde68a;
|
||||
--danger-text: #ef4444;
|
||||
--danger-bg: #fef2f2;
|
||||
--danger-line: #fecaca;
|
||||
}
|
||||
|
||||
/* Dark palette tuned to Perspective's "Pro Dark" theme:
|
||||
@ -74,17 +27,6 @@
|
||||
--border-light: #3b3f46;
|
||||
--accent-bg: rgba(39, 113, 170, 0.32);
|
||||
--accent-text: #4778c2;
|
||||
--accent-line: #2770a9;
|
||||
|
||||
/* Status accents desaturated to sit on Pro Dark's neutral background */
|
||||
--ok-text: #6ee7b7;
|
||||
--ok-bg: #1a3d2c;
|
||||
--warn-text: #f5c66f;
|
||||
--warn-bg: #3a2e14;
|
||||
--warn-line: #5a4a26;
|
||||
--danger-text: #ff9485;
|
||||
--danger-bg: #3d1f1f;
|
||||
--danger-line: #6b3030;
|
||||
}
|
||||
|
||||
body {
|
||||
@ -94,14 +36,62 @@ body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Bare border utilities have no colour of their own */
|
||||
.border, .border-t, .border-b, .border-l, .border-r { border-color: var(--border-color); }
|
||||
.dark .bg-white { background-color: var(--bg-secondary); }
|
||||
.dark .bg-gray-50 { background-color: var(--bg-tertiary); }
|
||||
.dark .bg-gray-100 { background-color: var(--bg-tertiary); }
|
||||
.dark .bg-gray-200 { background-color: var(--bg-tertiary); }
|
||||
.dark .bg-gray-300 { background-color: var(--bg-tertiary); }
|
||||
.dark .text-gray-300 { color: var(--text-muted); }
|
||||
.dark .text-gray-400 { color: var(--text-muted); }
|
||||
.dark .text-gray-500 { color: var(--text-muted); }
|
||||
.dark .text-gray-600 { color: var(--text-secondary); }
|
||||
.dark .text-gray-700 { color: var(--text-secondary); }
|
||||
.dark .text-gray-800 { color: var(--text-primary); }
|
||||
.dark .text-gray-900 { color: var(--text-primary); }
|
||||
.dark .bg-blue-50 { background-color: var(--accent-bg); }
|
||||
.dark .bg-blue-100 { background-color: var(--accent-bg); }
|
||||
.dark .text-blue-400 { color: var(--accent-text); }
|
||||
.dark .text-blue-600 { color: var(--accent-text); }
|
||||
.dark .text-blue-700 { color: var(--accent-text); }
|
||||
.dark .text-blue-800 { color: var(--accent-text); }
|
||||
.dark .border-blue-200 { border-color: var(--accent-text); }
|
||||
.dark .border-blue-300 { border-color: var(--accent-text); }
|
||||
.dark .hover\:bg-blue-50:hover { background-color: var(--accent-bg); }
|
||||
|
||||
/* Form controls don't inherit the surface token on their own */
|
||||
input, select, textarea {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
::selection { background-color: var(--accent-bg); color: var(--text-primary); }
|
||||
/* Status accents — desaturated to sit on Pro Dark's neutral background */
|
||||
.dark .bg-green-50 { background-color: #1a3d2c; }
|
||||
.dark .text-green-600 { color: #6ee7b7; }
|
||||
.dark .text-green-700 { color: #6ee7b7; }
|
||||
.dark .text-green-400 { color: #6ee7b7; }
|
||||
.dark .bg-amber-50 { background-color: #3a2e14; }
|
||||
.dark .text-amber-800 { color: #f5c66f; }
|
||||
.dark .border-amber-200 { border-color: #5a4a26; }
|
||||
.dark .bg-amber-200 { background-color: #5a4a26; }
|
||||
.dark .hover\:bg-amber-300:hover { background-color: #6b5830; }
|
||||
.dark .bg-red-50 { background-color: #3d1f1f; }
|
||||
.dark .text-red-500 { color: #ff9485; }
|
||||
.dark .text-red-700 { color: #ff9485; }
|
||||
.dark .border-gray-100 { border-color: var(--border-light); }
|
||||
.dark .border-gray-200 { border-color: var(--border-color); }
|
||||
.dark .border-gray-300 { border-color: var(--border-color); }
|
||||
.dark .border-blue-100 { border-color: var(--border-color); }
|
||||
.dark .border-b { border-color: var(--border-color); }
|
||||
.dark .border-t { border-color: var(--border-color); }
|
||||
.dark .border-r { border-color: var(--border-color); }
|
||||
.dark .border-l { border-color: var(--border-color); }
|
||||
.dark .hover\:bg-gray-50:hover { background-color: var(--bg-tertiary); }
|
||||
.dark .hover\:bg-gray-100:hover { background-color: var(--bg-tertiary); }
|
||||
.dark .hover\:bg-gray-200:hover { background-color: var(--bg-tertiary); }
|
||||
.dark .hover\:text-gray-500:hover { color: var(--text-secondary); }
|
||||
.dark .hover\:text-gray-600:hover { color: var(--text-secondary); }
|
||||
.dark .hover\:text-gray-700:hover { color: var(--text-primary); }
|
||||
.dark .hover\:text-gray-800:hover { color: var(--text-primary); }
|
||||
.dark .hover\:border-gray-300:hover { border-color: var(--border-color); }
|
||||
.dark .hover\:border-gray-400:hover { border-color: var(--border-color); }
|
||||
.dark .focus\:border-gray-300:focus { border-color: var(--border-color); }
|
||||
.dark .focus\:border-blue-400:focus { border-color: var(--accent-text); }
|
||||
.dark ::selection { background-color: var(--accent-bg); color: var(--text-primary); }
|
||||
.dark input { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
|
||||
.dark select { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
|
||||
.dark textarea { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
|
||||
.dark .bg-transparent { background-color: transparent; }
|
||||
|
||||
@ -3,19 +3,6 @@ import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import Section from '../components/Section.jsx'
|
||||
|
||||
// Accounting style: aligned to 2 decimals, negatives in parentheses
|
||||
function money(value) {
|
||||
const n = parseFloat(value)
|
||||
if (!isFinite(n)) return '—'
|
||||
const abs = Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
return n < 0 ? `(${abs})` : abs
|
||||
}
|
||||
|
||||
// SimpleFIN doesn't report an account type, so retirement accounts are
|
||||
// recognised from the institution and account names
|
||||
const RETIREMENT_RE = /401\(?k\)?|403\(?b\)?|\bira\b|retirement|pension|profit sharing/i
|
||||
const isRetirement = (a) => RETIREMENT_RE.test(`${a.name} ${a.organization || ''}`)
|
||||
|
||||
// One SimpleFIN bridge covers every linked bank account, so connection state is
|
||||
// a bridge-level concern rather than something to hunt for source by source.
|
||||
export default function Bridge({ sources }) {
|
||||
@ -42,20 +29,16 @@ export default function Bridge({ sources }) {
|
||||
const sourceFor = (accountId) =>
|
||||
sources.find(s => s.config?.simplefin?.account_id === accountId)
|
||||
|
||||
const sum = (list) => list.reduce((t, a) => t + (parseFloat(a.balance) || 0), 0)
|
||||
const banking = (accounts || []).filter(a => !isRetirement(a))
|
||||
const retirement = (accounts || []).filter(isRetirement)
|
||||
// Banking first, then retirement, so each subtotal sits under its own rows
|
||||
const ordered = [...banking, ...retirement]
|
||||
const total = (accounts || []).reduce((sum, a) => sum + (parseFloat(a.balance) || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-5xl space-y-4">
|
||||
<div className="p-6 max-w-5xl space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-xl font-semibold text-ink">Bridge</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-800">Bridge</h1>
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
|
||||
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
@ -63,18 +46,18 @@ export default function Bridge({ sources }) {
|
||||
|
||||
{error && (
|
||||
<Section title="Not connected" description="Claim a setup token with manage.py option 10, then restart the service.">
|
||||
<p className="text-xs text-danger">{error}</p>
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="bg-warn-soft border border-warn-line rounded p-3 text-xs text-warn space-y-1">
|
||||
<div className="bg-orange-50 border border-orange-200 rounded p-3 text-xs text-orange-700 space-y-1">
|
||||
{errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!accounts && !loading && !error && (
|
||||
<p className="text-sm text-muted">Click Refresh to load balances from SimpleFIN.</p>
|
||||
<p className="text-sm text-gray-400">Click Refresh to load balances from SimpleFIN.</p>
|
||||
)}
|
||||
|
||||
{accounts && (
|
||||
@ -82,9 +65,9 @@ export default function Bridge({ sources }) {
|
||||
title="SimpleFIN accounts"
|
||||
description="Every account behind the bridge credential, and which source pulls from it."
|
||||
>
|
||||
<table className="w-full text-xs hidden sm:table">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-line-soft">
|
||||
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||
<th className="pb-1 font-medium">Account</th>
|
||||
<th className="pb-1 font-medium">Institution</th>
|
||||
<th className="pb-1 font-medium text-right">Balance</th>
|
||||
@ -93,82 +76,34 @@ export default function Bridge({ sources }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ordered.map(a => {
|
||||
{accounts.map(a => {
|
||||
const src = sourceFor(a.id)
|
||||
return (
|
||||
<tr key={a.id} className="border-t border-line-soft">
|
||||
<td className="py-1.5 text-ink-soft">{a.name}</td>
|
||||
<td className="py-1.5 text-muted">{a.organization}</td>
|
||||
<td className="py-1.5 text-right font-mono text-ink-soft">{money(a.balance)}</td>
|
||||
<td className="py-1.5 pl-4 text-muted">{a.balance_date}</td>
|
||||
<tr key={a.id} className="border-t border-gray-50">
|
||||
<td className="py-1.5 text-gray-700">{a.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{a.organization}</td>
|
||||
<td className="py-1.5 text-right font-mono text-gray-700">{a.balance}</td>
|
||||
<td className="py-1.5 pl-4 text-gray-400">{a.balance_date}</td>
|
||||
<td className="py-1.5">
|
||||
{src
|
||||
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent hover:text-accent">{src.name}</Link>
|
||||
: <span className="text-muted">not linked</span>}
|
||||
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-blue-500 hover:text-blue-700">{src.name}</Link>
|
||||
: <span className="text-gray-300">not linked</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{banking.length > 0 && (
|
||||
<tr className="border-t border-line">
|
||||
<td className="pt-2 text-muted" colSpan={2}>Banking and cards</td>
|
||||
<td className="pt-2 text-right font-mono text-ink-soft">{money(sum(banking))}</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
)}
|
||||
{retirement.length > 0 && (
|
||||
<tr>
|
||||
<td className="pt-1 text-muted" colSpan={2}>Retirement</td>
|
||||
<td className="pt-1 text-right font-mono text-ink-soft">{money(sum(retirement))}</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t border-line">
|
||||
<td className="pt-2 text-ink-soft font-medium" colSpan={2}>Total</td>
|
||||
<td className="pt-2 text-right font-mono font-medium text-ink">{money(sum(accounts))}</td>
|
||||
<tr className="border-t border-gray-200">
|
||||
<td className="pt-2 text-gray-600 font-medium" colSpan={2}>Total</td>
|
||||
<td className="pt-2 text-right font-mono font-medium text-gray-800">
|
||||
{total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{/* Stacked cards for narrow screens */}
|
||||
<div className="sm:hidden divide-y divide-line-soft">
|
||||
{ordered.map(a => {
|
||||
const src = sourceFor(a.id)
|
||||
return (
|
||||
<div key={a.id} className="py-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-xs text-ink-soft">{a.name}</span>
|
||||
<span className="text-xs font-mono text-ink">{money(a.balance)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2 text-xs text-muted">
|
||||
<span>{a.organization}</span>
|
||||
<span>{a.balance_date}</span>
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
{src
|
||||
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent">{src.name}</Link>
|
||||
: <span className="text-muted">not linked</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="pt-2 flex justify-between text-xs">
|
||||
<span className="text-muted">Banking and cards</span>
|
||||
<span className="font-mono text-ink-soft">{money(sum(banking))}</span>
|
||||
</div>
|
||||
<div className="pt-1 flex justify-between text-xs border-0">
|
||||
<span className="text-muted">Retirement</span>
|
||||
<span className="font-mono text-ink-soft">{money(sum(retirement))}</span>
|
||||
</div>
|
||||
<div className="pt-2 flex justify-between text-xs font-medium">
|
||||
<span className="text-ink-soft">Total</span>
|
||||
<span className="font-mono text-ink">{money(sum(accounts))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accounts.length === 0 && <p className="text-xs text-muted">No accounts returned.</p>}
|
||||
{accounts.length === 0 && <p className="text-xs text-gray-400">No accounts returned.</p>}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ function KeyList({ keys, label, color }) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div>
|
||||
<div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
|
||||
<div className="max-h-32 overflow-y-auto bg-gray-50 rounded p-2 font-mono text-xs text-gray-500 space-y-0.5">
|
||||
{keys.map((k, i) => (
|
||||
<div key={i}>
|
||||
{typeof k === 'object' && k !== null
|
||||
@ -28,19 +28,19 @@ function LogRow({ entry, selected, onToggle }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={`border-b border-line-soft ${selected ? 'bg-danger-soft' : ''}`}>
|
||||
<tr className={`border-b border-gray-50 ${selected ? 'bg-red-50' : ''}`}>
|
||||
<td className="py-1.5 pr-2">
|
||||
<input type="checkbox" checked={selected} onChange={onToggle} className="cursor-pointer" />
|
||||
</td>
|
||||
<td className="py-1.5 text-xs text-muted font-mono">{entry.id}</td>
|
||||
<td className="py-1.5 text-muted">{new Date(entry.imported_at).toLocaleString()}</td>
|
||||
<td className="py-1.5 text-ink">{entry.records_imported}</td>
|
||||
<td className="py-1.5 text-muted">{entry.records_duplicate}</td>
|
||||
<td className="py-1.5 text-xs text-gray-400 font-mono">{entry.id}</td>
|
||||
<td className="py-1.5 text-gray-500">{new Date(entry.imported_at).toLocaleString()}</td>
|
||||
<td className="py-1.5 text-gray-800">{entry.records_imported}</td>
|
||||
<td className="py-1.5 text-gray-400">{entry.records_duplicate}</td>
|
||||
<td className="py-1.5">
|
||||
{hasKeys && (
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="text-xs text-accent hover:text-accent"
|
||||
className="text-xs text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
{expanded ? '▲ hide' : '▼ keys'}
|
||||
</button>
|
||||
@ -48,10 +48,10 @@ function LogRow({ entry, selected, onToggle }) {
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className={selected ? 'bg-danger-soft' : 'bg-raised'}>
|
||||
<tr className={selected ? 'bg-red-50' : 'bg-gray-50'}>
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
|
||||
<KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
|
||||
<KeyList keys={insertedKeys} label="Inserted" color="text-green-600" />
|
||||
<KeyList keys={excludedKeys} label="Excluded" color="text-gray-500" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@ -168,11 +168,11 @@ export default function Import({ source }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-2xl">
|
||||
<h1 className="text-xl font-semibold text-ink mb-6">Import — {source}</h1>
|
||||
<div className="p-6 max-w-2xl">
|
||||
<h1 className="text-xl font-semibold text-gray-800 mb-6">Import — {source}</h1>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
@ -182,9 +182,9 @@ export default function Import({ source }) {
|
||||
{ label: 'Transformed', value: stats.transformed_records },
|
||||
{ label: 'Pending', value: stats.pending_records },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-surface border border-line rounded px-4 py-3 flex-1 text-center">
|
||||
<div className="text-2xl font-semibold text-ink">{value}</div>
|
||||
<div className="text-xs text-muted mt-0.5">{label}</div>
|
||||
<div key={label} className="bg-white border border-gray-200 rounded px-4 py-3 flex-1 text-center">
|
||||
<div className="text-2xl font-semibold text-gray-800">{value}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -192,15 +192,15 @@ export default function Import({ source }) {
|
||||
|
||||
{/* SimpleFIN sync — only for sources with a bridge account in their config */}
|
||||
{simplefin?.account_id && (
|
||||
<div className="bg-surface border border-line rounded p-4 mb-4 flex items-center gap-3">
|
||||
<div className="bg-white border border-gray-200 rounded p-4 mb-4 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-ink-soft">SimpleFIN</div>
|
||||
<div className="text-xs text-muted font-mono truncate">{simplefin.account_id}</div>
|
||||
<div className="text-sm font-medium text-gray-700">SimpleFIN</div>
|
||||
<div className="text-xs text-gray-400 font-mono truncate">{simplefin.account_id}</div>
|
||||
</div>
|
||||
<select
|
||||
value={days}
|
||||
onChange={e => setDays(e.target.value)}
|
||||
className="text-sm border border-line rounded px-2 py-1.5 bg-surface text-ink-soft"
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white text-gray-700"
|
||||
>
|
||||
<option value="10">Last 10 days</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
@ -217,7 +217,7 @@ export default function Import({ source }) {
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 cursor-pointer transition-colors ${
|
||||
dragOver ? 'border-accent bg-accent-soft' : 'border-line hover:border-line'
|
||||
dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
@ -232,22 +232,22 @@ export default function Import({ source }) {
|
||||
onChange={e => handleImport(e.target.files[0])}
|
||||
/>
|
||||
{loading
|
||||
? <p className="text-sm text-muted">Importing…</p>
|
||||
: <p className="text-sm text-muted">Drop a CSV file here, or click to browse</p>
|
||||
? <p className="text-sm text-gray-500">Importing…</p>
|
||||
: <p className="text-sm text-gray-400">Drop a CSV file here, or click to browse</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger mb-3">{error}</p>}
|
||||
{error && <p className="text-sm text-red-500 mb-3">{error}</p>}
|
||||
|
||||
{result && (
|
||||
<div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-danger-soft border-danger-line' : 'bg-surface border-line'}`}>
|
||||
<div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200'}`}>
|
||||
{result.success === false ? (
|
||||
<>
|
||||
<p className="text-danger font-medium mb-2">{result.error}</p>
|
||||
<p className="text-red-600 font-medium mb-2">{result.error}</p>
|
||||
{result.duplicate_rows && (
|
||||
<div>
|
||||
<p className="text-xs text-danger mb-1">Offending rows:</p>
|
||||
<div className="max-h-48 overflow-y-auto bg-surface rounded border border-danger-line p-2 font-mono text-xs text-danger space-y-0.5">
|
||||
<p className="text-xs text-red-500 mb-1">Offending rows:</p>
|
||||
<div className="max-h-48 overflow-y-auto bg-white rounded border border-red-100 p-2 font-mono text-xs text-red-700 space-y-0.5">
|
||||
{result.duplicate_rows.map((row, i) => (
|
||||
<div key={i}>
|
||||
{Object.entries(row).map(([f, v]) => `${f}: ${v}`).join(' · ')}
|
||||
@ -260,28 +260,28 @@ export default function Import({ source }) {
|
||||
) : result.imported !== undefined ? (
|
||||
<>
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="mb-2 text-xs text-warn">
|
||||
<div className="mb-2 text-xs text-orange-600">
|
||||
{result.errors.map((e, i) => <div key={i}>Bridge: {e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{result.fetched !== undefined && (
|
||||
<>
|
||||
<span className="text-muted">{result.fetched} fetched</span>
|
||||
<span className="text-muted mx-2">·</span>
|
||||
<span className="text-gray-500">{result.fetched} fetched</span>
|
||||
<span className="text-gray-400 mx-2">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-ok font-medium">{result.imported} imported</span>
|
||||
<span className="text-muted mx-2">·</span>
|
||||
<span className="text-muted">{result.duplicates} duplicates skipped</span>
|
||||
<span className="text-green-600 font-medium">{result.imported} imported</span>
|
||||
<span className="text-gray-400 mx-2">·</span>
|
||||
<span className="text-gray-500">{result.duplicates} duplicates skipped</span>
|
||||
{result.transform && (
|
||||
<>
|
||||
<span className="text-muted mx-2">·</span>
|
||||
<span className="text-muted">{result.transform.transformed} transformed</span>
|
||||
<span className="text-gray-400 mx-2">·</span>
|
||||
<span className="text-gray-500">{result.transform.transformed} transformed</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-ok font-medium">{result.transformed} records transformed</span>
|
||||
<span className="text-green-600 font-medium">{result.transformed} records transformed</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -306,7 +306,7 @@ export default function Import({ source }) {
|
||||
{log.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-semibold text-ink-soft">Import history</h2>
|
||||
<h2 className="text-sm font-semibold text-gray-700">Import history</h2>
|
||||
{selected.size > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
@ -319,7 +319,7 @@ export default function Import({ source }) {
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted border-b border-line-soft">
|
||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-100">
|
||||
<th className="pb-1 w-6"></th>
|
||||
<th className="pb-1 font-medium w-12">ID</th>
|
||||
<th className="pb-1 font-medium">Date</th>
|
||||
|
||||
@ -65,18 +65,18 @@ export default function ImportHub({ sources }) {
|
||||
<div className="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-40">
|
||||
<Link to={`/sources/${encodeURIComponent(s.name)}/import`}
|
||||
className="text-sm font-medium text-ink hover:text-accent">
|
||||
className="text-sm font-medium text-gray-800 hover:text-blue-600">
|
||||
{s.name}
|
||||
</Link>
|
||||
<div className="text-xs text-muted">
|
||||
<div className="text-xs text-gray-400">
|
||||
{st ? `${st.total_records} records` : '—'}
|
||||
{st && Number(st.pending_records) > 0 && ` · ${st.pending_records} untransformed`}
|
||||
{when && ` · last import ${new Date(when).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{results[s.name] && <span className="text-xs text-ok">{results[s.name]}</span>}
|
||||
{errors[s.name] && <span className="text-xs text-danger">{errors[s.name]}</span>}
|
||||
{results[s.name] && <span className="text-xs text-green-600">{results[s.name]}</span>}
|
||||
{errors[s.name] && <span className="text-xs text-red-500">{errors[s.name]}</span>}
|
||||
|
||||
{isFeed ? (
|
||||
<button
|
||||
@ -88,7 +88,7 @@ export default function ImportHub({ sources }) {
|
||||
</button>
|
||||
) : (
|
||||
<Link to={`/sources/${encodeURIComponent(s.name)}/import`}
|
||||
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line">
|
||||
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400">
|
||||
Upload CSV
|
||||
</Link>
|
||||
)}
|
||||
@ -97,31 +97,31 @@ export default function ImportHub({ sources }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-4xl space-y-6">
|
||||
<h1 className="text-xl font-semibold text-ink">Import</h1>
|
||||
<div className="p-6 max-w-4xl space-y-6">
|
||||
<h1 className="text-xl font-semibold text-gray-800">Import</h1>
|
||||
|
||||
{feeds.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-ink-soft mb-2">Bank feeds</h2>
|
||||
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-2">Bank feeds</h2>
|
||||
<div className="bg-white border border-gray-200 rounded divide-y divide-gray-100">
|
||||
{feeds.map(s => <Row key={s.name} s={s} isFeed />)}
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-1">Syncs pull the last 10 days; use a source’s Import tab to backfill further.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Syncs pull the last 10 days; use a source’s Import tab to backfill further.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manual.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-ink-soft mb-2">CSV sources</h2>
|
||||
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-2">CSV sources</h2>
|
||||
<div className="bg-white border border-gray-200 rounded divide-y divide-gray-100">
|
||||
{manual.map(s => <Row key={s.name} s={s} isFeed={false} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length === 0 && <p className="text-sm text-muted">No sources yet.</p>}
|
||||
{sources.length === 0 && <p className="text-sm text-gray-400">No sources yet.</p>}
|
||||
|
||||
<Link to="/log" className="inline-block text-xs text-accent hover:text-accent">
|
||||
<Link to="/log" className="inline-block text-xs text-blue-500 hover:text-blue-700">
|
||||
Full import history →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ function KeyList({ keys, label, color }) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div>
|
||||
<div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
|
||||
<div className="max-h-32 overflow-y-auto bg-gray-50 rounded p-2 font-mono text-xs text-gray-500 space-y-0.5">
|
||||
{keys.map((k, i) => (
|
||||
<div key={i}>
|
||||
{typeof k === 'object' && k !== null
|
||||
@ -28,17 +28,17 @@ function LogRow({ entry }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-line-soft hover:bg-raised">
|
||||
<td className="py-1.5 text-xs text-muted font-mono pr-3">{entry.id}</td>
|
||||
<td className="py-1.5 text-ink-soft pr-3">{entry.source_name}</td>
|
||||
<td className="py-1.5 text-muted pr-3">{new Date(entry.imported_at).toLocaleString()}</td>
|
||||
<td className="py-1.5 text-ink pr-3">{entry.records_imported}</td>
|
||||
<td className="py-1.5 text-muted pr-3">{entry.records_duplicate}</td>
|
||||
<tr className="border-b border-gray-50 hover:bg-gray-50">
|
||||
<td className="py-1.5 text-xs text-gray-400 font-mono pr-3">{entry.id}</td>
|
||||
<td className="py-1.5 text-gray-700 pr-3">{entry.source_name}</td>
|
||||
<td className="py-1.5 text-gray-500 pr-3">{new Date(entry.imported_at).toLocaleString()}</td>
|
||||
<td className="py-1.5 text-gray-800 pr-3">{entry.records_imported}</td>
|
||||
<td className="py-1.5 text-gray-400 pr-3">{entry.records_duplicate}</td>
|
||||
<td className="py-1.5">
|
||||
{hasKeys && (
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="text-xs text-accent hover:text-accent"
|
||||
className="text-xs text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
{expanded ? '▲ hide' : '▼ keys'}
|
||||
</button>
|
||||
@ -46,10 +46,10 @@ function LogRow({ entry }) {
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="bg-raised">
|
||||
<tr className="bg-gray-50">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
|
||||
<KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
|
||||
<KeyList keys={insertedKeys} label="Inserted" color="text-green-600" />
|
||||
<KeyList keys={excludedKeys} label="Excluded" color="text-gray-500" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@ -70,18 +70,18 @@ export default function Log() {
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl font-semibold text-ink mb-6">Import Log</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-800 mb-6">Import Log</h1>
|
||||
|
||||
{loading && <p className="text-sm text-muted">Loading…</p>}
|
||||
{loading && <p className="text-sm text-gray-400">Loading…</p>}
|
||||
|
||||
{!loading && log.length === 0 && (
|
||||
<p className="text-sm text-muted">No imports yet.</p>
|
||||
<p className="text-sm text-gray-400">No imports yet.</p>
|
||||
)}
|
||||
|
||||
{log.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted border-b border-line-soft">
|
||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-100">
|
||||
<th className="pb-1 font-medium pr-3">ID</th>
|
||||
<th className="pb-1 font-medium pr-3">Source</th>
|
||||
<th className="pb-1 font-medium pr-3">Date</th>
|
||||
|
||||
@ -20,32 +20,32 @@ export default function Login({ onLogin }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-raised">
|
||||
<div className="bg-surface border border-line rounded-lg p-8 w-80 shadow-sm">
|
||||
<h1 className="text-lg font-semibold text-ink mb-6">Dataflow</h1>
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-8 w-80 shadow-sm">
|
||||
<h1 className="text-lg font-semibold text-gray-800 mb-6">Dataflow</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Username</label>
|
||||
<label className="block text-xs text-gray-500 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={user}
|
||||
onChange={e => setUser(e.target.value)}
|
||||
className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Password</label>
|
||||
<label className="block text-xs text-gray-500 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={e => setPass(e.target.value)}
|
||||
className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
|
||||
@ -68,13 +68,13 @@ function AutocompleteInput({ value, onChange, onEnter, suggestions = [], classNa
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
|
||||
className="bg-surface border border-line rounded shadow-lg max-h-48 overflow-y-auto"
|
||||
className="bg-white border border-gray-200 rounded shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
{filtered.map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${
|
||||
i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'
|
||||
i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
onMouseDown={e => { e.preventDefault(); select(s) }}
|
||||
>
|
||||
@ -100,11 +100,11 @@ function SortHeader({ col, label, sortBy, onSort, className = '' }) {
|
||||
const active = sortBy?.col === col
|
||||
return (
|
||||
<th
|
||||
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-ink-soft ${className}`}
|
||||
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`}
|
||||
onClick={() => onSort(col)}
|
||||
>
|
||||
{label}
|
||||
<span className="ml-1 text-muted">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
|
||||
<span className="ml-1 text-gray-300">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
@ -354,18 +354,18 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||||
|
||||
const displayRows = sortedRows(filteredRows)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sticky control bar */}
|
||||
<div className="sticky top-0 z-10 bg-surface border-b border-line px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm font-medium text-ink-soft">{source}</span>
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-700">{source}</span>
|
||||
|
||||
<select
|
||||
className="text-sm border border-line rounded px-2 py-1.5 focus:outline-none focus:border-accent"
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400"
|
||||
value={selectedRule}
|
||||
onChange={e => setSelectedRule(e.target.value)}
|
||||
>
|
||||
@ -374,7 +374,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
</select>
|
||||
|
||||
{selectedRule && (
|
||||
<div className="flex bg-raised rounded p-0.5">
|
||||
<div className="flex bg-gray-100 rounded p-0.5">
|
||||
{[
|
||||
{ key: 'all', label: `All (${allValues.length})` },
|
||||
{ key: 'unmapped', label: `Unmapped (${unmappedCount})` },
|
||||
@ -382,7 +382,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
].map(({ key, label }) => (
|
||||
<button key={key} onClick={() => setFilter(key)}
|
||||
className={`text-xs px-3 py-1 rounded transition-colors ${
|
||||
filter === key ? 'bg-surface text-ink shadow-sm' : 'text-muted'
|
||||
filter === key ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
|
||||
}`}>
|
||||
{label}
|
||||
</button>
|
||||
@ -393,15 +393,15 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
{selectedRule && (
|
||||
<div className="relative">
|
||||
<input
|
||||
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${
|
||||
rowFilterError ? 'border-danger-line bg-danger-soft' : rowFilter ? 'border-accent-line' : 'border-line'
|
||||
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
|
||||
rowFilterError ? 'border-red-400 bg-red-50' : rowFilter ? 'border-blue-300' : 'border-gray-200'
|
||||
}`}
|
||||
placeholder="filter regex…"
|
||||
value={rowFilter}
|
||||
onChange={e => setRowFilter(e.target.value)}
|
||||
/>
|
||||
{rowFilter && !rowFilterError && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted">
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">
|
||||
{filteredRows.length}
|
||||
</span>
|
||||
)}
|
||||
@ -435,12 +435,12 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
alert(err.message)
|
||||
}
|
||||
}}
|
||||
className="text-sm px-3 py-1.5 border border-line rounded hover:bg-raised text-ink-soft"
|
||||
className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600"
|
||||
>
|
||||
Export TSV
|
||||
</button>
|
||||
)}
|
||||
<label className={`text-sm px-3 py-1.5 border border-line rounded cursor-pointer hover:bg-raised text-ink-soft ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<label className={`text-sm px-3 py-1.5 border border-gray-200 rounded cursor-pointer hover:bg-gray-50 text-gray-600 ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{importing ? 'Importing…' : 'Import TSV'}
|
||||
<input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} />
|
||||
</label>
|
||||
@ -450,24 +450,24 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{!selectedRule && (
|
||||
<p className="text-sm text-muted">Select a rule to view mappings.</p>
|
||||
<p className="text-sm text-gray-400">Select a rule to view mappings.</p>
|
||||
)}
|
||||
{selectedRule && loading && (
|
||||
<p className="text-sm text-muted">Loading…</p>
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
)}
|
||||
{selectedRule && !loading && allValues.length === 0 && (
|
||||
<p className="text-sm text-muted">No extracted values for this rule. Run a transform first.</p>
|
||||
<p className="text-sm text-gray-400">No extracted values for this rule. Run a transform first.</p>
|
||||
)}
|
||||
{selectedRule && !loading && allValues.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
{/* Bulk assign bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2 p-2 bg-accent-soft border border-accent-line rounded flex-wrap">
|
||||
<span className="text-xs text-accent font-medium whitespace-nowrap">{selected.size} selected</span>
|
||||
<div className="flex items-center gap-2 mb-2 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
|
||||
<span className="text-xs text-blue-700 font-medium whitespace-nowrap">{selected.size} selected</span>
|
||||
{cols.map(col => (
|
||||
<AutocompleteInput
|
||||
key={col}
|
||||
className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface"
|
||||
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
|
||||
placeholder={col}
|
||||
value={bulkDraft[col] || ''}
|
||||
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
|
||||
@ -483,15 +483,15 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelected(new Set()); setBulkDraft({}) }}
|
||||
className="text-xs text-accent hover:text-accent"
|
||||
className="text-xs text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-xs bg-surface border border-line rounded">
|
||||
<table className="w-full text-xs bg-white border border-gray-200 rounded">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-line-soft bg-raised">
|
||||
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
|
||||
<th className="px-2 py-2 w-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -511,7 +511,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
{extraCols.map((col, idx) => (
|
||||
<th key={`extra-${idx}`} className="px-3 py-2 font-medium">
|
||||
<input
|
||||
className="border border-line rounded px-1 py-0.5 w-24 focus:outline-none focus:border-accent font-normal"
|
||||
className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
|
||||
value={col}
|
||||
placeholder="new key"
|
||||
onChange={e => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })}
|
||||
@ -521,7 +521,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
<th className="px-2 py-2">
|
||||
<button
|
||||
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||||
className="text-muted hover:text-ink-soft font-medium"
|
||||
className="text-gray-400 hover:text-gray-700 font-medium"
|
||||
title="Add column"
|
||||
>+</button>
|
||||
</th>
|
||||
@ -536,7 +536,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
const isSaving = saving[k]
|
||||
const isSelected = selected.has(k)
|
||||
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0)
|
||||
const rowBg = isSelected ? 'bg-accent-soft' : hasDraft ? 'bg-accent-soft' : row.is_mapped ? '' : 'bg-warn-soft'
|
||||
const rowBg = isSelected ? 'bg-blue-50' : hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
|
||||
|
||||
function handleRowClick(e) {
|
||||
if (e.target.closest('input,button,a,select')) return
|
||||
@ -571,7 +571,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
key={k}
|
||||
ref={el => rowRefs.current[k] = el}
|
||||
tabIndex={0}
|
||||
className={`border-t border-line-soft hover:bg-raised cursor-pointer outline-none ${rowBg}`}
|
||||
className={`border-t border-gray-50 hover:bg-gray-50 cursor-pointer outline-none ${rowBg}`}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
>
|
||||
@ -586,13 +586,13 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-ink whitespace-nowrap">{displayValue(row.extracted_value)}</td>
|
||||
<td className="px-3 py-1.5 text-right text-muted">{row.record_count}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td>
|
||||
<td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td>
|
||||
{cols.map(col => (
|
||||
<td key={col} className="px-3 py-1.5">
|
||||
<AutocompleteInput
|
||||
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-accent ${
|
||||
hasDraft ? 'border-accent-line' : row.is_mapped ? 'border-line' : 'border-warn-line'
|
||||
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-blue-400 ${
|
||||
hasDraft ? 'border-blue-300' : row.is_mapped ? 'border-gray-200' : 'border-yellow-300'
|
||||
}`}
|
||||
value={cellVal(col)}
|
||||
onChange={v => setCellValue(row.extracted_value, col, v)}
|
||||
@ -605,7 +605,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||
{samples.length > 0 && (
|
||||
<button
|
||||
className="text-accent hover:text-accent"
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))}
|
||||
>
|
||||
{sampleOpen[k] ? 'hide' : 'show'}
|
||||
@ -624,7 +624,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
{row.is_mapped && (
|
||||
<button
|
||||
onClick={() => deleteRow(row)}
|
||||
className="text-danger hover:text-danger text-base leading-none"
|
||||
className="text-red-400 hover:text-red-600 text-base leading-none"
|
||||
title="Remove mapping"
|
||||
>×</button>
|
||||
)}
|
||||
@ -634,21 +634,21 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
||||
{sampleOpen[k] && (() => {
|
||||
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
|
||||
return (
|
||||
<tr key={`${k}-sample`} className="border-t border-line-soft bg-raised">
|
||||
<tr key={`${k}-sample`} className="border-t border-gray-50 bg-gray-50">
|
||||
<td colSpan={3 + cols.length + 4} className="px-3 py-2">
|
||||
<table className="w-full text-xs border border-line-soft rounded bg-surface">
|
||||
<table className="w-full text-xs border border-gray-100 rounded bg-white">
|
||||
<thead>
|
||||
<tr className="bg-raised border-b border-line-soft">
|
||||
<tr className="bg-gray-50 border-b border-gray-100">
|
||||
{sampleCols.map(c => (
|
||||
<th key={c} className="px-2 py-1 text-left font-medium text-muted whitespace-nowrap">{c}</th>
|
||||
<th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{samples.map((rec, i) => (
|
||||
<tr key={i} className="border-t border-line-soft">
|
||||
<tr key={i} className="border-t border-gray-50">
|
||||
{sampleCols.map(c => (
|
||||
<td key={c} className="px-2 py-1 font-mono text-ink-soft whitespace-nowrap">
|
||||
<td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">
|
||||
{rec[c] != null ? String(rec[c]) : ''}
|
||||
</td>
|
||||
))}
|
||||
|
||||
@ -370,7 +370,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
|
||||
}
|
||||
|
||||
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||||
|
||||
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
|
||||
|
||||
@ -416,24 +416,24 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
<div className="w-full h-full flex flex-col">
|
||||
|
||||
{/* Layouts sub-bar */}
|
||||
<div className="flex items-center gap-2 px-3 h-9 bg-surface border-b border-line shrink-0 text-xs">
|
||||
<div className="flex items-center gap-2 px-3 h-9 bg-white border-b border-gray-200 shrink-0 text-xs">
|
||||
{layouts.map(l => (
|
||||
<div key={l.id}
|
||||
onClick={() => applyLayout(l)}
|
||||
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
|
||||
${activeLayoutId === l.id
|
||||
? 'bg-accent-soft border-accent-line text-accent'
|
||||
: 'bg-surface border-line text-ink-soft hover:border-line'}`}>
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||
{l.layout_name}
|
||||
<button
|
||||
onClick={(e) => handleDelete(l, e)}
|
||||
className="text-muted hover:text-danger leading-none ml-0.5 text-sm">×</button>
|
||||
className="text-gray-300 hover:text-red-400 leading-none ml-0.5 text-sm">×</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeLayoutId !== null && !showSaveAs && (
|
||||
<button onClick={handleSaveOver}
|
||||
className="text-accent hover:text-accent border border-accent-line rounded px-2 py-0.5">
|
||||
className="text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5">
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
@ -446,27 +446,27 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
onChange={e => setSaveAsName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
|
||||
placeholder="Layout name…"
|
||||
className="border border-line rounded px-2 py-0.5 w-36 focus:outline-none focus:border-accent"
|
||||
className="border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<button onClick={handleSaveAs} className="text-accent hover:text-accent px-1">Save</button>
|
||||
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-muted hover:text-ink-soft px-1">Cancel</button>
|
||||
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
|
||||
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 hover:text-gray-600 px-1">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSaveAs(true)}
|
||||
className="text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-0.5">
|
||||
className="text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
|
||||
+ Save as…
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeLayoutId !== null && (
|
||||
<button onClick={handleResetToDefault} className="text-muted hover:text-muted ml-1">reset</button>
|
||||
<button onClick={handleResetToDefault} className="text-gray-300 hover:text-gray-500 ml-1">reset</button>
|
||||
)}
|
||||
|
||||
{layoutMsg && <span className="text-ok ml-1">{layoutMsg}</span>}
|
||||
{layoutMsg && <span className="text-green-600 ml-1">{layoutMsg}</span>}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="text-muted">depth:</span>
|
||||
<span className="text-gray-400">depth:</span>
|
||||
{[0, 1, 2, 3].map(d => (
|
||||
<button key={d} onClick={async () => {
|
||||
const v = viewerRef.current; if (!v) return
|
||||
@ -475,7 +475,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
const p = await v.getPlugin()
|
||||
await p.draw(view)
|
||||
expandDepthRef.current = d
|
||||
}} className="border border-line rounded px-1.5 py-0.5 text-muted hover:border-line">
|
||||
}} className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400">
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
@ -486,18 +486,18 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
<div className="relative flex-1 flex min-h-0">
|
||||
<div className="relative flex-1">
|
||||
{status === 'loading' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
|
||||
<p className="text-sm text-muted">Loading…</p>
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
|
||||
<p className="text-sm text-danger">Error: {error}</p>
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
|
||||
<p className="text-sm text-red-500">Error: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'noview' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
|
||||
<p className="text-sm text-muted">No view data — generate a view and transform records first.</p>
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
|
||||
<p className="text-sm text-gray-400">No view data — generate a view and transform records first.</p>
|
||||
</div>
|
||||
)}
|
||||
<perspective-viewer
|
||||
@ -509,7 +509,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
{inspectedRows && clickDetail && (
|
||||
<div
|
||||
style={{ width: paneWidth }}
|
||||
className="relative border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0"
|
||||
className="relative border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0"
|
||||
>
|
||||
{/* Drag-to-resize handle on left edge */}
|
||||
<div
|
||||
@ -529,27 +529,27 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
/>
|
||||
|
||||
{/* Header: breadcrumb + row count + controls */}
|
||||
<div className="flex items-center justify-between pl-3 pr-2 py-2 border-b border-line-soft flex-shrink-0">
|
||||
<div className="flex items-center justify-between pl-3 pr-2 py-2 border-b border-gray-100 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{cellCoords.length > 0 && (
|
||||
<span className="text-xs text-ink-soft font-mono font-semibold truncate">
|
||||
<span className="text-xs text-gray-700 font-mono font-semibold truncate">
|
||||
{cellCoords.join(' › ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted flex-shrink-0">
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => setDecimals(d => Math.max(0, d - 1))}
|
||||
className="text-xs text-muted hover:text-ink-soft w-4 text-center">−</button>
|
||||
<span className="text-xs text-muted w-4 text-center">{decimals}</span>
|
||||
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">−</button>
|
||||
<span className="text-xs text-gray-400 w-4 text-center">{decimals}</span>
|
||||
<button onClick={() => setDecimals(d => Math.min(8, d + 1))}
|
||||
className="text-xs text-muted hover:text-ink-soft w-4 text-center">+</button>
|
||||
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">+</button>
|
||||
</div>
|
||||
<button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }}
|
||||
className="text-muted hover:text-muted leading-none text-lg">×</button>
|
||||
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -558,10 +558,10 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
{(() => {
|
||||
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
|
||||
return userFilters.length > 0 ? (
|
||||
<div className="px-3 py-2 border-b border-line-soft">
|
||||
<div className="text-xs text-muted uppercase tracking-wide mb-1">Filters</div>
|
||||
<div className="px-3 py-2 border-b border-gray-100">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Filters</div>
|
||||
{userFilters.map((f, i) => (
|
||||
<div key={i} className="text-xs text-muted py-0.5 font-mono">{f.join(' ')}</div>
|
||||
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
@ -572,13 +572,13 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0">
|
||||
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
|
||||
{cols.map(c => {
|
||||
const active = sortCol === c
|
||||
return (
|
||||
<th key={c}
|
||||
onClick={() => { if (active) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(c); setSortDir('asc') } }}
|
||||
className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft">
|
||||
className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600">
|
||||
{c}{active ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
|
||||
</th>
|
||||
)
|
||||
@ -587,12 +587,12 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-line-soft hover:bg-raised">
|
||||
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
|
||||
{cols.map(c => {
|
||||
const f = formatVal(row[c], decimals)
|
||||
return (
|
||||
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-ink-soft max-w-40 truncate">
|
||||
{f == null ? <span className="text-muted">—</span> : f}
|
||||
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-gray-700 max-w-40 truncate">
|
||||
{f == null ? <span className="text-gray-300">—</span> : f}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
@ -601,7 +601,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
||||
</tbody>
|
||||
{Object.keys(totals).length > 0 && (
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-line bg-raised font-semibold text-ink-soft sticky bottom-0">
|
||||
<tr className="border-t-2 border-gray-200 bg-gray-50 font-semibold text-gray-700 sticky bottom-0">
|
||||
{cols.map(c => (
|
||||
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-right">
|
||||
{totals[c] != null ? formatVal(totals[c], decimals) : ''}
|
||||
|
||||
@ -49,10 +49,10 @@ function AutocompleteInput({ value, onChange, onEnter, suggestions = [], classNa
|
||||
{open && filtered.length > 0 && dropPos && (
|
||||
<div ref={listRef}
|
||||
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
|
||||
className="bg-surface border border-line rounded shadow-lg max-h-40 overflow-y-auto">
|
||||
className="bg-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto">
|
||||
{filtered.map((s, i) => (
|
||||
<div key={s}
|
||||
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'}`}
|
||||
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}
|
||||
onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div>
|
||||
))}
|
||||
</div>
|
||||
@ -283,7 +283,7 @@ export default function Records({ source }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||||
|
||||
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
|
||||
const visCols = gridCols(cols)
|
||||
@ -300,42 +300,42 @@ export default function Records({ source }) {
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
<div className="flex-1 overflow-auto p-6 min-w-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-semibold text-ink">Records — {source}</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-800">Records — {source}</h1>
|
||||
{exists && rows.length > 0 && (
|
||||
<span className="text-xs text-muted font-mono">dfv.{source}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">dfv.{source}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{exists !== false && visCols.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-xs text-muted font-medium mr-1">DB query:</span>
|
||||
<span className="text-xs text-gray-400 font-medium mr-1">DB query:</span>
|
||||
{filters.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-1 bg-surface border border-line rounded px-2 py-1">
|
||||
<div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1">
|
||||
<select
|
||||
className="text-xs text-ink-soft border-0 focus:outline-none bg-transparent"
|
||||
className="text-xs text-gray-600 border-0 focus:outline-none bg-transparent"
|
||||
value={f.col}
|
||||
onChange={e => updateFilter(i, 'col', e.target.value)}
|
||||
>
|
||||
{visCols.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<span className="text-xs text-muted mx-0.5">~*</span>
|
||||
<span className="text-xs text-gray-300 mx-0.5">~*</span>
|
||||
<input
|
||||
className="text-xs font-mono border-0 focus:outline-none w-36 bg-transparent"
|
||||
placeholder="regex…"
|
||||
value={f.pattern}
|
||||
onChange={e => updateFilter(i, 'pattern', e.target.value)}
|
||||
/>
|
||||
<button onClick={() => removeFilter(i)} className="text-muted hover:text-muted ml-1 leading-none">×</button>
|
||||
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-gray-500 ml-1 leading-none">×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addFilter}
|
||||
className="text-xs text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-1">
|
||||
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-1">
|
||||
+ filter
|
||||
</button>
|
||||
{filters.length > 0 && (
|
||||
<button onClick={() => { setFilters([]); setOffset(0); load(0, sort.col, sort.dir, []) }}
|
||||
className="text-xs text-muted hover:text-danger">clear</button>
|
||||
className="text-xs text-gray-400 hover:text-red-500">clear</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -343,24 +343,24 @@ export default function Records({ source }) {
|
||||
{/* Bulk select + override bar */}
|
||||
{exists && visCols.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-xs text-muted font-medium mr-1">Bulk select:</span>
|
||||
<span className="text-xs text-gray-400 font-medium mr-1">Bulk select:</span>
|
||||
<input
|
||||
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${
|
||||
rowFilter ? 'border-accent-line' : 'border-line'
|
||||
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
|
||||
rowFilter ? 'border-blue-300' : 'border-gray-200'
|
||||
}`}
|
||||
placeholder="regex on loaded rows…"
|
||||
value={rowFilter}
|
||||
onChange={e => setRowFilter(e.target.value)}
|
||||
/>
|
||||
{rowFilter && (
|
||||
<span className="text-xs text-muted">{selected.size} of {rows.length} rows selected</span>
|
||||
<span className="text-xs text-gray-400">{selected.size} of {rows.length} rows selected</span>
|
||||
)}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-2 ml-4 p-2 bg-accent-soft border border-accent-line rounded flex-wrap">
|
||||
<div className="flex items-center gap-2 ml-4 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
|
||||
{allOverrideCols.map(col => (
|
||||
<AutocompleteInput
|
||||
key={col}
|
||||
className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface"
|
||||
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
|
||||
placeholder={col}
|
||||
value={bulkDraft[col] || ''}
|
||||
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
|
||||
@ -395,7 +395,7 @@ export default function Records({ source }) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }}
|
||||
className="text-xs text-accent hover:text-accent"
|
||||
className="text-xs text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
@ -404,25 +404,25 @@ export default function Records({ source }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-sm text-muted">Loading…</p>}
|
||||
{!loading && viewError && <p className="text-sm text-danger">View error: {viewError} — check field types in Sources.</p>}
|
||||
{loading && <p className="text-sm text-gray-400">Loading…</p>}
|
||||
{!loading && viewError && <p className="text-sm text-red-500">View error: {viewError} — check field types in Sources.</p>}
|
||||
{!loading && exists === false && (
|
||||
<p className="text-sm text-muted">
|
||||
No view generated yet. Go to <span className="font-medium text-ink-soft">Sources</span>, check fields as <span className="font-medium text-ink-soft">In view</span>, then click <span className="font-medium text-ink-soft">Generate view</span>.
|
||||
<p className="text-sm text-gray-400">
|
||||
No view generated yet. Go to <span className="font-medium text-gray-600">Sources</span>, check fields as <span className="font-medium text-gray-600">In view</span>, then click <span className="font-medium text-gray-600">Generate view</span>.
|
||||
</p>
|
||||
)}
|
||||
{!loading && exists && rows.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
<p className="text-sm text-gray-400">
|
||||
{filters.some(f => f.col && f.pattern) ? 'No records match the current filters.' : 'View exists but no transformed records yet. Import data and run a transform first.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && exists && rows.length > 0 && (
|
||||
<>
|
||||
<div className="bg-surface border border-line rounded overflow-auto mb-4">
|
||||
<div className="bg-white border border-gray-200 rounded overflow-auto mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted border-b border-line-soft bg-raised">
|
||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
|
||||
<th className="px-2 py-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -438,9 +438,9 @@ export default function Records({ source }) {
|
||||
const active = sort.col === col
|
||||
return (
|
||||
<th key={col} onClick={() => toggleSort(col)}
|
||||
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft">
|
||||
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600">
|
||||
{col}
|
||||
<span className="ml-1 text-muted">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
||||
<span className="ml-1 text-gray-300">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
@ -453,8 +453,8 @@ export default function Records({ source }) {
|
||||
const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id
|
||||
return (
|
||||
<tr key={i} onClick={() => openPanel(row)}
|
||||
className={`border-t border-line-soft cursor-pointer transition-colors
|
||||
${isPanelSelected ? 'bg-accent-soft' : isRowSelected ? 'bg-accent-soft' : isOverridden ? 'bg-warn-soft hover:bg-warn-soft' : 'hover:bg-raised'}`}>
|
||||
className={`border-t border-gray-50 cursor-pointer transition-colors
|
||||
${isPanelSelected ? 'bg-blue-50' : isRowSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -469,8 +469,8 @@ export default function Records({ source }) {
|
||||
{displayCols.map((col, j) => {
|
||||
const formatted = formatVal(row[col])
|
||||
return (
|
||||
<td key={j} className="px-3 py-2 text-xs text-ink-soft whitespace-nowrap max-w-48 truncate">
|
||||
{formatted === null ? <span className="text-muted">—</span> : formatted}
|
||||
<td key={j} className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap max-w-48 truncate">
|
||||
{formatted === null ? <span className="text-gray-300">—</span> : formatted}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
@ -481,12 +481,12 @@ export default function Records({ source }) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-muted">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500">
|
||||
<button onClick={prev} disabled={offset === 0}
|
||||
className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40">← Prev</button>
|
||||
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">← Prev</button>
|
||||
<span>{offset + 1}–{offset + rows.length}</span>
|
||||
<button onClick={next} disabled={rows.length < LIMIT}
|
||||
className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40">Next →</button>
|
||||
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">Next →</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -494,58 +494,58 @@ export default function Records({ source }) {
|
||||
|
||||
{/* Panel */}
|
||||
{panelOpen && (
|
||||
<div className="w-80 border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line-soft">
|
||||
<span className="text-xs font-semibold text-ink-soft uppercase tracking-wide">Record</span>
|
||||
<button onClick={closePanel} className="text-muted hover:text-muted leading-none text-lg">×</button>
|
||||
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
|
||||
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Record</span>
|
||||
<button onClick={closePanel} className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
|
||||
</div>
|
||||
|
||||
{panelLoading && <p className="text-xs text-muted p-3">Loading…</p>}
|
||||
{panelLoading && <p className="text-xs text-gray-400 p-3">Loading…</p>}
|
||||
|
||||
{selectedRecord && !panelLoading && (
|
||||
<div className="flex-1 overflow-y-auto flex flex-col min-h-0">
|
||||
{panelMsg && (
|
||||
<div className={`text-xs px-3 py-2 border-b border-line-soft ${panelMsg.ok ? 'text-ok' : 'text-danger'}`}>
|
||||
<div className={`text-xs px-3 py-2 border-b border-gray-100 ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{panelMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw fields — read only */}
|
||||
<div className="border-b border-line-soft">
|
||||
<div className="px-3 py-1.5 bg-raised border-b border-line-soft">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Raw</span>
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Raw</span>
|
||||
</div>
|
||||
{Object.entries(selectedRecord.data || {}).map(([field, val]) => (
|
||||
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0">
|
||||
<span className="text-xs font-mono text-muted w-28 shrink-0 truncate">{field}</span>
|
||||
<span className="text-xs font-mono text-muted truncate">{formatVal(val) ?? <span className="text-muted">—</span>}</span>
|
||||
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
|
||||
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
|
||||
<span className="text-xs font-mono text-gray-500 truncate">{formatVal(val) ?? <span className="text-gray-300">—</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Transformed fields — read only delta */}
|
||||
<div className="border-b border-line-soft">
|
||||
<div className="px-3 py-1.5 bg-raised border-b border-line-soft">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Transformed</span>
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Transformed</span>
|
||||
</div>
|
||||
{Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0
|
||||
? <div className="px-3 py-2 text-xs text-muted">No rule output yet.</div>
|
||||
? <div className="px-3 py-2 text-xs text-gray-300">No rule output yet.</div>
|
||||
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => (
|
||||
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0">
|
||||
<span className="text-xs font-mono text-muted w-28 shrink-0 truncate">{field}</span>
|
||||
<span className="text-xs font-mono text-accent truncate">{formatVal(val) ?? <span className="text-muted">—</span>}</span>
|
||||
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
|
||||
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
|
||||
<span className="text-xs font-mono text-blue-600 truncate">{formatVal(val) ?? <span className="text-gray-300">—</span>}</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Overrides — editable */}
|
||||
<div className="flex-1 border-b border-line-soft">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-raised border-b border-line-soft">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Overrides</span>
|
||||
<div className="flex-1 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Overrides</span>
|
||||
<button
|
||||
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||||
className="text-muted hover:text-ink-soft font-medium text-sm leading-none"
|
||||
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
|
||||
title="Add field">+</button>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
@ -559,14 +559,14 @@ export default function Records({ source }) {
|
||||
const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? ''
|
||||
const suggestions = [...(globalValues[col] || [])].sort()
|
||||
return (
|
||||
<tr key={col} className="border-t border-line-soft">
|
||||
<tr key={col} className="border-t border-gray-50">
|
||||
<td className="px-3 py-1.5 w-28 shrink-0">
|
||||
<span className="font-mono text-muted truncate block">{col}</span>
|
||||
<span className="font-mono text-gray-500 truncate block">{col}</span>
|
||||
</td>
|
||||
<td className="px-1 py-1.5">
|
||||
<AutocompleteInput
|
||||
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
|
||||
override ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft'
|
||||
override ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
|
||||
}`}
|
||||
value={override}
|
||||
placeholder={placeholder}
|
||||
@ -579,7 +579,7 @@ export default function Records({ source }) {
|
||||
{override && (
|
||||
<button
|
||||
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
|
||||
className="text-muted hover:text-danger leading-none text-base">×</button>
|
||||
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@ -589,10 +589,10 @@ export default function Records({ source }) {
|
||||
const val = overrideDraft[col] ?? ''
|
||||
const suggestions = [...(globalValues[col] || [])].sort()
|
||||
return (
|
||||
<tr key={`extra-${i}`} className="border-t border-line-soft">
|
||||
<tr key={`extra-${i}`} className="border-t border-gray-50">
|
||||
<td className="px-3 py-1.5 w-28 shrink-0">
|
||||
<input
|
||||
className="w-full text-xs font-mono border border-line rounded px-1 py-0.5 focus:outline-none focus:border-accent"
|
||||
className="w-full text-xs font-mono border border-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
|
||||
value={col}
|
||||
placeholder="field name"
|
||||
onChange={e => {
|
||||
@ -610,7 +610,7 @@ export default function Records({ source }) {
|
||||
<td className="px-1 py-1.5">
|
||||
<AutocompleteInput
|
||||
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
|
||||
val ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft'
|
||||
val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
|
||||
}`}
|
||||
value={val}
|
||||
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
|
||||
@ -622,7 +622,7 @@ export default function Records({ source }) {
|
||||
{val && (
|
||||
<button
|
||||
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
|
||||
className="text-muted hover:text-danger leading-none text-base">×</button>
|
||||
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@ -632,7 +632,7 @@ export default function Records({ source }) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 px-3 py-2 border-t border-line-soft shrink-0">
|
||||
<div className="flex gap-2 px-3 py-2 border-t border-gray-100 shrink-0">
|
||||
<button
|
||||
onClick={handleSaveOverrides}
|
||||
disabled={panelSaving || !isDirty}
|
||||
@ -643,7 +643,7 @@ export default function Records({ source }) {
|
||||
<button
|
||||
onClick={handleClearOverrides}
|
||||
disabled={panelSaving}
|
||||
className="text-xs border border-line rounded px-3 py-1.5 text-muted hover:border-danger-line hover:text-danger disabled:opacity-40">
|
||||
className="text-xs border border-gray-200 rounded px-3 py-1.5 text-gray-500 hover:border-red-300 hover:text-red-500 disabled:opacity-40">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -73,8 +73,8 @@ export default function Remap() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-4xl">
|
||||
<h1 className="text-base font-semibold text-ink mb-4">Remap Output Values</h1>
|
||||
<div className="p-6 max-w-4xl">
|
||||
<h1 className="text-base font-semibold text-gray-800 mb-4">Remap Output Values</h1>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-2 mb-5">
|
||||
@ -83,7 +83,7 @@ export default function Remap() {
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search output values…"
|
||||
className="text-sm border border-line rounded px-3 py-1.5 w-72 focus:outline-none focus:border-accent"
|
||||
className="text-sm border border-gray-300 rounded px-3 py-1.5 w-72 focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<button type="submit" disabled={searching}
|
||||
className="text-sm bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-50">
|
||||
@ -95,15 +95,15 @@ export default function Remap() {
|
||||
{results !== null && (
|
||||
<div className="mb-6">
|
||||
{results.length === 0 ? (
|
||||
<p className="text-sm text-muted">No matching output values found.</p>
|
||||
<p className="text-sm text-gray-400">No matching output values found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-muted uppercase tracking-wide mb-1">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''} — click one to remap
|
||||
</div>
|
||||
<table className="w-full text-sm border border-line rounded overflow-hidden">
|
||||
<table className="w-full text-sm border border-gray-200 rounded overflow-hidden">
|
||||
<thead>
|
||||
<tr className="bg-raised text-left text-xs text-muted uppercase tracking-wide">
|
||||
<tr className="bg-gray-50 text-left text-xs text-gray-400 uppercase tracking-wide">
|
||||
<th className="px-3 py-2">Field</th>
|
||||
<th className="px-3 py-2">Value</th>
|
||||
<th className="px-3 py-2 text-right">Mappings</th>
|
||||
@ -115,11 +115,11 @@ export default function Remap() {
|
||||
return (
|
||||
<tr key={i}
|
||||
onClick={() => handleSelect(r)}
|
||||
className={`border-t border-line-soft cursor-pointer transition-colors
|
||||
${isActive ? 'bg-accent-soft' : 'hover:bg-raised'}`}>
|
||||
<td className="px-3 py-2 font-mono text-muted">{r.col}</td>
|
||||
<td className="px-3 py-2 font-mono text-ink">{r.val}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{r.mapping_count}</td>
|
||||
className={`border-t border-gray-100 cursor-pointer transition-colors
|
||||
${isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
|
||||
<td className="px-3 py-2 font-mono text-gray-500">{r.col}</td>
|
||||
<td className="px-3 py-2 font-mono text-gray-800">{r.val}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-400">{r.mapping_count}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
@ -132,25 +132,25 @@ export default function Remap() {
|
||||
|
||||
{/* Remap panel */}
|
||||
{selected && (
|
||||
<div className="border border-line rounded p-4 mb-6 bg-surface">
|
||||
<div className="text-xs text-muted uppercase tracking-wide mb-3">
|
||||
Remap <span className="font-mono text-ink-soft">{selected.col}</span>
|
||||
<div className="border border-gray-200 rounded p-4 mb-6 bg-white">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">
|
||||
Remap <span className="font-mono text-gray-600">{selected.col}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted mb-1">From</div>
|
||||
<div className="text-sm font-mono bg-raised border border-line rounded px-3 py-1.5 text-ink-soft">
|
||||
<div className="text-xs text-gray-400 mb-1">From</div>
|
||||
<div className="text-sm font-mono bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-gray-700">
|
||||
{selected.val}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted mt-4">→</div>
|
||||
<div className="text-gray-300 mt-4">→</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted mb-1">To</div>
|
||||
<div className="text-xs text-gray-400 mb-1">To</div>
|
||||
<input
|
||||
value={toVal}
|
||||
onChange={e => setToVal(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleApply()}
|
||||
className="w-full text-sm font-mono border border-line rounded px-3 py-1.5 focus:outline-none focus:border-accent"
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
@ -164,22 +164,22 @@ export default function Remap() {
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`text-sm mb-3 ${msg.ok ? 'text-ok' : 'text-danger'}`}>
|
||||
<div className={`text-sm mb-3 ${msg.ok ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affected mappings */}
|
||||
{loadingMatches ? (
|
||||
<p className="text-xs text-muted">Loading…</p>
|
||||
<p className="text-xs text-gray-400">Loading…</p>
|
||||
) : matches && matches.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted uppercase tracking-wide mb-1">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
|
||||
Affected mappings
|
||||
</div>
|
||||
<table className="w-full text-xs border border-line-soft rounded overflow-hidden">
|
||||
<table className="w-full text-xs border border-gray-100 rounded overflow-hidden">
|
||||
<thead>
|
||||
<tr className="bg-raised text-left text-muted">
|
||||
<tr className="bg-gray-50 text-left text-gray-400">
|
||||
<th className="px-2 py-1">Source</th>
|
||||
<th className="px-2 py-1">Rule</th>
|
||||
<th className="px-2 py-1">Input</th>
|
||||
@ -188,15 +188,15 @@ export default function Remap() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.map(m => (
|
||||
<tr key={m.id} className="border-t border-line-soft">
|
||||
<td className="px-2 py-1 font-mono text-muted">{m.source_name}</td>
|
||||
<td className="px-2 py-1 font-mono text-muted">{m.rule_name}</td>
|
||||
<td className="px-2 py-1 font-mono text-ink-soft">
|
||||
<tr key={m.id} className="border-t border-gray-50">
|
||||
<td className="px-2 py-1 font-mono text-gray-500">{m.source_name}</td>
|
||||
<td className="px-2 py-1 font-mono text-gray-500">{m.rule_name}</td>
|
||||
<td className="px-2 py-1 font-mono text-gray-700">
|
||||
{typeof m.input_value === 'string' ? m.input_value : JSON.stringify(m.input_value)}
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono text-ink-soft">
|
||||
<td className="px-2 py-1 font-mono text-gray-700">
|
||||
{Object.entries(m.output).map(([k, v]) => (
|
||||
<span key={k} className={k === selected.col ? 'text-accent font-semibold' : ''}>
|
||||
<span key={k} className={k === selected.col ? 'text-blue-600 font-semibold' : ''}>
|
||||
{k}: {v}{' '}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@ -7,27 +7,27 @@ function PreviewModal({ rows, onClose }) {
|
||||
const matched = rows.filter(r => r.extracted_value != null).length
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col"
|
||||
<div className="bg-white rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
|
||||
<span className="text-sm font-medium text-ink-soft">
|
||||
Pattern results — <span className="text-muted font-normal">{matched}/{rows.length} matched</span>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Pattern results — <span className="text-gray-500 font-normal">{matched}/{rows.length} matched</span>
|
||||
</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-ink-soft text-lg leading-none">✕</button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">✕</button>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1 px-5 py-3">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-line-soft">
|
||||
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||
<th className="pb-2 font-medium w-1/2 pr-4">Raw value</th>
|
||||
<th className="pb-2 font-medium">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="border-t border-line-soft">
|
||||
<td className="py-1 font-mono text-muted pr-4 break-all">{r.raw_value}</td>
|
||||
<td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-ink' : 'text-muted'}`}>
|
||||
<tr key={i} className="border-t border-gray-50">
|
||||
<td className="py-1 font-mono text-gray-400 pr-4 break-all">{r.raw_value}</td>
|
||||
<td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}>
|
||||
{r.extracted_value != null
|
||||
? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value))
|
||||
: '—'}
|
||||
@ -67,33 +67,33 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
|
||||
}, [form.field, form.pattern, form.flags, form.function_type, form.replace_value, source])
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-line rounded p-4 mb-4">
|
||||
<h2 className="text-sm font-semibold text-ink-soft mb-3">{editing ? 'Edit rule' : 'New rule'}</h2>
|
||||
<div className="bg-white border border-gray-200 rounded p-4 mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">{editing ? 'Edit rule' : 'New rule'}</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Rule name</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Rule name</label>
|
||||
<input
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="e.g. First 20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Sequence</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Sequence</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.sequence} onChange={e => setForm(f => ({ ...f, sequence: parseInt(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Input field</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Input field</label>
|
||||
{fields.length > 0 ? (
|
||||
<select
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
|
||||
>
|
||||
<option value="">— select field —</option>
|
||||
@ -101,34 +101,34 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
|
||||
placeholder="e.g. description"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Output field</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Output field</label>
|
||||
<input
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.output_field} onChange={e => setForm(f => ({ ...f, output_field: e.target.value }))}
|
||||
placeholder="e.g. merchant"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Pattern (regex)</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Pattern (regex)</label>
|
||||
<input
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
|
||||
value={form.pattern} onChange={e => setForm(f => ({ ...f, pattern: e.target.value }))}
|
||||
placeholder="e.g. .{1,20}"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Function</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Function</label>
|
||||
<select
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.function_type} onChange={e => setForm(f => ({ ...f, function_type: e.target.value }))}
|
||||
>
|
||||
<option value="extract">extract</option>
|
||||
@ -136,16 +136,16 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Flags</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Flags</label>
|
||||
<input
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
|
||||
value={form.flags} onChange={e => setForm(f => ({ ...f, flags: e.target.value }))}
|
||||
placeholder="e.g. i"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.function_type === 'extract' && (
|
||||
<label className="flex items-center gap-2 text-xs text-ink-soft cursor-pointer select-none">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.retain}
|
||||
@ -156,9 +156,9 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
|
||||
)}
|
||||
{form.function_type === 'replace' && (
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Replacement string</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Replacement string</label>
|
||||
<input
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
|
||||
value={form.replace_value} onChange={e => setForm(f => ({ ...f, replace_value: e.target.value }))}
|
||||
placeholder="e.g. leave blank to delete the match"
|
||||
/>
|
||||
@ -166,23 +166,23 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
|
||||
)}
|
||||
{/* Live preview */}
|
||||
{(preview.length > 0 || previewing) && (
|
||||
<div className="border border-line-soft rounded p-2 bg-raised">
|
||||
<div className="border border-gray-100 rounded p-2 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xs text-muted">
|
||||
<p className="text-xs text-gray-400">
|
||||
{previewing ? 'Testing…' : `${preview.filter(r => r.extracted_value != null).length}/${preview.length} matched`}
|
||||
</p>
|
||||
{!previewing && preview.length > 0 && (
|
||||
<button type="button" onClick={() => setModalOpen(true)}
|
||||
className="text-xs text-accent hover:text-accent">expand</button>
|
||||
className="text-xs text-blue-400 hover:text-blue-600">expand</button>
|
||||
)}
|
||||
</div>
|
||||
{!previewing && (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{preview.slice(0, 5).map((r, i) => (
|
||||
<tr key={i} className="border-t border-line-soft first:border-0">
|
||||
<td className="py-0.5 font-mono text-muted truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td>
|
||||
<td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-ink' : 'text-muted'}`}>
|
||||
<tr key={i} className="border-t border-gray-100 first:border-0">
|
||||
<td className="py-0.5 font-mono text-gray-400 truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td>
|
||||
<td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}>
|
||||
{r.extracted_value != null
|
||||
? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value))
|
||||
: '—'}
|
||||
@ -197,14 +197,14 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
|
||||
|
||||
{modalOpen && <PreviewModal rows={preview} onClose={() => setModalOpen(false)} />}
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={loading}
|
||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? 'Saving…' : (editing ? 'Save' : 'Create')}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}
|
||||
className="text-sm text-muted px-3 py-1.5 rounded hover:bg-raised">
|
||||
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@ -310,12 +310,12 @@ export default function Rules({ source, onStale }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-3xl">
|
||||
<div className="p-6 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-semibold text-ink">Rules — {source}</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-800">Rules — {source}</h1>
|
||||
<button onClick={startCreate}
|
||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
|
||||
New rule
|
||||
@ -332,17 +332,17 @@ export default function Rules({ source, onStale }) {
|
||||
)}
|
||||
|
||||
{rules.length === 0 && !creating && (
|
||||
<p className="text-sm text-muted">No rules yet. Add a regex rule to start extracting values.</p>
|
||||
<p className="text-sm text-gray-400">No rules yet. Add a regex rule to start extracting values.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{rules.map(rule => {
|
||||
const isExpanded = expanded === rule.id
|
||||
return (
|
||||
<div key={rule.id} className="bg-surface border border-line rounded">
|
||||
<div key={rule.id} className="bg-white border border-gray-200 rounded">
|
||||
{/* Header — always visible, click to expand/collapse */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-raised select-none"
|
||||
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 select-none"
|
||||
onClick={() => {
|
||||
if (isExpanded) { setExpanded(null); setEditing(null) }
|
||||
else { setExpanded(rule.id); startEdit(rule) }
|
||||
@ -350,33 +350,33 @@ export default function Rules({ source, onStale }) {
|
||||
>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleToggle(rule) }}
|
||||
className={`w-8 h-4 rounded-full flex-shrink-0 transition-colors ${rule.enabled ? 'bg-blue-500' : 'bg-raised'}`}
|
||||
className={`w-8 h-4 rounded-full flex-shrink-0 transition-colors ${rule.enabled ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||
title={rule.enabled ? 'Disable' : 'Enable'}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-ink text-sm">{rule.name}</span>
|
||||
<span className="text-muted text-xs ml-2">seq {rule.sequence}</span>
|
||||
<span className="font-medium text-gray-800 text-sm">{rule.name}</span>
|
||||
<span className="text-gray-400 text-xs ml-2">seq {rule.sequence}</span>
|
||||
{!isExpanded && (
|
||||
<div className="text-xs text-muted mt-0.5 truncate">
|
||||
<div className="text-xs text-gray-400 mt-0.5 truncate">
|
||||
<span className="font-mono">{rule.field}</span>
|
||||
<span className="mx-1">→</span>
|
||||
<span className="font-mono bg-raised px-1 rounded">{rule.pattern}</span>
|
||||
{rule.flags && <span className="text-accent ml-1">/{rule.flags}</span>}
|
||||
<span className="font-mono bg-gray-50 px-1 rounded">{rule.pattern}</span>
|
||||
{rule.flags && <span className="text-blue-400 ml-1">/{rule.flags}</span>}
|
||||
<span className="mx-1">→</span>
|
||||
<span className="font-mono">{rule.output_field}</span>
|
||||
{rule.function_type === 'replace' && <span className="ml-1 text-warn">(replace)</span>}
|
||||
{rule.function_type === 'replace' && <span className="ml-1 text-orange-400">(replace)</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted flex-shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||
<span className="text-xs text-gray-300 flex-shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-line-soft">
|
||||
<div className="border-t border-gray-100">
|
||||
<div className="px-4 pt-3 pb-1 flex justify-end">
|
||||
<button onClick={e => { e.stopPropagation(); handleDelete(rule.id) }}
|
||||
className="text-xs text-danger hover:text-danger">Delete</button>
|
||||
className="text-xs text-red-400 hover:text-red-600">Delete</button>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<FormPanel
|
||||
|
||||
@ -156,15 +156,15 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceObj) return <div className="p-4 sm:p-6 text-sm text-muted">Source not found.</div>
|
||||
if (!sourceObj) return <div className="p-6 text-sm text-gray-400">Source not found.</div>
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-5xl space-y-4">
|
||||
<div className="p-6 max-w-5xl space-y-4">
|
||||
{stats && (
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="text-muted"><span className="font-medium text-ink">{stats.total_records}</span> total</span>
|
||||
<span className="text-muted"><span className="font-medium text-ink">{stats.transformed_records}</span> transformed</span>
|
||||
<span className="text-muted"><span className="font-medium text-ink">{stats.pending_records}</span> pending</span>
|
||||
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.total_records}</span> total</span>
|
||||
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.transformed_records}</span> transformed</span>
|
||||
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.pending_records}</span> pending</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -176,13 +176,13 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{bridgeAccounts === null ? (
|
||||
<>
|
||||
<span className="text-xs text-muted font-mono">
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
{sourceObj.config?.simplefin?.account_id || 'not linked'}
|
||||
</span>
|
||||
<button
|
||||
onClick={loadBridgeAccounts}
|
||||
disabled={bridgeLoading}
|
||||
className="text-xs border border-line rounded px-2 py-1 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
|
||||
className="text-xs border border-gray-300 rounded px-2 py-1 text-gray-600 hover:bg-gray-50 hover:border-gray-400 disabled:opacity-50"
|
||||
>
|
||||
{bridgeLoading ? 'Loading…' : sourceObj.config?.simplefin?.account_id ? 'Change' : 'Link SimpleFIN account'}
|
||||
</button>
|
||||
@ -191,7 +191,7 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
<select
|
||||
value={sourceObj.config?.simplefin?.account_id || ''}
|
||||
onChange={e => handleLinkAccount(e.target.value)}
|
||||
className="text-xs border border-line rounded px-2 py-1 bg-surface text-ink-soft"
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white text-gray-700"
|
||||
>
|
||||
<option value="">Not linked</option>
|
||||
{bridgeAccounts.map(a => (
|
||||
@ -203,12 +203,12 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bridgeError && <p className="text-xs text-warn mt-1">{bridgeError}</p>}
|
||||
{bridgeError && <p className="text-xs text-orange-600 mt-1">{bridgeError}</p>}
|
||||
|
||||
{/* Dedupe depends on the transaction id being the constraint key */}
|
||||
{sourceObj.config?.simplefin?.account_id
|
||||
&& sourceObj.constraint_fields?.join(',') !== 'id' && (
|
||||
<p className="text-xs text-warn mt-1">
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
Constraint fields are “{sourceObj.constraint_fields?.join(', ') || 'none'}” — a
|
||||
bank feed should use “id” so re-syncs don’t duplicate rows.
|
||||
</p>
|
||||
@ -223,7 +223,7 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-line-soft">
|
||||
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||
{[
|
||||
{ col: 'key', label: 'Key' },
|
||||
{ col: 'origin', label: 'Origin' },
|
||||
@ -235,10 +235,10 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
<th
|
||||
key={col}
|
||||
onClick={() => setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))}
|
||||
className={`pb-1 font-medium cursor-pointer select-none hover:text-ink-soft ${center ? 'text-center' : ''}`}
|
||||
className={`pb-1 font-medium cursor-pointer select-none hover:text-gray-600 ${center ? 'text-center' : ''}`}
|
||||
>
|
||||
{label}
|
||||
<span className="ml-1 text-muted">
|
||||
<span className="ml-1 text-gray-300">
|
||||
{fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'}
|
||||
</span>
|
||||
</th>
|
||||
@ -266,14 +266,14 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
const schemaEntry = schemaFields.find(sf => sf.name === f.key)
|
||||
const inView = !!schemaEntry
|
||||
return (
|
||||
<tr key={f.key} className="border-t border-line-soft">
|
||||
<td className="py-1 font-mono text-ink-soft">{f.key}</td>
|
||||
<td className="py-1 text-muted">{f.origins.join(', ')}</td>
|
||||
<tr key={f.key} className="border-t border-gray-50">
|
||||
<td className="py-1 font-mono text-gray-700">{f.key}</td>
|
||||
<td className="py-1 text-gray-400">{f.origins.join(', ')}</td>
|
||||
<td className="py-1">
|
||||
{inView && (
|
||||
<div className="flex gap-1 items-center">
|
||||
<select
|
||||
className="border border-line rounded px-1 py-0.5 text-xs focus:outline-none focus:border-accent"
|
||||
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400"
|
||||
value={schemaEntry.type}
|
||||
onChange={e => setSchemaFields(sf =>
|
||||
sf.map(s => s.name === f.key ? { ...s, type: e.target.value } : s)
|
||||
@ -282,7 +282,7 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<input
|
||||
className="border border-line rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-accent"
|
||||
className="border border-gray-200 rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-blue-400"
|
||||
value={schemaEntry.expression || ''}
|
||||
placeholder="{field} * {sign}"
|
||||
onChange={e => setSchemaFields(sf =>
|
||||
@ -327,7 +327,7 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
{inView && (
|
||||
<input
|
||||
type="number"
|
||||
className="w-12 border border-line rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-accent"
|
||||
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400"
|
||||
value={schemaEntry.seq ?? ''}
|
||||
onChange={e => setSchemaFields(sf =>
|
||||
sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
|
||||
@ -341,8 +341,8 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="flex items-center gap-3 pt-3 mt-2 border-t border-line-soft flex-wrap">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
||||
<div className="flex items-center gap-3 pt-3 mt-2 border-t border-gray-100 flex-wrap">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
||||
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
|
||||
Global picklist
|
||||
</label>
|
||||
@ -362,7 +362,7 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
{generating ? 'Generating…' : 'Generate view'}
|
||||
</button>
|
||||
{viewName && (
|
||||
<code className="text-xs bg-raised px-2 py-1 rounded text-ink-soft">{viewName}</code>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-600">{viewName}</code>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@ -377,7 +377,7 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
description="No fields yet — they are discovered from imported records. Import or sync data first."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
||||
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
|
||||
Global picklist
|
||||
</label>
|
||||
@ -406,16 +406,16 @@ export default function SourceDetail({ sources, setSources }) {
|
||||
>
|
||||
{reprocessing ? 'Reprocessing…' : 'Reprocess all records'}
|
||||
</button>
|
||||
<span className="text-xs text-muted">Clears and reruns all transformation rules</span>
|
||||
<span className="text-xs text-gray-400">Clears and reruns all transformation rules</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{result && <p className="text-xs text-ok">{result}</p>}
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{result && <p className="text-xs text-green-600">{result}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<Section title="Delete source" description="Removes the source and every record, rule, and mapping belonging to it.">
|
||||
<button onClick={handleDelete}
|
||||
className="text-sm border border-danger-line text-danger px-3 py-1.5 rounded hover:bg-danger-soft hover:border-danger-line">
|
||||
className="text-sm border border-red-200 text-red-500 px-3 py-1.5 rounded hover:bg-red-50 hover:border-red-300">
|
||||
Delete source
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
@ -124,9 +124,9 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-5xl">
|
||||
<div className="p-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-semibold text-ink">Sources</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-800">Sources</h1>
|
||||
{!creating && (
|
||||
<button
|
||||
onClick={() => { setCreating(true); setCreateError('') }}
|
||||
@ -138,42 +138,42 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
</div>
|
||||
|
||||
{!creating && sources.length === 0 && (
|
||||
<p className="text-sm text-muted">No sources yet. Create one to get started.</p>
|
||||
<p className="text-sm text-gray-400">No sources yet. Create one to get started.</p>
|
||||
)}
|
||||
|
||||
{!creating && sources.length > 0 && (
|
||||
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
|
||||
<div className="bg-white border border-gray-200 rounded divide-y divide-gray-100">
|
||||
{sources.map(s => (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => { setSource(s.name); navigate(`/sources/${encodeURIComponent(s.name)}`) }}
|
||||
className="w-full text-left px-4 py-3 hover:bg-raised flex items-center gap-3"
|
||||
className="w-full text-left px-4 py-3 hover:bg-gray-50 flex items-center gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium text-ink flex-1">{s.name}</span>
|
||||
<span className="text-sm font-medium text-gray-800 flex-1">{s.name}</span>
|
||||
{s.config?.simplefin?.account_id && (
|
||||
<span className="text-xs bg-accent-soft text-accent border border-accent-line rounded px-1.5 py-0.5">
|
||||
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded px-1.5 py-0.5">
|
||||
bank feed
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted">
|
||||
<span className="text-xs text-gray-400">
|
||||
{(s.constraint_fields || []).join(', ') || 'no constraint'}
|
||||
</span>
|
||||
<span className="text-muted">›</span>
|
||||
<span className="text-gray-300">›</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<div className="bg-surface border border-line rounded p-4">
|
||||
<h2 className="text-sm font-semibold text-ink-soft mb-3">New source</h2>
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">New source</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<input type="file" accept=".csv" ref={fileRef} onChange={handleSuggest} className="hidden" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line"
|
||||
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400"
|
||||
>
|
||||
{csvFileName || 'Choose CSV…'}
|
||||
</button>
|
||||
@ -181,9 +181,9 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Source name</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Source name</label>
|
||||
<input
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="e.g. chase, dcard"
|
||||
@ -193,14 +193,14 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
{/* Bank feed — optional; picking an account defaults the constraint
|
||||
field to the transaction id, which is what dedupe needs */}
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Bank feed (optional)</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Bank feed (optional)</label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{bridgeAccounts === null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadBridgeAccounts}
|
||||
disabled={bridgeLoading}
|
||||
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
|
||||
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400 disabled:opacity-50"
|
||||
>
|
||||
{bridgeLoading ? 'Loading…' : 'Link SimpleFIN account…'}
|
||||
</button>
|
||||
@ -208,7 +208,7 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
<select
|
||||
value={form.simplefin_account_id || ''}
|
||||
onChange={e => handleSelectFeedAccount(e.target.value)}
|
||||
className="text-sm border border-line rounded px-3 py-1.5 bg-surface text-ink-soft"
|
||||
className="text-sm border border-gray-200 rounded px-3 py-1.5 bg-white text-gray-700"
|
||||
>
|
||||
<option value="">No bank feed — CSV import</option>
|
||||
{bridgeAccounts.map(a => (
|
||||
@ -219,10 +219,10 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{bridgeError && <p className="text-xs text-warn mt-1">{bridgeError}</p>}
|
||||
{bridgeError && <p className="text-xs text-orange-600 mt-1">{bridgeError}</p>}
|
||||
|
||||
{form.simplefin_account_id && sampleInfo && (
|
||||
<div className="mt-2 bg-accent-soft border border-accent-line rounded p-3 text-xs text-ink-soft space-y-1">
|
||||
<div className="mt-2 bg-blue-50 border border-blue-100 rounded p-3 text-xs text-gray-600 space-y-1">
|
||||
<p>
|
||||
Read {sampleInfo.fetched} transaction{sampleInfo.fetched === 1 ? '' : 's'} from this
|
||||
account and found {sampleInfo.fields} field{sampleInfo.fields === 1 ? '' : 's'}.
|
||||
@ -231,16 +231,16 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
</p>
|
||||
{form.constraint_fields === 'id' && (
|
||||
<p>
|
||||
<span className="font-mono text-ink-soft">id</span> is checked as the constraint
|
||||
<span className="font-mono text-gray-700">id</span> is checked as the constraint
|
||||
field because it is SimpleFIN’s own transaction identifier. Syncs pull an
|
||||
overlapping window of days, so the same transaction arrives more than once —
|
||||
matching on <span className="font-mono text-ink-soft">id</span> skips the repeats
|
||||
matching on <span className="font-mono text-gray-700">id</span> skips the repeats
|
||||
while still keeping genuinely separate charges that share a date, amount, and
|
||||
description.
|
||||
</p>
|
||||
)}
|
||||
{sampleInfo.fetched === 0 && (
|
||||
<p className="text-warn">
|
||||
<p className="text-orange-600">
|
||||
No transactions came back, so there was nothing to infer fields from. Sync first,
|
||||
then set the fields up here.
|
||||
</p>
|
||||
@ -250,10 +250,10 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
</div>
|
||||
|
||||
{form.fields.length > 0 && (
|
||||
<div className="pt-2 border-t border-line-soft space-y-2">
|
||||
<div className="pt-2 border-t border-gray-100 space-y-2">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-line-soft">
|
||||
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||
<th className="pb-1 font-medium">Key</th>
|
||||
<th className="pb-1 font-medium">Type</th>
|
||||
<th className="pb-1 font-medium text-center">Constraint</th>
|
||||
@ -267,12 +267,12 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
const inView = !!schemaEntry
|
||||
const currentType = schemaEntry?.type || f.type
|
||||
return (
|
||||
<tr key={f.name} className="border-t border-line-soft">
|
||||
<td className="py-1 font-mono text-ink-soft">{f.name}</td>
|
||||
<tr key={f.name} className="border-t border-gray-50">
|
||||
<td className="py-1 font-mono text-gray-700">{f.name}</td>
|
||||
<td className="py-1">
|
||||
{inView && (
|
||||
<select
|
||||
className="border border-line rounded px-1 py-0.5 text-xs focus:outline-none focus:border-accent"
|
||||
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400"
|
||||
value={currentType}
|
||||
onChange={e => setForm(ff => ({
|
||||
...ff,
|
||||
@ -316,7 +316,7 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
{inView && (
|
||||
<input
|
||||
type="number"
|
||||
className="w-12 border border-line rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-accent"
|
||||
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400"
|
||||
value={schemaEntry.seq ?? ''}
|
||||
onChange={e => setForm(ff => ({
|
||||
...ff,
|
||||
@ -336,9 +336,9 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
|
||||
{form.fields.length === 0 && (
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Constraint fields (comma-separated)</label>
|
||||
<label className="text-xs text-gray-500 block mb-1">Constraint fields (comma-separated)</label>
|
||||
<input
|
||||
className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.constraint_fields}
|
||||
onChange={e => setForm(f => ({ ...f, constraint_fields: e.target.value }))}
|
||||
placeholder="e.g. date, amount, description"
|
||||
@ -347,7 +347,7 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.global_picklist !== false}
|
||||
@ -356,7 +356,7 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
Global picklist
|
||||
</label>
|
||||
{form.fields.length > 0 && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.importSample !== false}
|
||||
@ -367,7 +367,7 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createError && <p className="text-xs text-danger">{createError}</p>}
|
||||
{createError && <p className="text-xs text-red-500">{createError}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={createLoading}
|
||||
@ -376,7 +376,7 @@ export default function SourceList({ sources, setSources, setSource }) {
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { setCreating(false); setCreateError(''); setForm({ name: '', constraint_fields: '', fields: [], schema: [] }) }}
|
||||
className="text-sm text-muted px-3 py-1.5 rounded hover:bg-raised">
|
||||
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -54,54 +54,54 @@ function CalibrateModal({ stack, sourceName, currentOffset, onClose, onApply })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-semibold text-ink-soft">Calibrate — {sourceName}</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-ink-soft">✕</button>
|
||||
<span className="text-sm font-semibold text-gray-700">Calibrate — {sourceName}</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs text-muted block mb-1">As-of date</label>
|
||||
<input type="date" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
<label className="text-xs text-gray-500 block mb-1">As-of date</label>
|
||||
<input type="date" className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* Reconciliation table */}
|
||||
<div className="bg-raised rounded border border-line mb-4 text-sm">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||||
<span className="text-muted text-xs">Data sum at date</span>
|
||||
<span className="font-mono text-ink-soft">
|
||||
{loading ? <span className="text-muted">…</span> : computed !== null ? fmt(computed) : <span className="text-muted">—</span>}
|
||||
<div className="bg-gray-50 rounded border border-gray-200 mb-4 text-sm">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<span className="text-gray-500 text-xs">Data sum at date</span>
|
||||
<span className="font-mono text-gray-700">
|
||||
{loading ? <span className="text-gray-300">…</span> : computed !== null ? fmt(computed) : <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||||
<span className="text-muted text-xs">Known balance</span>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<span className="text-gray-500 text-xs">Known balance</span>
|
||||
<input
|
||||
type="number" step="0.01"
|
||||
className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-ink-soft placeholder-gray-300"
|
||||
className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-gray-700 placeholder-gray-300"
|
||||
placeholder="enter balance"
|
||||
value={known} onChange={e => setKnown(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||||
<span className="text-muted text-xs">Current offset</span>
|
||||
<span className="font-mono text-muted">{fmt(currentOffset ?? 0)}</span>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<span className="text-gray-500 text-xs">Current offset</span>
|
||||
<span className="font-mono text-gray-400">{fmt(currentOffset ?? 0)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 py-2 font-medium">
|
||||
<span className="text-ink-soft text-xs">Plug (offset needed)</span>
|
||||
<span className={`font-mono ${plug !== null ? 'text-accent' : 'text-muted'}`}>
|
||||
<span className="text-gray-700 text-xs">Plug (offset needed)</span>
|
||||
<span className={`font-mono ${plug !== null ? 'text-blue-700' : 'text-gray-300'}`}>
|
||||
{plug !== null ? fmt(plug) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-danger mb-3">{error}</p>}
|
||||
{error && <p className="text-xs text-red-500 mb-3">{error}</p>}
|
||||
|
||||
{/* Apply */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<input type="number" step="0.01"
|
||||
className="flex-1 border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||||
className="flex-1 border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
|
||||
placeholder="offset to apply"
|
||||
value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
|
||||
<button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === '' || isNaN(parseFloat(applyOffset))}
|
||||
@ -434,12 +434,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Label */}
|
||||
<div className="bg-surface border border-line rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-soft mb-3">Configuration</h3>
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Configuration</h3>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted block mb-1">Label <span className="text-muted">(optional)</span></label>
|
||||
<input className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||||
<label className="text-xs text-gray-500 block mb-1">Label <span className="text-gray-400">(optional)</span></label>
|
||||
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={label} onChange={e => setLabel(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && saveLabel()} />
|
||||
</div>
|
||||
@ -448,13 +448,13 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-danger mt-2">{error}</p>}
|
||||
{error && <p className="text-xs text-red-500 mt-2">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Sources */}
|
||||
<div className="bg-surface border border-line rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-soft mb-1">Sources</h3>
|
||||
<p className="text-xs text-muted mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance — use Calibrate to compute it from a known good balance.</p>
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance — use Calibrate to compute it from a known good balance.</p>
|
||||
<div className="space-y-2 mb-3">
|
||||
{members.map((m, idx) => {
|
||||
const cfg = srcCfg[m.source_name] || {}
|
||||
@ -467,50 +467,50 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
onDragOver={e => handleSrcDragOver(e, idx)}
|
||||
onDrop={e => handleSrcDrop(e, idx)}
|
||||
onDragEnd={() => { setSrcDragIdx(null); setSrcDragOverIdx(null) }}
|
||||
className={`border border-line-soft rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-accent-soft' : ''}`}>
|
||||
className={`border border-gray-100 rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-blue-50' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted cursor-grab select-none">⠿</span>
|
||||
<span className="font-medium text-ink-soft flex-1">{m.source_name}</span>
|
||||
<button onClick={() => removeSource(m.source_name)} className="text-danger hover:text-danger">Remove</button>
|
||||
<span className="text-gray-300 cursor-grab select-none">⠿</span>
|
||||
<span className="font-medium text-gray-700 flex-1">{m.source_name}</span>
|
||||
<button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
<div>
|
||||
<label className="text-muted block mb-0.5">Amount field</label>
|
||||
<label className="text-gray-400 block mb-0.5">Amount field</label>
|
||||
<select value={cfg.amount_field || ''}
|
||||
onChange={e => handleSrcAmountField(m.source_name, e.target.value)}
|
||||
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||||
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
|
||||
<option value="">— select —</option>
|
||||
{sf.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted block mb-0.5">Sign</label>
|
||||
<label className="text-gray-400 block mb-0.5">Sign</label>
|
||||
<select value={cfg.sign ?? 1}
|
||||
onChange={e => { setSrcSign(m.source_name, parseInt(e.target.value)); setMappingsDirty(true) }}
|
||||
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||||
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
|
||||
<option value={1}>+1 (as-is)</option>
|
||||
<option value={-1}>−1 (flip)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted block mb-0.5">Date field</label>
|
||||
<label className="text-gray-400 block mb-0.5">Date field</label>
|
||||
<select value={cfg.date_field || ''}
|
||||
onChange={e => handleSrcDateField(m.source_name, e.target.value)}
|
||||
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||||
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
|
||||
<option value="">— select —</option>
|
||||
{sf.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted block mb-0.5">Balance offset</label>
|
||||
<label className="text-gray-400 block mb-0.5">Balance offset</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input type="number" step="0.01" value={cfg.offset ?? 0}
|
||||
onChange={e => { setSrcOffset(m.source_name, parseFloat(e.target.value) || 0); setMappingsDirty(true) }}
|
||||
className="flex-1 border border-line rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-accent" />
|
||||
className="flex-1 border border-gray-200 rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-blue-400" />
|
||||
<button onClick={() => handleCalibrate(m.source_name)}
|
||||
disabled={!canCalibrate}
|
||||
title={!canCalibrate ? 'Set amount and date fields first' : 'Calibrate balance'}
|
||||
className="text-accent hover:text-accent underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
|
||||
className="text-blue-400 hover:text-blue-600 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
|
||||
Calibrate
|
||||
</button>
|
||||
</div>
|
||||
@ -519,43 +519,43 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{members.length === 0 && <p className="text-xs text-muted">No sources added yet.</p>}
|
||||
{members.length === 0 && <p className="text-xs text-gray-400">No sources added yet.</p>}
|
||||
</div>
|
||||
{availableSources.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<select className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||||
<select className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={addingSrc} onChange={e => setAddingSrc(e.target.value)}>
|
||||
<option value="">— add source —</option>
|
||||
{availableSources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||
</select>
|
||||
<button onClick={addSource} disabled={!addingSrc}
|
||||
className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft disabled:opacity-40">Add</button>
|
||||
className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700 disabled:opacity-40">Add</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output columns mapping grid */}
|
||||
<div className="bg-surface border border-line rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-soft mb-1">Output columns</h3>
|
||||
<p className="text-xs text-muted mb-3">
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">Output columns</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Each row is a column in the combined view. Each source column shows which field from that source maps to it.
|
||||
The first <span className="text-accent">numeric</span> field drives the running balance; the first <span className="text-ok">date</span> field drives the ordering.
|
||||
The first <span className="text-blue-500">numeric</span> field drives the running balance; the first <span className="text-green-600">date</span> field drives the ordering.
|
||||
Both <span className="font-mono">source_balance</span> (per-source) and <span className="font-mono">net_balance</span> (combined) are always included in the generated view.
|
||||
Drag rows to reorder.
|
||||
</p>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<p className="text-xs text-muted mb-3">Add sources above first.</p>
|
||||
<p className="text-xs text-gray-400 mb-3">Add sources above first.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto mb-3">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-line">
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="w-5 pb-2"></th>
|
||||
<th className="text-left text-muted font-normal pb-2 pr-4">Column</th>
|
||||
<th className="text-left text-muted font-normal pb-2 pr-4">Type</th>
|
||||
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Column</th>
|
||||
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Type</th>
|
||||
{members.map(m => (
|
||||
<th key={m.source_name} className="text-left text-muted font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
|
||||
<th key={m.source_name} className="text-left text-gray-400 font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
|
||||
))}
|
||||
<th className="w-5 pb-2"></th>
|
||||
</tr>
|
||||
@ -571,21 +571,21 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
onDragOver={e => handleDragOver(e, idx)}
|
||||
onDrop={e => handleDrop(e, idx)}
|
||||
onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }}
|
||||
className={`border-b border-line-soft ${dragOverIdx === idx && dragIdx !== idx ? 'bg-accent-soft' : ''}`}>
|
||||
<td className="py-1.5 pr-1 text-muted cursor-grab select-none">⠿</td>
|
||||
<td className="py-1.5 pr-4 font-mono text-ink-soft whitespace-nowrap">
|
||||
className={`border-b border-gray-50 ${dragOverIdx === idx && dragIdx !== idx ? 'bg-blue-50' : ''}`}>
|
||||
<td className="py-1.5 pr-1 text-gray-300 cursor-grab select-none">⠿</td>
|
||||
<td className="py-1.5 pr-4 font-mono text-gray-700 whitespace-nowrap">
|
||||
{f.name}
|
||||
{isAmount && <span className="ml-1.5 text-accent font-sans font-normal">amount</span>}
|
||||
{isDate && <span className="ml-1.5 text-ok font-sans font-normal">date</span>}
|
||||
{isAmount && <span className="ml-1.5 text-blue-500 font-sans font-normal">amount</span>}
|
||||
{isDate && <span className="ml-1.5 text-green-600 font-sans font-normal">date</span>}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-muted">{f.type}</td>
|
||||
<td className="py-1.5 pr-4 text-gray-400">{f.type}</td>
|
||||
{members.map(m => (
|
||||
<td key={m.source_name} className="py-1.5 pr-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={getMappingValue(m.source_name, f.name)}
|
||||
onChange={e => setMappingValue(m.source_name, f.name, e.target.value)}
|
||||
className="border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent min-w-0 flex-1">
|
||||
className="border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400 min-w-0 flex-1">
|
||||
<option value="">— same name —</option>
|
||||
{(srcFields[m.source_name] || []).map(sf => (
|
||||
<option key={sf} value={sf}>{sf}</option>
|
||||
@ -595,13 +595,13 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1.5">
|
||||
<button onClick={() => removeField(f.name)} className="text-danger hover:text-danger">✕</button>
|
||||
<button onClick={() => removeField(f.name)} className="text-red-300 hover:text-red-500">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{fields.length === 0 && (
|
||||
<tr><td colSpan={3 + members.length} className="py-3 text-muted text-center">No columns defined yet — add one below.</td></tr>
|
||||
<tr><td colSpan={3 + members.length} className="py-3 text-gray-400 text-center">No columns defined yet — add one below.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -610,15 +610,15 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
|
||||
{/* Add field */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||||
<input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="column name" value={newField.name}
|
||||
onChange={e => setNewField(f => ({ ...f, name: e.target.value }))}
|
||||
onKeyDown={e => e.key === 'Enter' && addField()} />
|
||||
<select className="border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||||
<select className="border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
|
||||
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<button onClick={addField} className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft">Add</button>
|
||||
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
|
||||
</div>
|
||||
|
||||
{mappingsDirty && (
|
||||
@ -630,12 +630,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
</div>
|
||||
|
||||
{/* Generate view + balance */}
|
||||
<div className="bg-surface border border-line rounded p-4">
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ink-soft">View</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-700">View</h3>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetchBalance}
|
||||
className="text-sm bg-raised text-ink-soft px-3 py-1.5 rounded hover:bg-raised">
|
||||
className="text-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded hover:bg-gray-200">
|
||||
Refresh balance
|
||||
</button>
|
||||
<button onClick={generateView}
|
||||
@ -646,18 +646,18 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
</div>
|
||||
{netBalance !== null && (
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="text-xs text-muted">Current net balance</span>
|
||||
<span className="text-lg font-mono font-semibold text-ink">
|
||||
<span className="text-xs text-gray-500">Current net balance</span>
|
||||
<span className="text-lg font-mono font-semibold text-gray-800">
|
||||
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{balanceError && <p className="text-xs text-muted mb-3">{balanceError}</p>}
|
||||
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
|
||||
{viewResult && !viewResult.success && (
|
||||
<p className="text-xs text-danger">{viewResult.error}</p>
|
||||
<p className="text-xs text-red-500">{viewResult.error}</p>
|
||||
)}
|
||||
{viewResult && viewResult.success && (
|
||||
<p className="text-xs text-ok">View created: <span className="font-mono">{viewResult.view}</span></p>
|
||||
<p className="text-xs text-green-600">View created: <span className="font-mono">{viewResult.view}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -748,31 +748,31 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
|
||||
<div className="p-6">
|
||||
{/* Stack list — horizontal row of cards */}
|
||||
<div className="flex items-center gap-2 mb-5 flex-wrap">
|
||||
<h1 className="text-sm font-semibold text-ink mr-1">Stacks</h1>
|
||||
<h1 className="text-sm font-semibold text-gray-800 mr-1">Stacks</h1>
|
||||
{stacks.map(s => (
|
||||
<div key={s.name}
|
||||
onClick={() => loadDetail(s.name)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-accent-line bg-accent-soft text-accent' : 'border-line bg-surface text-ink-soft hover:border-line hover:bg-raised'}`}>
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}>
|
||||
<span className="font-medium">{s.label || s.name}</span>
|
||||
<span className="text-muted">{s.source_count}s</span>
|
||||
<span className="text-gray-400">{s.source_count}s</span>
|
||||
<Link to={`/stacks/${encodeURIComponent(s.name)}/pivot`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-accent underline decoration-transparent hover:decoration-inherit">pivot</Link>
|
||||
className="opacity-0 group-hover:opacity-100 text-blue-400 hover:text-blue-600">pivot</Link>
|
||||
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-danger hover:text-danger leading-none ml-2 pl-2 border-l border-line">✕</button>
|
||||
className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 leading-none">✕</button>
|
||||
</div>
|
||||
))}
|
||||
{creating ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input autoFocus className="border border-accent rounded px-2 py-1 text-xs focus:outline-none w-32"
|
||||
<input autoFocus className="border border-blue-400 rounded px-2 py-1 text-xs focus:outline-none w-32"
|
||||
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
|
||||
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">Create</button>
|
||||
<button onClick={() => setCreating(false)} className="text-xs text-muted px-1">✕</button>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
<button onClick={() => setCreating(false)} className="text-xs text-gray-400 px-1">✕</button>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setCreating(true)} className="text-xs text-accent hover:text-accent px-2 py-1.5">+ New</button>
|
||||
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700 px-2 py-1.5">+ New</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -780,9 +780,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
|
||||
<div className="flex gap-6 items-start">
|
||||
{/* Left: config panel */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-semibold text-ink mb-4">
|
||||
<h2 className="text-base font-semibold text-gray-800 mb-4">
|
||||
{stackDetail.label || stackDetail.name}
|
||||
{stackDetail.label && <span className="text-sm text-muted font-normal ml-2">{stackDetail.name}</span>}
|
||||
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
|
||||
</h2>
|
||||
<StackPanel
|
||||
key={stackDetail.name}
|
||||
@ -797,9 +797,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
|
||||
|
||||
{/* Right: SQL panel */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-surface border border-line rounded p-4 sticky top-4">
|
||||
<div className="bg-white border border-gray-200 rounded p-4 sticky top-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ink-soft">Generated SQL</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generated SQL</h3>
|
||||
<button
|
||||
onClick={runSql}
|
||||
disabled={!sqlDraft.trim() || sqlRunning}
|
||||
@ -809,17 +809,17 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
|
||||
</div>
|
||||
{sqlDraft ? (
|
||||
<textarea
|
||||
className="w-full font-mono text-xs text-ink-soft bg-raised border border-line rounded p-2 focus:outline-none focus:border-accent resize-none leading-relaxed"
|
||||
className="w-full font-mono text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-2 focus:outline-none focus:border-blue-400 resize-none leading-relaxed"
|
||||
style={{ minHeight: '60vh' }}
|
||||
value={sqlDraft}
|
||||
onChange={e => { setSqlDraft(e.target.value); setSqlResult(null) }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted">Generate a view to see the SQL here.</p>
|
||||
<p className="text-xs text-gray-400">Generate a view to see the SQL here.</p>
|
||||
)}
|
||||
{sqlResult && (
|
||||
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-ok' : 'text-danger'}`}>
|
||||
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{sqlResult.success ? 'View updated successfully.' : sqlResult.error}
|
||||
</p>
|
||||
)}
|
||||
@ -827,7 +827,7 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">Select a stack or create one.</p>
|
||||
<p className="text-sm text-gray-400">Select a stack or create one.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user