Initial commit: intercompany ledger app

This commit is contained in:
leefer
2026-08-06 23:46:04 +08:00
commit b3043f9430
60 changed files with 4280 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
.venv/
.tmp-*/
+274
View File
@@ -0,0 +1,274 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-06T22:59:45.9531815+08:00",
"title": "Design System: Intercompany Ledger",
"extensions": {
"colorMeta": {
"canvas": {
"role": "neutral",
"displayName": "Void Canvas",
"canonical": "#050706",
"tonalRamp": ["oklch(15% 0.008 150)", "oklch(25% 0.008 150)", "oklch(35% 0.008 150)", "oklch(47% 0.008 150)", "oklch(59% 0.008 150)", "oklch(71% 0.008 150)", "oklch(83% 0.008 150)", "oklch(95% 0.008 150)"]
},
"canvas-soft": {
"role": "neutral",
"displayName": "Soft Void",
"canonical": "#090d0b",
"tonalRamp": ["oklch(15% 0.012 150)", "oklch(25% 0.012 150)", "oklch(35% 0.012 150)", "oklch(47% 0.012 150)", "oklch(59% 0.012 150)", "oklch(71% 0.012 150)", "oklch(83% 0.012 150)", "oklch(95% 0.012 150)"]
},
"navigation": {
"role": "neutral",
"displayName": "Navigation Graphite",
"canonical": "#090c0b",
"tonalRamp": ["oklch(15% 0.009 150)", "oklch(25% 0.009 150)", "oklch(35% 0.009 150)", "oklch(47% 0.009 150)", "oklch(59% 0.009 150)", "oklch(71% 0.009 150)", "oklch(83% 0.009 150)", "oklch(95% 0.009 150)"]
},
"glass": {
"role": "neutral",
"displayName": "Graphite Glass",
"canonical": "rgba(20, 26, 23, 0.82)",
"tonalRamp": ["oklch(15% 0.016 150)", "oklch(25% 0.016 150)", "oklch(35% 0.016 150)", "oklch(47% 0.016 150)", "oklch(59% 0.016 150)", "oklch(71% 0.016 150)", "oklch(83% 0.016 150)", "oklch(95% 0.016 150)"]
},
"surface-solid": {
"role": "neutral",
"displayName": "Solid Graphite",
"canonical": "#131916",
"tonalRamp": ["oklch(15% 0.015 150)", "oklch(25% 0.015 150)", "oklch(35% 0.015 150)", "oklch(47% 0.015 150)", "oklch(59% 0.015 150)", "oklch(71% 0.015 150)", "oklch(83% 0.015 150)", "oklch(95% 0.015 150)"]
},
"surface-raised": {
"role": "neutral",
"displayName": "Raised Graphite",
"canonical": "#18201c",
"tonalRamp": ["oklch(15% 0.019 150)", "oklch(25% 0.019 150)", "oklch(35% 0.019 150)", "oklch(47% 0.019 150)", "oklch(59% 0.019 150)", "oklch(71% 0.019 150)", "oklch(83% 0.019 150)", "oklch(95% 0.019 150)"]
},
"surface-muted": {
"role": "neutral",
"displayName": "Muted Glass",
"canonical": "rgba(255, 255, 255, 0.035)",
"tonalRamp": ["oklch(15% 0 0)", "oklch(25% 0 0)", "oklch(35% 0 0)", "oklch(47% 0 0)", "oklch(59% 0 0)", "oklch(71% 0 0)", "oklch(83% 0 0)", "oklch(95% 0 0)"]
},
"line": {
"role": "neutral",
"displayName": "Graphite Hairline",
"canonical": "rgba(255, 255, 255, 0.09)",
"tonalRamp": ["oklch(15% 0 0)", "oklch(25% 0 0)", "oklch(35% 0 0)", "oklch(47% 0 0)", "oklch(59% 0 0)", "oklch(71% 0 0)", "oklch(83% 0 0)", "oklch(95% 0 0)"]
},
"line-strong": {
"role": "neutral",
"displayName": "Strong Hairline",
"canonical": "rgba(255, 255, 255, 0.16)",
"tonalRamp": ["oklch(15% 0 0)", "oklch(25% 0 0)", "oklch(35% 0 0)", "oklch(47% 0 0)", "oklch(59% 0 0)", "oklch(71% 0 0)", "oklch(83% 0 0)", "oklch(95% 0 0)"]
},
"ink": {
"role": "neutral",
"displayName": "Primary Ink",
"canonical": "#f2f7f4",
"tonalRamp": ["oklch(15% 0.012 150)", "oklch(25% 0.012 150)", "oklch(35% 0.012 150)", "oklch(47% 0.012 150)", "oklch(59% 0.012 150)", "oklch(71% 0.012 150)", "oklch(83% 0.012 150)", "oklch(95% 0.012 150)"]
},
"ink-soft": {
"role": "neutral",
"displayName": "Soft Ink",
"canonical": "#a6b0aa",
"tonalRamp": ["oklch(15% 0.012 150)", "oklch(25% 0.012 150)", "oklch(35% 0.012 150)", "oklch(47% 0.012 150)", "oklch(59% 0.012 150)", "oklch(71% 0.012 150)", "oklch(83% 0.012 150)", "oklch(95% 0.012 150)"]
},
"ink-muted": {
"role": "neutral",
"displayName": "Muted Ink",
"canonical": "#89938d",
"tonalRamp": ["oklch(15% 0.012 150)", "oklch(25% 0.012 150)", "oklch(35% 0.012 150)", "oklch(47% 0.012 150)", "oklch(59% 0.012 150)", "oklch(71% 0.012 150)", "oklch(83% 0.012 150)", "oklch(95% 0.012 150)"]
},
"action": {
"role": "primary",
"displayName": "Command Emerald",
"canonical": "#37eb89",
"tonalRamp": ["oklch(15% 0.12 150)", "oklch(25% 0.14 150)", "oklch(35% 0.16 150)", "oklch(47% 0.18 150)", "oklch(59% 0.19 150)", "oklch(71% 0.18 150)", "oklch(83% 0.14 150)", "oklch(95% 0.06 150)"]
},
"action-strong": {
"role": "primary",
"displayName": "Signal Emerald",
"canonical": "#70ffae",
"tonalRamp": ["oklch(15% 0.12 150)", "oklch(25% 0.14 150)", "oklch(35% 0.16 150)", "oklch(47% 0.18 150)", "oklch(59% 0.19 150)", "oklch(71% 0.18 150)", "oklch(83% 0.14 150)", "oklch(95% 0.06 150)"]
},
"action-dark": {
"role": "primary",
"displayName": "Emerald Ink",
"canonical": "#08160e",
"tonalRamp": ["oklch(15% 0.055 150)", "oklch(25% 0.055 150)", "oklch(35% 0.055 150)", "oklch(47% 0.055 150)", "oklch(59% 0.055 150)", "oklch(71% 0.055 150)", "oklch(83% 0.055 150)", "oklch(95% 0.055 150)"]
},
"action-wash": {
"role": "primary",
"displayName": "Emerald Wash",
"canonical": "rgba(55, 235, 137, 0.11)",
"tonalRamp": ["oklch(15% 0.12 150)", "oklch(25% 0.14 150)", "oklch(35% 0.16 150)", "oklch(47% 0.18 150)", "oklch(59% 0.19 150)", "oklch(71% 0.18 150)", "oklch(83% 0.14 150)", "oklch(95% 0.06 150)"]
},
"positive-wash": {
"role": "primary",
"displayName": "Positive Wash",
"canonical": "rgba(55, 235, 137, 0.10)",
"tonalRamp": ["oklch(15% 0.12 150)", "oklch(25% 0.14 150)", "oklch(35% 0.16 150)", "oklch(47% 0.18 150)", "oklch(59% 0.19 150)", "oklch(71% 0.18 150)", "oklch(83% 0.14 150)", "oklch(95% 0.06 150)"]
},
"warning": {
"role": "secondary",
"displayName": "Review Amber",
"canonical": "#ffbc52",
"tonalRamp": ["oklch(15% 0.09 80)", "oklch(25% 0.11 80)", "oklch(35% 0.13 80)", "oklch(47% 0.15 80)", "oklch(59% 0.16 80)", "oklch(71% 0.15 80)", "oklch(83% 0.11 80)", "oklch(95% 0.04 80)"]
},
"warning-wash": {
"role": "secondary",
"displayName": "Amber Wash",
"canonical": "rgba(255, 188, 82, 0.11)",
"tonalRamp": ["oklch(15% 0.09 80)", "oklch(25% 0.11 80)", "oklch(35% 0.13 80)", "oklch(47% 0.15 80)", "oklch(59% 0.16 80)", "oklch(71% 0.15 80)", "oklch(83% 0.11 80)", "oklch(95% 0.04 80)"]
},
"danger": {
"role": "secondary",
"displayName": "Blocked Red",
"canonical": "#ff626d",
"tonalRamp": ["oklch(15% 0.10 20)", "oklch(25% 0.13 20)", "oklch(35% 0.16 20)", "oklch(47% 0.19 20)", "oklch(59% 0.21 20)", "oklch(71% 0.19 20)", "oklch(83% 0.13 20)", "oklch(95% 0.05 20)"]
},
"danger-wash": {
"role": "secondary",
"displayName": "Red Wash",
"canonical": "rgba(255, 98, 109, 0.11)",
"tonalRamp": ["oklch(15% 0.10 20)", "oklch(25% 0.13 20)", "oklch(35% 0.16 20)", "oklch(47% 0.19 20)", "oklch(59% 0.21 20)", "oklch(71% 0.19 20)", "oklch(83% 0.13 20)", "oklch(95% 0.05 20)"]
},
"info": {
"role": "tertiary",
"displayName": "Evidence Blue",
"canonical": "#66a8ff",
"tonalRamp": ["oklch(15% 0.09 255)", "oklch(25% 0.11 255)", "oklch(35% 0.13 255)", "oklch(47% 0.15 255)", "oklch(59% 0.16 255)", "oklch(71% 0.15 255)", "oklch(83% 0.11 255)", "oklch(95% 0.04 255)"]
},
"info-wash": {
"role": "tertiary",
"displayName": "Blue Wash",
"canonical": "rgba(102, 168, 255, 0.11)",
"tonalRamp": ["oklch(15% 0.09 255)", "oklch(25% 0.11 255)", "oklch(35% 0.13 255)", "oklch(47% 0.15 255)", "oklch(59% 0.16 255)", "oklch(71% 0.15 255)", "oklch(83% 0.11 255)", "oklch(95% 0.04 255)"]
}
},
"typographyMeta": {
"display": { "displayName": "Entry Display", "purpose": "Sign-in identity statement only." },
"headline": { "displayName": "Workbench Headline", "purpose": "Portal page titles and first-view orientation." },
"title": { "displayName": "Panel Title", "purpose": "Panel, dialog, and compact section headings." },
"body": { "displayName": "Operational Body", "purpose": "Navigation, controls, evidence descriptions, and workflow copy." },
"label": { "displayName": "Evidence Label", "purpose": "Table headers, metadata, badges, and compact context." },
"data": { "displayName": "Tabular Evidence", "purpose": "Amounts, dates, account suffixes, counts, ratios, and balances." }
},
"shadows": [
{ "name": "panel-float", "value": "0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px rgba(255, 255, 255, 0.035)", "purpose": "Floating sidebar, dialog, toast, and sign-in form." },
{ "name": "low-glass", "value": "0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.025)", "purpose": "Panels, metric cards, alerts, and account cards at rest." },
{ "name": "command-glow", "value": "0 0 28px rgba(55, 235, 137, 0.12)", "purpose": "Focused search and selected identity cues only." }
],
"motion": [
{ "name": "state-fast", "value": "150ms", "purpose": "Hover, focus, field, row, and button state changes." },
{ "name": "structure-standard", "value": "260ms", "purpose": "Metric movement and mobile sidebar transitions." },
{ "name": "navigation-repeat", "value": "180ms", "purpose": "Brief repeat navigation after cancelling any previous view animation." },
{ "name": "first-load", "value": "440ms with 38ms stagger capped at 260ms", "purpose": "One-time workbench entrance sequencing." },
{ "name": "ease-out", "value": "cubic-bezier(0.22, 1, 0.36, 1)", "purpose": "Entrances and structural motion." },
{ "name": "reduced-motion", "value": "0.01ms", "purpose": "Forced near-instant animation and transition duration when reduced motion is requested." }
],
"breakpoints": [
{ "name": "rail", "value": "1180px" },
{ "name": "recompose", "value": "900px" },
{ "name": "mobile", "value": "720px" },
{ "name": "narrow", "value": "460px" }
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "The sole filled command treatment for a page or decision group.",
"html": "<button class=\"ds-btn-primary\" type=\"button\">查询往来</button>",
"css": ".ds-btn-primary { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0 15px; border: 1px solid transparent; border-radius: 13px; background: var(--color-primary, #37eb89); color: var(--color-primary-dark, #08160e); font: 700 13px/1.55 var(--font-ui, sans-serif); cursor: pointer; box-shadow: 0 8px 24px rgba(55, 235, 137, 0.18); transition: transform 150ms, box-shadow 150ms, background 150ms; } .ds-btn-primary:hover { background: var(--color-primary-strong, #70ffae); transform: translateY(-1px); box-shadow: 0 10px 30px rgba(55, 235, 137, 0.25); } .ds-btn-primary:active { transform: scale(0.985); } .ds-btn-primary:focus-visible { outline: 2px solid var(--color-primary-strong, #70ffae); outline-offset: 3px; }"
},
{
"name": "Secondary Button",
"kind": "button",
"refersTo": "button-secondary",
"description": "A bordered graphite command for supporting or reversible actions.",
"html": "<button class=\"ds-btn-secondary\" type=\"button\">重新检查</button>",
"css": ".ds-btn-secondary { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0 15px; border: 1px solid var(--color-line, rgba(255,255,255,.09)); border-radius: 13px; background: rgba(255, 255, 255, 0.045); color: var(--color-ink, #f2f7f4); font: 700 13px/1.55 var(--font-ui, sans-serif); cursor: pointer; transition: transform 150ms, background 150ms, border-color 150ms; } .ds-btn-secondary:hover { border-color: var(--color-line-strong, rgba(255,255,255,.16)); background: rgba(255,255,255,.08); transform: translateY(-1px); } .ds-btn-secondary:active { transform: scale(0.985); } .ds-btn-secondary:focus-visible { outline: 2px solid var(--color-primary-strong, #70ffae); outline-offset: 3px; }"
},
{
"name": "Evidence Field",
"kind": "input",
"refersTo": "field",
"description": "A labelled inset field for dates, accounts, keywords, and audited decisions.",
"html": "<label class=\"ds-field\"><span>截止日期</span><input type=\"text\" value=\"2026.07.31\"></label>",
"css": ".ds-field { min-width: 220px; display: flex; flex-direction: column; gap: 6px; color: var(--color-ink-soft, #a6b0aa); font: 13px/1.55 var(--font-ui, sans-serif); } .ds-field span { color: var(--color-ink-muted, #89938d); font-size: 11px; } .ds-field input { width: 100%; min-height: 40px; padding: 9px 11px; border: 1px solid var(--color-line, rgba(255,255,255,.09)); border-radius: 11px; outline: 0; background: rgba(2,5,3,.48); color: var(--color-ink, #f2f7f4); transition: border-color 150ms, box-shadow 150ms, background 150ms; } .ds-field input:focus { border-color: rgba(55,235,137,.5); background: rgba(2,5,3,.7); box-shadow: 0 0 0 3px rgba(55,235,137,.08); } .ds-field input:focus-visible { outline: 2px solid var(--color-primary-strong, #70ffae); outline-offset: 3px; }"
},
{
"name": "Active Navigation Item",
"kind": "nav",
"refersTo": "navigation-active",
"description": "The current destination within either portal's independent sidebar.",
"html": "<button class=\"ds-nav-active\" type=\"button\" aria-current=\"page\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z\"/></svg><span>管理总览</span></button>",
"css": ".ds-nav-active { width: 210px; min-height: 43px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid rgba(255,255,255,.1); border-radius: 14px; background: rgba(255,255,255,.075); color: var(--color-ink, #f2f7f4); font: 13px/1.55 var(--font-ui, sans-serif); cursor: pointer; box-shadow: inset 0 1px rgba(255,255,255,.045), 0 8px 22px rgba(0,0,0,.2); transition: transform 150ms, background 150ms; } .ds-nav-active svg { width: 17px; height: 17px; fill: none; stroke: var(--color-primary, #37eb89); stroke-width: 1.8; filter: drop-shadow(0 0 8px rgba(55,235,137,.42)); } .ds-nav-active:hover { background: rgba(255,255,255,.095); transform: translateX(2px); } .ds-nav-active:focus-visible { outline: 2px solid var(--color-primary-strong, #70ffae); outline-offset: 3px; }"
},
{
"name": "Warning Status",
"kind": "chip",
"refersTo": "status-warning",
"description": "Explicit pending-review state; wording remains visible alongside color.",
"html": "<span class=\"ds-status-warning\">单边待核</span>",
"css": ".ds-status-warning { min-height: 23px; display: inline-flex; align-items: center; justify-content: center; padding: 2px 8px; border: 1px solid rgba(255,188,82,.18); border-radius: 8px; background: var(--color-warning-wash, rgba(255,188,82,.11)); color: var(--color-warning, #ffbc52); font: 500 10px/1.4 var(--font-ui, sans-serif); white-space: nowrap; }"
},
{
"name": "Interactive Metric Card",
"kind": "card",
"refersTo": "metric-card",
"description": "One of four first-view signals, with native button semantics when interactive.",
"html": "<button class=\"ds-metric\" type=\"button\"><span class=\"ds-metric-copy\"><span>待审核事项</span><strong>7 <small>项</small></strong></span><span class=\"ds-metric-icon\" aria-hidden=\"true\"><svg viewBox=\"0 0 24 24\"><path d=\"m9 11 2 2 4-4M5 4h14v16H5z\"/></svg></span><span class=\"ds-metric-foot\"><b>2 项高风险</b> · 影响当前结账</span></button>",
"css": ".ds-metric { width: 268px; min-height: 142px; display: grid; grid-template-columns: minmax(0,1fr) 46px; align-content: space-between; gap: 14px; padding: 20px; border: 1px solid var(--color-line, rgba(255,255,255,.09)); border-radius: 22px; background: var(--color-surface, rgba(20,26,23,.82)); color: var(--color-ink, #f2f7f4); text-align: left; cursor: pointer; box-shadow: 0 12px 32px rgba(0,0,0,.22), inset 0 1px rgba(255,255,255,.025); backdrop-filter: blur(24px) saturate(125%); transition: transform 260ms cubic-bezier(.22,1,.36,1), border-color 260ms, box-shadow 260ms; } .ds-metric:hover { transform: translateY(-4px); border-color: rgba(255,255,255,.17); box-shadow: 0 24px 54px rgba(0,0,0,.3); } .ds-metric:focus-visible { outline: 2px solid var(--color-primary-strong, #70ffae); outline-offset: 3px; } .ds-metric-copy { display: flex; flex-direction: column; color: var(--color-ink-soft, #a6b0aa); font: 12px/1.4 var(--font-ui, sans-serif); } .ds-metric-copy strong { margin-top: 7px; color: var(--color-ink, #f2f7f4); font: 700 31px/1 var(--font-data, sans-serif); } .ds-metric-copy small { color: var(--color-ink-muted, #89938d); font: 11px var(--font-ui, sans-serif); } .ds-metric-icon { width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid var(--color-line, rgba(255,255,255,.09)); border-radius: 15px; background: rgba(255,255,255,.055); color: var(--color-danger, #ff626d); } .ds-metric-icon svg { width: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-metric-foot { grid-column: 1 / -1; color: var(--color-ink-muted, #89938d); font: 11px/1.4 var(--font-ui, sans-serif); } .ds-metric-foot b { color: var(--color-danger, #ff626d); }"
},
{
"name": "Graphite Glass Panel",
"kind": "card",
"refersTo": "panel",
"description": "A meaningful work region divided internally by hairlines instead of nested cards.",
"html": "<section class=\"ds-panel\"><header><div><h2>优先处理</h2><p>影响当前账期可结性的事项</p></div><a href=\"#\">查看全部 7 项</a></header><div class=\"ds-panel-row\"><strong>流水覆盖缺口</strong><span>2 个账户待处理</span></div></section>",
"css": ".ds-panel { width: 380px; overflow: hidden; border: 1px solid var(--color-line, rgba(255,255,255,.09)); border-radius: 22px; background: var(--color-surface, rgba(20,26,23,.82)); color: var(--color-ink, #f2f7f4); font: 13px/1.55 var(--font-ui, sans-serif); box-shadow: 0 12px 32px rgba(0,0,0,.22), inset 0 1px rgba(255,255,255,.025); backdrop-filter: blur(22px) saturate(120%); } .ds-panel header { min-height: 67px; display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 14px 17px; border-bottom: 1px solid var(--color-line, rgba(255,255,255,.09)); } .ds-panel h2 { margin: 0; font-size: 15px; } .ds-panel p { margin: 3px 0 0; color: var(--color-ink-muted, #89938d); font-size: 11px; } .ds-panel a { color: var(--color-primary, #37eb89); text-decoration: none; } .ds-panel a:hover { color: var(--color-primary-strong, #70ffae); } .ds-panel a:focus-visible { outline: 2px solid var(--color-primary-strong, #70ffae); outline-offset: 3px; } .ds-panel-row { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 17px; } .ds-panel-row span { color: var(--color-ink-muted, #89938d); font-size: 11px; }"
}
],
"narrative": {
"northStar": "The Midnight Evidence Desk",
"overview": "This is a quiet, high-consequence operations room for financial evidence. A near-black scene keeps attention on state and action; translucent graphite surfaces reveal hierarchy without turning every section into a decorative card. Emerald behaves like an illuminated command channel, while amber and red identify review pressure and blocked work.\n\nThe administrator and cashier portals are sibling workbenches, not skins of one generic dashboard. They share material, type, and interaction grammar, but their navigation, first-view metrics, priorities, data scope, and primary commands remain distinct. The administrator sees group-wide calculation health and exception pressure; the cashier sees account coverage, uploads, confirmations, and work required before closing.\n\nThe interface is dense enough for repeated operational use, yet each first view opens with four prominent metric cards and a clear action hierarchy. Every balance carries a cutoff date and unresolved amount nearby. Muted copy remains readable on the primary glass surface; the shipped muted token measures 5.73:1 there.",
"keyCharacteristics": [
"Near-black continuous canvas with restrained translucent graphite glass.",
"Floating desktop sidebar and narrow sticky top bar establish the workbench frame.",
"Four prominent metric cards expose workload, risk, completion, and closing state.",
"Emerald is reserved for commands, focus, confirmed progress, and positive outcomes.",
"Amber and red carry explicit text for review and blocked states; color never acts alone.",
"Stable tabular numerals support scanning amounts, dates, balances, and counts.",
"First load uses a measured staged reveal; repeat navigation uses a brief 180ms transition.",
"Both portals retain independent routes, navigation models, and information priorities."
],
"rules": [
{ "name": "The One Command Light Rule", "body": "Emerald is the only global action accent. Do not introduce cobalt, purple, or a second brand-action hue.", "section": "colors" },
{ "name": "The State Is Evidence Rule", "body": "Amber and red must be paired with explicit status text and a consequence or recovery action when one exists.", "section": "colors" },
{ "name": "The Stable Evidence Rule", "body": "Comparable numbers use tabular figures and stable alignment. Never communicate debit, credit, or risk from typographic style alone.", "section": "typography" },
{ "name": "The Zero Tracking Rule", "body": "Keep letter spacing at zero. Hierarchy comes from size, weight, spacing, and material contrast.", "section": "typography" },
{ "name": "The Two Workbenches Rule", "body": "Do not merge the administrator and cashier portals into one route with hidden controls. Shared components may be reused, but each portal owns its navigation and first-view priorities.", "section": "layout" },
{ "name": "The Four Signals Rule", "body": "Both portal home views open with four prominent metrics. Preserve their stable dimensions and button semantics when they navigate or execute an action.", "section": "layout" },
{ "name": "The Glass With Restraint Rule", "body": "Blur and translucency establish material, but every surface still needs a readable border and sufficient tonal separation. Do not stack glass cards inside glass cards for decoration.", "section": "elevation" },
{ "name": "The Motion Has Memory Rule", "body": "Sequenced motion belongs to first load only. Repeat navigation is brief and does not replay the full entrance choreography.", "section": "elevation" },
{ "name": "The Radius By Responsibility Rule", "body": "Small radii belong to controls and statuses, large radii to major surfaces, and the extra-large radius to application-frame objects. Do not make every element equally round.", "section": "shapes" }
],
"dos": [
"Do keep the canvas near-black and use translucent graphite only for meaningful surfaces.",
"Do preserve four prominent first-view metrics with cutoff and unresolved context nearby.",
"Do keep administrator and cashier portals separate at the route, navigation, and information-priority levels.",
"Do expose status in words, not color alone, and place recovery near blocked work.",
"Do keep muted text at or above the shipped 5.73:1 contrast on primary glass.",
"Do replace tokens and component rules in place when evolving the visual system.",
"Do keep initial sequencing one-time and repeat navigation to the brief 180ms transition."
],
"donts": [
"Don't reintroduce warm paper, cobalt commands, cream surfaces, or a light-theme visual vocabulary.",
"Don't add a theme override layer after the existing stylesheet; update the canonical tokens and component definitions in place.",
"Don't use !important in business-component rules. Priority forcing is reserved for accessibility utilities such as visually hidden or [hidden], and reduced-motion enforcement.",
"Don't stack cards inside cards, add decorative glass, or amplify blur and glow beyond the shipped restraint.",
"Don't infer financial direction, ownership, subject, or risk from a name, color, or visual position alone.",
"Don't edit imported evidence through UI styling patterns that imply source rows are mutable."
]
}
}
+50
View File
@@ -0,0 +1,50 @@
# Project Behavior Rules
## Product Truth
- Treat bank files as immutable evidence. Never rewrite, delete, or silently
repair an imported source row.
- Never infer a financial fact from a name alone when an account identifier,
amount, direction, date, or balance check is available.
- A bank-side observation is not automatically an economic transfer. Deduplicate
both sides into one canonical event before calculating any relationship.
- Same-company account transfers affect account cash positions but never enter
intercompany balances.
- Unknown, ambiguous, or incomplete input becomes an explicit exception. Do not
guess a template, company, account, or counterparty.
- Keep manual records separate from immutable bank rows. A manual record affects
positions only after administrator approval and must retain its audit trail.
- Treat cashier account registration as a request. Only administrator-approved
accounts may identify ownership, accept uploads, or affect calculations.
- Every balance shown in the UI must expose its cutoff date and unresolved
amount.
- Keep the administrator and cashier portals as independent route-level
surfaces with different information priorities.
- Apply the global calculation start date before any aggregation. Preserve but
exclude earlier source rows.
- Treat cashier no-business gap attestations as audited evidence, never as
generated bank transactions.
- Subject classification may be suggested by deterministic rules, but an
ambiguous statutory subject must enter review rather than being guessed.
## Engineering Behavior
- Prefer deterministic parsing and validation for money, dates, account ids, and
balance continuity. AI-like classification may suggest, never post silently.
- Keep company and account master data dynamic. Do not hard-code the current
group roster into code or fixtures outside tests.
- Use decimal arithmetic for money. Preserve original precision and currency.
- Changes to imported facts happen through a reversal or audited adjustment,
never by editing the source transaction in place.
- Add a focused regression test for every new bank template or parsing edge
case. Run the complete test suite before reporting completion.
- Keep UI copy operational and specific: state, action, consequence, and
recovery belong near the control that needs them.
- Do not expose temporary server filenames in import errors. Report the original
filename, worksheet, scanned range, and useful candidate header rows.
- Scope company-user reads, exports, uploads, and confirmations on the server;
UI visibility is not a permission boundary.
- Use accessible labels, visible keyboard focus, and status text that does not
rely on color alone.
- Keep changes scoped to the requested phase. Avoid speculative integrations,
migrations, or design-system rewrites.
+111
View File
@@ -0,0 +1,111 @@
# Behavior Specification
## Access Boundaries
- `/admin.html` is the group administrator portal and can access all companies.
- `/company.html` is the cashier portal and is scoped to exactly one company.
- A company user is created by an administrator and stores login name, default
password state, company, cashier name, status, and last sign-in time.
- Every read, export, upload, reminder, confirmation, and setting change is
permission checked on the server; client-side hiding is not authorization.
## Import Lifecycle
`待上传 -> 已上传 -> 解析中 -> 已解析 -> 待处理 -> 已确认`
- Original files are stored and hashed before parsing.
- Header signatures select the bank template; unknown or ambiguous headers go
to review rather than being guessed.
- Rows are normalized into date, direction, amount, balance, account,
counterparty name/account, bank identifiers, summary, and source location.
- Duplicate files and duplicate rows are flagged before they can affect totals.
- Unknown-template errors show the original upload filename, scanned worksheet,
and likely header rows. Server temporary filenames are never shown to users.
## Manual Records
`草稿 -> 待总账复核 -> 已确认 / 已退回 / 异常待处理`
- A cashier can submit a manual record only for the bound company, with date,
direction, amount, funding source, counterparty, subject, summary, and reason.
- Personal transit is an explicit funding-source option. A bank account may be
referenced, but the manual record never becomes or edits a bank source row.
- Supporting evidence and an optional related bank reference are retained with
operator, submission time, review decision, reason, and review time.
- Only `已确认` manual records can join canonical events or affect positions.
Pending, returned, and exceptional records remain visible but excluded.
## Bank Account Registration
`公司端提交 -> 待总账复核 -> 已启用 / 已退回 / 已停用`
- Company users register accounts only for their bound company. Administrators
review ownership, bank, account number, type, and effective date.
- Before `已启用`, an account is excluded from ownership recognition, statement
upload choices, same-company transfer detection, and coverage calculations.
- Every decision retains submitter, reviewer, timestamps, and reason. Account
numbers are unique within the active master data.
## Classification And Matching
1. Resolve the source account owner from account master data.
2. Resolve the counterparty using account number first and audited aliases second.
3. Same owner means `同公司账户调拨`; retain it in flow history and exclude it
from intercompany totals.
4. Different owners create or join one canonical intercompany event. A matched
pair of bank rows counts once.
5. No reliable counterparty match means `外部或待识别` and cannot affect an
intercompany position until resolved.
6. Direction and amount are deterministic. Subject is assigned by explicit
rules; unresolved subjects enter review and may be confirmed manually with
operator, reason, and timestamp.
## Position Hierarchy
The administrator can navigate the following levels without losing context:
1. Company debit total and credit total for the effective period.
2. Debit entries and credit entries for that company.
3. Counterparty totals grouped by `应收`, `应付`, `其他应收`, `其他应付`.
4. Individual canonical transfers and their original bank-side evidence.
Pair query accepts two different companies and shows both viewpoints, opening
balance, period movement, closing position, unresolved amount, and transactions.
## Calculation Window And Closing
- Rows with transaction date earlier than the global start date are excluded
from all position calculations but remain queryable as archived evidence.
- Coverage is evaluated per bank account as date intervals, not by upload month.
- A missing interval between the start date and earliest accepted statement, or
between accepted statements, creates a gap visible in both portals.
- Cashier `无业务校准` closes a gap only after a reason is submitted. It does
not fabricate bank rows and remains visible in the audit trail.
- Monthly closing freezes the period result. Later files affecting a closed
period create a reopening exception; they do not silently rewrite the close.
## Flow Management And Export
- Administrator filters flows by company, bank, account, date, direction,
counterparty, match state, and keyword.
- Cashier has the same flow tools within the bound company only.
- Export uses a stable custom schema and preserves source bank, source account,
bank transaction id/reference, original summary, import batch, match state,
and source-row locator.
## Reminders
- Automatic reminders are generated for missing monthly uploads, coverage gaps,
single-side candidates, unmatched internal flows, and overdue exceptions.
- Administrator can send a manual in-product reminder to one company with a
message and due date.
- Reminders have `未读`, `已读`, `处理中`, and `已完成` states and retain sender,
recipient, trigger, timestamps, and linked task.
## Homepage Priorities
- Administrator homepage: accounting-window status, urgent management queue,
company debit/credit directory, and A-B quick query. Import history is not on
the homepage.
- Cashier homepage: missing uploads, gaps, one-sided matching, unmatched flows,
and the next direct action for the bound company.
+145
View File
@@ -0,0 +1,145 @@
# Design Tokens
This file maps the shipped dark visual system to its implementation source in `web/styles.css`. `DESIGN.md` is the portable design contract; the `:root` custom properties in `web/styles.css` are the runtime source of truth.
## Architecture
- Replace tokens and component definitions in place. Do not append a legacy-compatible theme override layer.
- Keep shared primitives in `:root`; keep component behavior in its existing semantic rule group.
- Business-component rules must not use `!important`.
- Only accessibility utilities (`.sr-only`, `[hidden]`) and `prefers-reduced-motion` enforcement may force priority.
- Do not restore warm-paper, white-card, cobalt-action, cream, or light-theme aliases. New code must consume the dark semantic tokens directly.
- Keep administrator and cashier portals as independent route-level surfaces even when they share tokens and component primitives.
## Typography
| Token | Value | Use |
| --- | --- | --- |
| `--font-ui` | `"Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif` | Chinese-first interface, navigation, fields, status, and headings |
| `--font-data` | `"Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif` | Amounts, dates, ratios, account suffixes, and dashboard metrics |
Body type is `13px/1.55`. Page headlines use `clamp(26px, 3vw, 35px)` with `1.15` line height. The sign-in statement alone uses `clamp(38px, 5vw, 70px)`. Letter spacing stays at `0`; numeric evidence uses `font-variant-numeric: tabular-nums`.
## Color Primitives
### Canvas and Surfaces
| Token | Value | Use |
| --- | --- | --- |
| `--color-bg` | `#050706` | Continuous application canvas |
| `--color-bg-soft` | `#090d0b` | Soft near-black tonal alternative |
| `--color-nav` | `#090c0b` | Navigation frame fallback |
| `--color-surface` | `rgba(20, 26, 23, 0.82)` | Primary graphite glass |
| `--color-surface-solid` | `#131916` | Opaque graphite fallback |
| `--color-surface-raised` | `#18201c` | Raised or denser surface |
| `--color-surface-muted` | `rgba(255, 255, 255, 0.035)` | Quiet inset field and row treatment |
### Structure and Ink
| Token | Value | Use |
| --- | --- | --- |
| `--color-line` | `rgba(255, 255, 255, 0.09)` | Default glass, row, and field boundary |
| `--color-line-strong` | `rgba(255, 255, 255, 0.16)` | Hover, dialog, and transient boundaries |
| `--color-ink` | `#f2f7f4` | Primary headings, values, and action labels |
| `--color-ink-soft` | `#a6b0aa` | Operational body copy and secondary values |
| `--color-ink-muted` | `#89938d` | Metadata, helper copy, cutoff dates, and descriptions |
`--color-ink-muted` has a verified 5.73:1 contrast ratio on the primary graphite glass. Do not reduce it.
### Action and State
| Token | Value | Use |
| --- | --- | --- |
| `--color-primary` | `#37eb89` | Primary commands, focus, active icons, and confirmed progress |
| `--color-primary-strong` | `#70ffae` | Primary hover and high-emphasis command state |
| `--color-primary-dark` | `#08160e` | Text and icons on filled emerald controls |
| `--color-primary-wash` | `rgba(55, 235, 137, 0.11)` | Selected and highlighted emerald fields |
| `--color-positive` | `#37eb89` | Confirmed and successful state |
| `--color-positive-wash` | `rgba(55, 235, 137, 0.10)` | Confirmed-state background |
| `--color-warning` | `#ffbc52` | Review, incomplete, expiring, or recoverable caution |
| `--color-warning-wash` | `rgba(255, 188, 82, 0.11)` | Warning background |
| `--color-danger` | `#ff626d` | Blocked, overdue, failed, or destructive consequence |
| `--color-danger-wash` | `rgba(255, 98, 109, 0.11)` | Danger background |
| `--color-info` | `#66a8ff` | Neutral evidence guidance |
| `--color-info-wash` | `rgba(102, 168, 255, 0.11)` | Informational callout background |
Emerald is the only global action accent. Amber and red must always be accompanied by status text. Never use color alone to imply financial direction or accounting classification.
## Shape
| Token | Value | Use |
| --- | --- | --- |
| `--radius-sm` | `10px` | Compact identifiers and control-adjacent shapes |
| `--radius-md` | `16px` | Inset evidence, role cards, and compact grouped content |
| `--radius-lg` | `22px` | Panels, metric cards, alerts, dialogs, and account cards |
| `--radius-xl` | `28px` | Floating sidebar and sign-in form |
Observed component-specific radii remain local: fields use `11px`, primary controls `13px`, navigation rows `14px`, and statuses `8px`. Do not promote every one-off dimension into a global token.
## Depth
| Token | Value | Use |
| --- | --- | --- |
| `--shadow-panel` | `0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px rgba(255, 255, 255, 0.035)` | Sidebar, dialog, toast, and sign-in form |
| `--shadow-low` | `0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.025)` | Panels, metrics, alerts, and account cards |
| `--shadow-glow` | `0 0 28px rgba(55, 235, 137, 0.12)` | Focused search and selected identity cues |
Glass surfaces pair background, boundary, blur, and shadow. Do not add shadow without the corresponding surface and border logic, and do not nest glass surfaces as decoration.
## Motion
| Token | Value | Use |
| --- | --- | --- |
| `--duration-fast` | `150ms` | Hover, focus, row, field, and button state changes |
| `--duration-standard` | `260ms` | Metric movement and mobile sidebar structure |
| `--ease-out` | `cubic-bezier(0.22, 1, 0.36, 1)` | Entrances and structural transitions |
First load uses a `440ms` entrance with a `38ms` stagger capped at `260ms`. It runs once. Repeat navigation uses `180ms` and cancels any prior view animation before starting. Reduced-motion mode forces animations and transitions to `0.01ms`.
## Component Contracts
### Application Frame
- Desktop: 252px application column, 234px floating sidebar, 18px viewport offset, 78px sticky top bar.
- Main content: maximum width 1560px and 32px desktop inset.
- 1180px: sidebar compresses to an icon rail; four metrics become two columns.
- 900px: global search hides and complex ledgers recompose.
- 720px: 242px off-canvas sidebar, 64px top bar, 13px main inset, two-up metrics.
- 460px: metric cards gain height for wrapping; role selection becomes one column.
### Buttons and Fields
- Primary and secondary buttons have a 40px minimum height; icon buttons are stable 40px squares.
- Primary uses emerald fill with dark ink; secondary uses graphite wash with a hairline border.
- Fields have a 40px minimum height, 11px radius, dark inset fill, persistent visible label, and emerald focus treatment.
- Keyboard focus uses a two-pixel emerald outline with three-pixel offset.
### Metric Cards
- Exactly four prominent metrics lead each portal home view.
- Desktop minimum height is 142px with 20px padding; mobile minimum height is 128px with 15px padding, increasing at very narrow width.
- Interactive cards require native button semantics or equivalent role, focusability, and keyboard activation.
- A metric contains a label, stable numeric or status value, icon tile, and consequence line. Warning and danger color only the relevant signal.
### Panels, Ledgers, and Tables
- Major panels use graphite glass, a hairline border, 22px radius, low shadow, and backdrop blur.
- Use dividers and tonal rows inside a panel, not nested floating cards.
- Data tables retain a minimum width and horizontal scrolling so financial evidence is not discarded.
- Amounts and dates use tabular figures. Every balance view keeps its cutoff and unresolved amount visible.
### Status and Feedback
- Status chips are 23px high with 8px radius and `2px 8px` padding.
- Success, warning, danger, and neutral states always include explicit text.
- Dialogs use the strong boundary and panel shadow; mobile dialogs become full-screen with square outer corners.
- Toasts use the same raised graphite material as other transient layers.
## Change Checklist
1. Update the existing `:root` primitive instead of adding a duplicate alias.
2. Update every affected component state in its current rule group.
3. Verify administrator and cashier routes independently at desktop and mobile widths.
4. Verify focus visibility, text status, muted-text contrast, stable metric dimensions, and reduced motion.
5. Search `web/styles.css` for new business-rule `!important`; only approved accessibility and reduced-motion rules may remain.
6. Confirm no warm-paper, cobalt, cream, or light-theme override vocabulary has been introduced.
+311
View File
@@ -0,0 +1,311 @@
---
name: "Intercompany Ledger"
description: "Two distinct financial operations workbenches built as a near-black evidence room with graphite glass and emerald commands."
colors:
canvas: "#050706"
canvas-soft: "#090d0b"
navigation: "#090c0b"
glass: "rgba(20, 26, 23, 0.82)"
surface-solid: "#131916"
surface-raised: "#18201c"
surface-muted: "rgba(255, 255, 255, 0.035)"
line: "rgba(255, 255, 255, 0.09)"
line-strong: "rgba(255, 255, 255, 0.16)"
ink: "#f2f7f4"
ink-soft: "#a6b0aa"
ink-muted: "#89938d"
action: "#37eb89"
action-strong: "#70ffae"
action-dark: "#08160e"
action-wash: "rgba(55, 235, 137, 0.11)"
positive-wash: "rgba(55, 235, 137, 0.10)"
warning: "#ffbc52"
warning-wash: "rgba(255, 188, 82, 0.11)"
danger: "#ff626d"
danger-wash: "rgba(255, 98, 109, 0.11)"
info: "#66a8ff"
info-wash: "rgba(102, 168, 255, 0.11)"
typography:
display:
fontFamily: "Microsoft YaHei UI, PingFang SC, Noto Sans CJK SC, system-ui, sans-serif"
fontSize: "clamp(38px, 5vw, 70px)"
fontWeight: 700
lineHeight: 1.12
letterSpacing: "0"
headline:
fontFamily: "Microsoft YaHei UI, PingFang SC, Noto Sans CJK SC, system-ui, sans-serif"
fontSize: "clamp(26px, 3vw, 35px)"
fontWeight: 700
lineHeight: 1.15
letterSpacing: "0"
title:
fontFamily: "Microsoft YaHei UI, PingFang SC, Noto Sans CJK SC, system-ui, sans-serif"
fontSize: "15px"
fontWeight: 700
lineHeight: 1.35
letterSpacing: "0"
body:
fontFamily: "Microsoft YaHei UI, PingFang SC, Noto Sans CJK SC, system-ui, sans-serif"
fontSize: "13px"
fontWeight: 400
lineHeight: 1.55
letterSpacing: "0"
label:
fontFamily: "Microsoft YaHei UI, PingFang SC, Noto Sans CJK SC, system-ui, sans-serif"
fontSize: "10px"
fontWeight: 500
lineHeight: 1.4
letterSpacing: "0"
data:
fontFamily: "Segoe UI, Microsoft YaHei UI, system-ui, sans-serif"
fontSize: "13px"
fontWeight: 600
lineHeight: 1.35
letterSpacing: "0"
rounded:
sm: "10px"
md: "16px"
lg: "22px"
xl: "28px"
components:
button-primary:
backgroundColor: "{colors.action}"
textColor: "{colors.action-dark}"
typography: "{typography.body}"
rounded: "13px"
padding: "0 15px"
height: "40px"
button-primary-hover:
backgroundColor: "{colors.action-strong}"
textColor: "{colors.action-dark}"
rounded: "13px"
button-secondary:
backgroundColor: "{colors.surface-muted}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "13px"
padding: "0 15px"
height: "40px"
field:
backgroundColor: "rgba(2, 5, 3, 0.48)"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "11px"
padding: "9px 11px"
height: "40px"
panel:
backgroundColor: "{colors.glass}"
textColor: "{colors.ink}"
rounded: "{rounded.lg}"
metric-card:
backgroundColor: "{colors.glass}"
textColor: "{colors.ink}"
rounded: "{rounded.lg}"
padding: "20px"
height: "142px"
navigation-active:
backgroundColor: "rgba(255, 255, 255, 0.075)"
textColor: "{colors.ink}"
rounded: "14px"
padding: "0 12px"
height: "43px"
status-success:
backgroundColor: "{colors.positive-wash}"
textColor: "{colors.action}"
rounded: "8px"
padding: "2px 8px"
height: "23px"
typography: "{typography.label}"
status-warning:
backgroundColor: "{colors.warning-wash}"
textColor: "{colors.warning}"
rounded: "8px"
padding: "2px 8px"
height: "23px"
typography: "{typography.label}"
status-danger:
backgroundColor: "{colors.danger-wash}"
textColor: "{colors.danger}"
rounded: "8px"
padding: "2px 8px"
height: "23px"
typography: "{typography.label}"
---
# Design System: Intercompany Ledger
## Overview
**Creative North Star: "The Midnight Evidence Desk"**
This is a quiet, high-consequence operations room for financial evidence. A near-black scene keeps attention on state and action; translucent graphite surfaces reveal hierarchy without turning every section into a decorative card. Emerald behaves like an illuminated command channel, while amber and red identify review pressure and blocked work.
The administrator and cashier portals are sibling workbenches, not skins of one generic dashboard. They share material, type, and interaction grammar, but their navigation, first-view metrics, priorities, data scope, and primary commands remain distinct. The administrator sees group-wide calculation health and exception pressure; the cashier sees account coverage, uploads, confirmations, and work required before closing.
The interface is dense enough for repeated operational use, yet each first view opens with four prominent metric cards and a clear action hierarchy. Every balance carries a cutoff date and unresolved amount nearby. Muted copy remains readable on the primary glass surface; the shipped muted token measures 5.73:1 there.
**Key Characteristics:**
- Near-black continuous canvas with restrained translucent graphite glass.
- Floating desktop sidebar and narrow sticky top bar establish the workbench frame.
- Four prominent metric cards expose workload, risk, completion, and closing state.
- Emerald is reserved for commands, focus, confirmed progress, and positive outcomes.
- Amber and red carry explicit text for review and blocked states; color never acts alone.
- Stable tabular numerals support scanning amounts, dates, balances, and counts.
- First load uses a measured staged reveal; repeat navigation uses a brief 180ms transition.
- Both portals retain independent routes, navigation models, and information priorities.
## Colors
The palette is dark, cool-neutral, and operational: a nearly black field, green-black graphite surfaces, high-clarity text, and sparse luminous semantic signals.
### Primary
- **Command Emerald:** The sole dominant action color for primary commands, active focus, selected controls, confirmed progress, and links that cause navigation.
- **Signal Emerald:** A brighter hover and emphasis state for command affordances; use it sparingly so the interface does not become neon.
- **Emerald Ink:** The dark text color placed on filled emerald controls for legibility.
- **Emerald Wash:** A translucent state field for selected items, confirmation summaries, and compact identity marks.
### Secondary
- **Review Amber:** Marks incomplete coverage, pending review, expiring work, and caution that still has a recovery path.
- **Blocked Red:** Marks failed continuity, overdue work, destructive consequences, and states that prevent closing.
### Tertiary
- **Evidence Blue:** Supports informational callouts where neither success nor risk is implied.
### Neutral
- **Void Canvas:** The uninterrupted application background and the darkest visual anchor.
- **Graphite Glass:** The shared translucent material for panels, metric cards, query bands, and the sign-in form.
- **Solid Graphite / Raised Graphite:** Opaque fallbacks and denser transient layers.
- **Hairline / Strong Hairline:** Low-contrast structure for glass boundaries, rows, fields, and decision layers.
- **Primary Ink:** High-emphasis headings, values, and action labels.
- **Soft Ink:** Operational body copy and secondary values.
- **Muted Ink:** Metadata, descriptions, cutoff context, and helper text; never reduce its shipped contrast.
**The One Command Light Rule.** Emerald is the only global action accent. Do not introduce cobalt, purple, or a second brand-action hue.
**The State Is Evidence Rule.** Amber and red must be paired with explicit status text and a consequence or recovery action when one exists.
## Typography
**Display Font:** Microsoft YaHei UI, falling back through PingFang SC, Noto Sans CJK SC, system UI, and sans-serif.
**Body Font:** The same Chinese-first UI stack for predictable density and platform rendering.
**Data Font:** Segoe UI with the UI stack as fallback; numeric evidence always enables tabular figures.
**Character:** The type system is neutral, compact, and sturdy. Scale and weight establish hierarchy without letter-spacing effects, decorative type, or oversized dashboard copy.
### Hierarchy
- **Display:** Reserved for the sign-in identity statement; it does not enter either operational portal.
- **Headline:** Portal page titles and first-view orientation.
- **Title:** Panel titles, dialog titles, and compact section anchors.
- **Body:** Controls, navigation, evidence descriptions, and operational copy.
- **Label:** Table headers, metadata, badges, and compact context.
- **Data:** Amounts, dates, ratios, account suffixes, metrics, and balance results.
**The Stable Evidence Rule.** Comparable numbers use tabular figures and stable alignment. Never communicate debit, credit, or risk from typographic style alone.
**The Zero Tracking Rule.** Keep letter spacing at zero. Hierarchy comes from size, weight, spacing, and material contrast.
## Layout
Desktop portals use a fixed floating sidebar beside a fluid workspace. The sidebar occupies a 234px visual rail inside a 252px application column, offset 18px from the viewport. The sticky top bar is 78px high, and the main work area is capped at 1560px with 32px desktop insets.
The opening dashboard rhythm is invariant: page heading, four metric cards, calculation or blocking context, then portal-specific action and evidence regions. Administrator work pairs a larger priority queue with a compact company-pair query; cashier work pairs a larger task list with account coverage. Evidence tables retain column integrity and scroll horizontally when necessary.
At 1180px the sidebar compresses to an icon rail, the four metrics become two columns, and major two-column work areas stack. At 900px search is removed from the top bar and complex ledgers recompose. At 720px the sidebar becomes a 242px off-canvas surface, the top bar becomes 64px high, main insets tighten to 13px, and primary mobile actions may become icon-only when their accessible name remains present. Metric cards stay two-up. At 460px their internal height increases to protect wrapping; role selection becomes one column.
**The Two Workbenches Rule.** Do not merge the administrator and cashier portals into one route with hidden controls. Shared components may be reused, but each portal owns its navigation and first-view priorities.
**The Four Signals Rule.** Both portal home views open with four prominent metrics. Preserve their stable dimensions and button semantics when they navigate or execute an action.
## Elevation & Depth
Depth is a restrained hybrid of tonal layering, translucent glass, hairline borders, blur, and low black shadows. Primary content panels use the lower shadow; the floating sidebar, dialog, sign-in form, and toast use the stronger panel shadow. Emerald glow belongs only to active identity or focus cues. The broad glass is intentionally more restrained than the pinned reference so dense financial content remains calm.
### Shadow Vocabulary
- **Panel Float:** Used by the floating sidebar, dialogs, toast, and sign-in form to separate decision or frame layers.
- **Low Glass:** Used by panels, metric cards, alerts, and account cards at rest.
- **Command Glow:** Used on primary action hover and selected identity marks; it is not general decoration.
First-load content enters over 440ms with a short capped stagger. Subsequent portal navigation uses 180ms. Ordinary component transitions use 150ms; structural sidebar motion uses 260ms with the standard emphasized ease. Reduced-motion mode collapses animation and transition duration to a near-instant value.
**The Glass With Restraint Rule.** Blur and translucency establish material, but every surface still needs a readable border and sufficient tonal separation. Do not stack glass cards inside glass cards for decoration.
**The Motion Has Memory Rule.** Sequenced motion belongs to first load only. Repeat navigation is brief and does not replay the full entrance choreography.
## Shapes
The system uses rounded, engineered silhouettes rather than pills or sharp ledger boxes. Base controls and compact indicators begin at the small radius; structured surfaces use medium and large radii; only the floating sidebar and sign-in form reach the extra-large radius. Icon buttons remain stable squares, and avatars alone are circular.
Hairline borders are part of the material model, not optional ornament. They separate translucent surfaces from the near-black canvas and divide evidence rows without resorting to multiple background cards.
**The Radius By Responsibility Rule.** Small radii belong to controls and statuses, large radii to major surfaces, and the extra-large radius to application-frame objects. Do not make every element equally round.
## Components
### Buttons
- **Shape:** Compact rounded rectangle with a stable 40px minimum height; small variants remain 34px high.
- **Primary:** Filled Command Emerald with dark text, strong weight, and a restrained green shadow.
- **Hover / Focus:** Brighten to Signal Emerald and lift by one pixel; keyboard focus uses a visible two-pixel emerald outline with offset.
- **Secondary:** Translucent graphite with a hairline border and light ink; it gains contrast on hover without becoming a second primary action.
- **Icon buttons:** Stable 40px squares with an accessible label and tooltip. Mobile may visually hide primary-action text only when the accessible name remains.
### Chips
- **Style:** Status chips are compact rounded rectangles, not decorative pills. Wash, border, text, and explicit wording share the same semantic family.
- **State:** Success, warning, danger, and neutral are distinct; selected filters use emerald wash or a restrained neutral selection field.
### Cards / Containers
- **Corner Style:** Major glass surfaces use the large radius; the sidebar and sign-in form use the extra-large radius.
- **Background:** Primary panels and metric cards use Graphite Glass; nested content is expressed with dividers or subtle tonal fields rather than another floating card.
- **Shadow Strategy:** Low Glass at rest, Panel Float only for frame and decision layers.
- **Border:** Every glass surface keeps a visible hairline boundary.
- **Internal Padding:** Metric cards use a generous 20px desktop inset, reduced to 15px on mobile.
### Inputs / Fields
- **Style:** Dark inset field, 40px minimum height, compact 11px corner, and persistent label above the control.
- **Focus:** Border shifts toward emerald and receives a low-opacity three-pixel focus halo; the global focus outline remains available.
- **Error / Disabled:** Error uses explicit red text and consequence. Disabled commands retain their label and use opacity plus the disabled cursor.
### Navigation
The sidebar floats as a framed glass rail. Navigation rows use soft ink at rest, a subtle translated hover, and a bordered translucent active field with an emerald icon. The administrator and cashier lists remain independent. At tablet width the rail collapses to icons with accessible names; on mobile it becomes an off-canvas surface that restores focus when dismissed.
### Metric Cards
Four metric cards form the first operational scan. Each has a label, a large stable value, a compact icon tile, and a consequence line. Interactive metrics are buttons or otherwise carry button semantics and full keyboard handling. Warning and danger use their semantic colors only for the relevant icon or consequence, not the entire card.
### Evidence Tables and Ledgers
Tables use continuous rows, muted headers, tabular numeric alignment, and subtle hover wash. Expanded company ledgers reveal counterparty and subject details in place before linking to a company-pair report and original bank evidence. On narrow screens, preserve evidence through reflow or horizontal scrolling rather than deleting financial context.
## Do's and Don'ts
### Do:
- **Do** keep the canvas near-black and use translucent graphite only for meaningful surfaces.
- **Do** preserve four prominent first-view metrics with cutoff and unresolved context nearby.
- **Do** keep administrator and cashier portals separate at the route, navigation, and information-priority levels.
- **Do** expose status in words, not color alone, and place recovery near blocked work.
- **Do** keep muted text at or above the shipped 5.73:1 contrast on primary glass.
- **Do** replace tokens and component rules in place when evolving the visual system.
- **Do** keep initial sequencing one-time and repeat navigation to the brief 180ms transition.
### Don't:
- **Don't** reintroduce warm paper, cobalt commands, cream surfaces, or a light-theme visual vocabulary.
- **Don't** add a theme override layer after the existing stylesheet; update the canonical tokens and component definitions in place.
- **Don't** use `!important` in business-component rules. Priority forcing is reserved for accessibility utilities such as visually hidden or `[hidden]`, and reduced-motion enforcement.
- **Don't** stack cards inside cards, add decorative glass, or amplify blur and glow beyond the shipped restraint.
- **Don't** infer financial direction, ownership, subject, or risk from a name, color, or visual position alone.
- **Don't** edit imported evidence through UI styling patterns that imply source rows are mutable.
+81
View File
@@ -0,0 +1,81 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
Web application with one role-aware sign-in entry and two independent portals.
## Users And Portals
- **Group administrator portal**: manages companies, company users, system
settings, reminders, all bank flows, reconciliation exceptions, and every
company-to-company position.
- **Company cashier portal**: is bound to exactly one company and can upload,
review, query, and export only that company's bank flows and exceptions.
The portals have different navigation, homepages, priorities, and URLs. They
must not be implemented as one dashboard with hidden controls.
## Product Purpose
Centralize group bank statements, prevent local-file data loss, identify
internal transfers, and calculate traceable company-to-company positions with
software-led reconciliation and human exception review.
## Core Accounting Model
- One or two bank-side observations may represent one canonical transfer.
- Same-company transfers between bank accounts affect account cash history but
never affect intercompany positions.
- Intercompany positions are presented as debit and credit totals, then by
counterparty and subject: `应收`, `应付`, `其他应收`, `其他应付`.
- Bank evidence can determine amount, direction, date, parties, and matching
state. Statutory subject classification uses deterministic rules plus
audited exception confirmation; it is never silently guessed.
## Calculation Window
- The group administrator owns one global calculation start date, opening
balances, and the monthly closing day.
- Imported rows before the start date remain preserved but are excluded from
calculations.
- If the earliest accepted statement begins after the start date, both portals
show a coverage gap.
- A cashier may attest that no transactions occurred during a gap. The
attestation requires a reason and creates an immutable audit record.
- Without an opening balance, the result is labelled `期间净变动`, not
`期末余额`.
## Capabilities And Constraints
- Companies, users, accounts, aliases, effective dates, and opening balances
are dynamic master data and are never hard-coded as product configuration.
- Bank templates are identified from header signatures rather than fixed row
or column positions.
- Imported files and rows remain immutable; corrections use audited mappings,
reversals, or adjustments.
- Cashiers may submit manual records for personal transit flows or facts that
are not represented by a bank row. Manual records remain separate from bank
evidence and affect positions only after administrator approval.
- Cashiers submit bank-account registrations. An account becomes active master
data only after administrator review; pending accounts cannot identify
ownership, accept statement uploads, or affect coverage calculations.
- Personal transit-account flows require a known mapping or manual association.
- External messaging is future scope; the current product provides in-product
automatic reminders and administrator-triggered manual reminders.
## Evidence On Hand
The `流水模板` directory contains statement samples from CITIC Bank,
Agricultural Bank of China, ICBC, China Construction Bank, Henan Rural
Commercial Bank, and Bank of Zhengzhou. The set includes both `.xls` and
`.xlsx`, variable header positions, and different debit/credit conventions.
## Product Principles
- Automate deterministic facts and route uncertainty to a small exception queue.
- Never count two bank observations as two economic transfers.
- Always show the effective calculation window and unresolved coverage gaps.
- Keep cashier work centered on upload, validation, and exception handling.
- Make every position traceable from company total to original bank evidence.
+36
View File
@@ -0,0 +1,36 @@
# 往来台账
当前版本包含银行流水表头识别内核,以及相互独立的总账管理端和公司业务端原型。
## How recognition works
1. Scan the first 50 rows of every worksheet.
2. Normalize whitespace and full-width characters in candidate headers.
3. Match required header aliases against known bank template signatures.
4. Map columns by header name, independent of their order.
5. Normalize transaction dates, income, expense, balance, counterparty, and
reference fields.
6. Reject unknown or ambiguous templates instead of guessing.
The original Excel file is never modified.
## Run
```powershell
python -m pip install -r requirements.txt
$env:PYTHONPATH = "src"
python -m bank_importer.cli "流水模板"
python -m unittest discover -s tests -v
python server.py
```
访问地址:
- 登录入口:`http://127.0.0.1:4173/`
- 总账管理端:`http://127.0.0.1:4173/admin.html`
- 公司业务端:`http://127.0.0.1:4173/company.html`
公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器。当前前端是交互原型,公司、账号、期初、提醒和导入确认仅保存在当前浏览器页面中,尚未接入数据库和正式身份认证。
The CLI prints batch summaries only. It does not print sensitive transaction
details.
+50
View File
@@ -0,0 +1,50 @@
# Task Plan
## Phase 1 - Import Core (completed)
- Header-driven `.xls`/`.xlsx` parsing for six supplied bank formats.
- Unified transaction fields, decimal amounts, and regression tests.
## Phase 2 - Dual-Portal Prototype (completed)
- [x] Record role boundaries, calculation-window rules, hierarchy, exports,
and reminders in product behavior documents.
- [x] Replace the single dashboard with login, administrator, and cashier pages.
- [x] Implement administrator company drill-down and A-B pair query.
- [x] Implement audit, flow filters/export, company/user management, accounting
settings, and reminders as working prototype interactions.
- [x] Implement cashier task queue, upload/parser flow, own-company flows,
reconciliation, bank accounts, and notifications.
- [x] Add cashier manual records as audited evidence, with administrator review
before any record can affect intercompany calculations.
- [x] Complete cashier account submission -> administrator review -> enabled
upload-account workflow; pending accounts remain excluded from recognition.
- [x] Localize unsupported-template diagnostics with original filename,
worksheet, scanned range, and candidate header rows.
- [x] Verify desktop/mobile layouts, keyboard behavior, console output, live bank
parsing, and the parser regression suite.
## Phase 3 - Data Service
- Persist dynamic companies, users, bank accounts, immutable source files, and
imports with server-side access controls.
- Build coverage intervals, matching, subject-rule, audit, reminder, export, and
monthly-close APIs.
- Add canonical same-company and intercompany transfer event storage.
## Phase 4 - Production Reconciliation
- Add opening-balance migrations, closed-period reopening, adjustment approval,
transit-account mapping, and comprehensive audit reporting.
- Add external messaging only after in-product reminders are stable.
## Definition Of Done For Phase 2
- The login entry makes the two portals and their responsibilities unmistakable.
- Administrator can reach company -> direction -> counterparty/subject -> row.
- Administrator can query any A-B pair and find audit/flow/settings/reminder tools.
- Cashier sees only one company's actionable tasks and data.
- Upload still calls the real parser and reports detected bank/header/row count.
- Manual records and account registrations remain excluded until administrator
approval, and review outcomes are visible in both portals.
- Layouts remain usable at 375px, 768px, 1024px, and 1440px.
+227
View File
@@ -0,0 +1,227 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** 集团资金往来工作台
**Generated:** 2026-08-06 10:54:28
**Category:** Financial Dashboard
**Design Dials:** Variance 4/10 (Balanced / Modern) | Motion 3/10 (Subtle) | Density 8/10 (Dense / Dashboard)
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#1E40AF` | `--color-primary` |
| On Primary | `#FFFFFF` | `--color-on-primary` |
| Secondary | `#3B82F6` | `--color-secondary` |
| Accent/CTA | `#059669` | `--color-accent` |
| Background | `#0F172A` | `--color-background` |
| Foreground | `#FFFFFF` | `--color-foreground` |
| Muted | `#101A34` | `--color-muted` |
| Border | `rgba(255,255,255,0.08)` | `--color-border` |
| Destructive | `#DC2626` | `--color-destructive` |
| Ring | `#1E40AF` | `--color-ring` |
**Color Notes:** Trust blue + profit green on dark
### Typography
- **Heading Font:** Fira Code
- **Body Font:** Fira Sans
- **Mood:** dashboard, data, analytics, code, technical, precise
- **Google Fonts:** [Fira Code + Fira Sans](https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap)
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
```
### Spacing Variables
*Density: 8/10 — Dense / Dashboard*
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `2px` / `0.125rem` | Tight gaps |
| `--space-sm` | `4px` / `0.25rem` | Icon gaps, inline spacing |
| `--space-md` | `8px` / `0.5rem` | Standard padding |
| `--space-lg` | `12px` / `0.75rem` | Section padding |
| `--space-xl` | `16px` / `1rem` | Large gaps |
| `--space-2xl` | `24px` / `1.5rem` | Section margins |
| `--space-3xl` | `32px` / `2rem` | Hero padding |
### Shadow Depths
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #059669;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #1E40AF;
border: 2px solid #1E40AF;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #0F172A;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #1E40AF;
outline: none;
box-shadow: 0 0 0 3px #1E40AF20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Soft UI Evolution
**Keywords:** Evolved soft UI, better contrast, modern aesthetics, subtle depth, accessibility-focused, improved shadows, hybrid
**Best For:** Modern enterprise apps, SaaS platforms, health/wellness, modern business tools, professional, hybrid
**Key Effects:** Improved shadows (softer than flat, clearer than neumorphism), modern (200-300ms), focus visible, WCAG AA/AAA
### Page Pattern
**Pattern Name:** Real-Time / Operations Landing
- **Conversion Strategy:** For ops/security/iot products. Demo or sandbox link. Trust signals.
- **CTA Placement:** Primary CTA in nav + After metrics
- **Section Order:** 1. Hero (product + live preview or status), 2. Key metrics/indicators, 3. How it works, 4. CTA (Start trial / Contact)
---
## Motion
**Scroll Reveal** (Subtle) — Trigger: scroll (viewport enter) | Duration: 300-400ms | Easing: `power1.out`
```js
gsap.from(el, { opacity: 0, y: 12, duration: 0.35, ease: 'power1.out', scrollTrigger: { trigger: el, start: 'top 90%', toggleActions: 'play none none reverse' } });
```
**Framework notes:** Requires the ScrollTrigger plugin registered once via gsap.registerPlugin(ScrollTrigger)
- ✅ Keep the y offset small (8-16px) so it reads as a fade, not a slide
- ❌ Don't reveal below-the-fold content needed for SEO/crawlers as invisible-by-default without a no-JS fallback
- ⚡ toggleActions 'play none none reverse' avoids re-triggering on every scroll direction change
---
## Anti-Patterns (Do NOT Use)
- ❌ Light mode default
- ❌ Slow rendering
### Additional Forbidden Patterns
-**Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
-**Missing cursor:pointer** — All clickable elements must have cursor:pointer
-**Layout-shifting hovers** — Avoid scale transforms that shift layout
-**Low contrast text** — Maintain 4.5:1 minimum contrast ratio
-**Instant state changes** — Always use transitions (150-300ms)
-**Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile
+48
View File
@@ -0,0 +1,48 @@
# Dashboard Page Overrides
> **PROJECT:** 集团资金往来工作台
> **Generated:** 2026-08-06 10:54:28
> **Page Type:** Dashboard / Data View
> ⚠️ **IMPORTANT:** Rules in this file **override** the Master file (`design-system/MASTER.md`).
> Only deviations from the Master are documented here. For all other rules, refer to the Master.
---
## Page-Specific Rules
### Layout Overrides
- **Max Width:** 1200px (standard)
- **Layout:** Full-width sections, centered content
- **Sections:** 1. Hero (product + live preview or status), 2. Key metrics/indicators, 3. How it works, 4. CTA (Start trial / Contact)
### Spacing Overrides
- No overrides — use Master spacing
### Typography Overrides
- No overrides — use Master typography
### Color Overrides
- **Strategy:** Dark or neutral. Status colors (green/amber/red). Data-dense but scannable.
### Component Overrides
- Avoid: Leave UI frozen with no feedback
---
## Page-Specific Components
- No unique components for this page
---
## Recommendations
- Effects: Number animations (count-up), trend direction indicators, percentage change animations, profit/loss color transitions
- Animation: Use skeleton screens or spinners
- CTA Placement: Primary CTA in nav + After metrics
+2
View File
@@ -0,0 +1,2 @@
openpyxl>=3.1,<4
xlrd>=2.0,<3
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import re
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from tempfile import NamedTemporaryFile
from urllib.parse import urlparse
from bank_importer.parser import StatementParseError, parse_statement
ROOT = Path(__file__).resolve().parent
WEB_ROOT = ROOT / "web"
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
class AppHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(WEB_ROOT), **kwargs)
def do_POST(self) -> None:
if urlparse(self.path).path != "/api/parse":
self.send_error(404)
return
try:
filename, content = self._read_uploaded_file()
suffix = Path(filename).suffix.lower()
if suffix not in {".xls", ".xlsx"}:
raise ValueError("仅支持 .xls 或 .xlsx 银行流水文件。")
with NamedTemporaryFile(suffix=suffix, delete=False) as temp_file:
temp_path = Path(temp_file.name)
temp_file.write(content)
try:
batches = parse_statement(temp_path)
except StatementParseError as exc:
message = str(exc).replace(temp_path.name, filename)
raise StatementParseError(message) from exc
finally:
temp_path.unlink(missing_ok=True)
batch = batches[0]
self._send_json(
200,
{
"status": "parsed",
"bank": batch.bank_name,
"template": batch.template_id,
"header_row": batch.header_row,
"period_start": batch.period_start.isoformat()
if batch.period_start
else None,
"period_end": batch.period_end.isoformat()
if batch.period_end
else None,
"transactions": len(batch.transactions),
"warnings": list(batch.warnings),
},
)
except (ValueError, StatementParseError) as exc:
self._send_json(422, {"status": "exception", "message": str(exc)})
except Exception:
self._send_json(
500,
{"status": "error", "message": "文件解析失败,请检查文件是否完整。"},
)
def _read_uploaded_file(self) -> tuple[str, bytes]:
content_length = int(self.headers.get("Content-Length", "0"))
if content_length <= 0 or content_length > MAX_UPLOAD_BYTES:
raise ValueError("文件为空或超过 20 MB 限制。")
content_type = self.headers.get("Content-Type", "")
boundary_match = re.search(r"boundary=(?:\"([^\"]+)\"|([^;]+))", content_type)
if not boundary_match:
raise ValueError("上传请求缺少文件边界。")
boundary = (boundary_match.group(1) or boundary_match.group(2)).encode()
body = self.rfile.read(content_length)
for part in body.split(b"--" + boundary):
if b'name="file"' not in part:
continue
header, separator, content = part.partition(b"\r\n\r\n")
if not separator:
continue
filename_match = re.search(br'filename="([^\"]+)"', header)
filename = (
filename_match.group(1).decode("utf-8", errors="replace")
if filename_match
else "statement.xlsx"
)
return os.path.basename(filename), content.rstrip(b"\r\n")
raise ValueError("上传请求中没有找到文件。")
def _send_json(self, status: int, payload: dict[str, object]) -> None:
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(content)
def main() -> None:
port = int(os.environ.get("APP_PORT", "4173"))
server = ThreadingHTTPServer(("127.0.0.1", port), AppHandler)
print(f"Serving on http://127.0.0.1:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
from .models import NormalizedTransaction, StatementBatch
from .parser import parse_directory, parse_statement
__all__ = [
"NormalizedTransaction",
"StatementBatch",
"parse_directory",
"parse_statement",
]
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from .parser import StatementParseError, parse_directory, parse_statement
def main() -> int:
parser = argparse.ArgumentParser(description="Parse bank statements by header signature.")
parser.add_argument("path", type=Path, help="Statement file or directory")
args = parser.parse_args()
try:
batches = (
parse_directory(args.path)
if args.path.is_dir()
else parse_statement(args.path)
)
except (OSError, StatementParseError) as exc:
parser.error(str(exc))
summaries = [
{
"file": batch.source_file.name,
"sheet": batch.sheet_name,
"bank": batch.bank_name,
"template": batch.template_id,
"header_row": batch.header_row,
"period_start": batch.period_start.isoformat() if batch.period_start else None,
"period_end": batch.period_end.isoformat() if batch.period_end else None,
"transactions": len(batch.transactions),
"warnings": list(batch.warnings),
}
for batch in batches
]
print(json.dumps(summaries, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
@dataclass(frozen=True)
class NormalizedTransaction:
source_file: Path
sheet_name: str
source_row: int
transaction_at: datetime
income: Decimal
expense: Decimal
balance: Decimal | None
own_account: str | None
own_name: str | None
counterparty_account: str | None
counterparty_name: str | None
counterparty_bank: str | None
summary: str | None
purpose: str | None
reference: str | None
currency: str | None
@dataclass(frozen=True)
class StatementBatch:
source_file: Path
sheet_name: str
bank_name: str
template_id: str
header_row: int
own_account: str | None
own_name: str | None
period_start: date | None
period_end: date | None
transactions: tuple[NormalizedTransaction, ...]
warnings: tuple[str, ...]
+399
View File
@@ -0,0 +1,399 @@
from __future__ import annotations
from datetime import date, datetime, time
from decimal import Decimal, InvalidOperation
from itertools import groupby
from pathlib import Path
import re
from typing import Any, Iterable
from .models import NormalizedTransaction, StatementBatch
from .reader import RawSheet, read_workbook
from .templates import BankTemplate, TEMPLATES, normalize_header
class StatementParseError(RuntimeError):
pass
class UnknownTemplateError(StatementParseError):
pass
class AmbiguousTemplateError(StatementParseError):
pass
def detect_header(
rows: tuple[tuple[Any, ...], ...], scan_limit: int = 50
) -> tuple[BankTemplate, int, dict[str, int]]:
candidates: list[tuple[int, BankTemplate, int, dict[str, int]]] = []
for row_index, row in enumerate(rows[:scan_limit]):
normalized = [normalize_header(value) for value in row]
for template in TEMPLATES:
column_map: dict[str, int] = {}
for field, aliases in template.columns.items():
for column_index, header in enumerate(normalized):
if header and header in aliases:
column_map[field] = column_index
break
if all(field in column_map for field in template.required):
candidates.append((len(column_map), template, row_index, column_map))
if not candidates:
inspected = min(len(rows), scan_limit)
candidate_rows = _header_candidate_summary(rows[:scan_limit])
detail = f";候选表头:{candidate_rows}" if candidate_rows else ""
raise UnknownTemplateError(
f"未识别到受支持的银行表头(已扫描前 {inspected} 行){detail}"
)
candidates.sort(key=lambda item: item[0], reverse=True)
best_score = candidates[0][0]
best = [candidate for candidate in candidates if candidate[0] == best_score]
identities = {(item[1].template_id, item[2]) for item in best}
if len(identities) > 1:
matches = ", ".join(f"{item[1].bank_name}@{item[2] + 1}" for item in best)
raise AmbiguousTemplateError(f"Ambiguous bank header signatures: {matches}")
_, template, row_index, column_map = best[0]
return template, row_index, column_map
def parse_statement(path: str | Path) -> tuple[StatementBatch, ...]:
source = Path(path)
batches: list[StatementBatch] = []
errors: list[str] = []
for sheet in read_workbook(source):
if not any(any(_text(value) for value in row) for row in sheet.rows):
continue
try:
batches.append(_parse_sheet(source, sheet))
except UnknownTemplateError as exc:
errors.append(f"{sheet.name}: {exc}")
if not batches:
detail = "; ".join(errors) or "Workbook contains no readable worksheets."
raise UnknownTemplateError(f"{source.name}: {detail}")
return tuple(batches)
def parse_directory(path: str | Path) -> tuple[StatementBatch, ...]:
root = Path(path)
files = sorted(
file
for file in root.rglob("*")
if file.is_file()
and file.suffix.lower() in {".xls", ".xlsx"}
and not file.name.startswith("~$")
and file.stat().st_size > 0
)
return tuple(batch for file in files for batch in parse_statement(file))
def _header_candidate_summary(
rows: tuple[tuple[Any, ...], ...], limit: int = 3
) -> str:
known_headers = {
alias
for template in TEMPLATES
for aliases in template.columns.values()
for alias in aliases
}
candidates: list[tuple[int, int, int, tuple[str, ...]]] = []
for row_index, row in enumerate(rows):
values = tuple(_text(value) for value in row if _text(value))
if len(values) < 2:
continue
matched = sum(normalize_header(value) in known_headers for value in values)
candidates.append((matched, len(values), row_index, values))
candidates.sort(key=lambda item: (item[0], item[1]), reverse=True)
summaries: list[str] = []
for _, _, row_index, values in candidates[:limit]:
preview = "".join(value[:24] for value in values[:8])
if len(values) > 8:
preview += "……"
summaries.append(f"{row_index + 1} 行「{preview}")
return "".join(summaries)
def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch:
template, header_index, columns = detect_header(sheet.rows)
metadata = _extract_metadata(sheet.rows[:header_index])
transactions: list[NormalizedTransaction] = []
warnings: list[str] = []
for row_index, row in enumerate(sheet.rows[header_index + 1 :], header_index + 2):
transaction_at = _transaction_datetime(row, columns)
if transaction_at is None:
continue
income = _decimal(_value(row, columns.get("income")))
expense = _decimal(_value(row, columns.get("expense")))
if income == 0 and expense == 0:
continue
if income > 0 and expense > 0:
warnings.append(f"Row {row_index} has both income and expense.")
transactions.append(
NormalizedTransaction(
source_file=source,
sheet_name=sheet.name,
source_row=row_index,
transaction_at=transaction_at,
income=income,
expense=expense,
balance=_optional_decimal(_value(row, columns.get("balance"))),
own_account=_optional_text(_value(row, columns.get("own_account"))),
own_name=_optional_text(_value(row, columns.get("own_name"))),
counterparty_account=_optional_text(
_value(row, columns.get("counterparty_account"))
),
counterparty_name=_optional_text(
_value(row, columns.get("counterparty_name"))
),
counterparty_bank=_optional_text(
_value(row, columns.get("counterparty_bank"))
),
summary=_optional_text(_value(row, columns.get("summary"))),
purpose=_optional_text(_value(row, columns.get("purpose"))),
reference=_optional_text(_value(row, columns.get("reference"))),
currency=_optional_text(_value(row, columns.get("currency"))),
)
)
if not transactions:
raise StatementParseError(
f"{source.name}/{sheet.name}: header found but no transaction rows parsed."
)
own_account = metadata.get("own_account") or _first_value(
transaction.own_account for transaction in transactions
)
own_name = metadata.get("own_name") or _first_value(
transaction.own_name for transaction in transactions
)
warnings.extend(_balance_warnings(transactions))
return StatementBatch(
source_file=source,
sheet_name=sheet.name,
bank_name=template.bank_name,
template_id=template.template_id,
header_row=header_index + 1,
own_account=own_account if isinstance(own_account, str) else None,
own_name=own_name if isinstance(own_name, str) else None,
period_start=metadata.get("period_start")
if isinstance(metadata.get("period_start"), date)
else None,
period_end=metadata.get("period_end")
if isinstance(metadata.get("period_end"), date)
else None,
transactions=tuple(transactions),
warnings=tuple(warnings),
)
def _extract_metadata(rows: tuple[tuple[Any, ...], ...]) -> dict[str, str | date]:
result: dict[str, str | date] = {}
labels = {
"账号": "own_account",
"账/卡号": "own_account",
"户名": "own_name",
"账户名称": "own_name",
"起始日期": "period_start",
"开始日期": "period_start",
"截止日期": "period_end",
"结束日期": "period_end",
}
for row in rows:
for index, value in enumerate(row):
text = _text(value)
if not text:
continue
key_text, inline_value = _split_label(text)
key = labels.get(key_text)
if key:
candidate: Any = inline_value or _next_nonempty(row, index + 1)
if key.startswith("period_"):
parsed = _date_value(candidate)
if parsed:
result[key] = parsed
elif candidate is not None:
result[key] = _text(candidate)
if "起止日期" in text:
dates = _dates_in_text(text)
if len(dates) >= 2:
result["period_start"], result["period_end"] = dates[:2]
return result
def _transaction_datetime(row: tuple[Any, ...], columns: dict[str, int]) -> datetime | None:
if "transaction_at" in columns:
return _datetime_value(_value(row, columns["transaction_at"]))
transaction_date = _date_value(_value(row, columns.get("transaction_date")))
if transaction_date is None:
return None
transaction_time = _time_value(_value(row, columns.get("transaction_time")))
return datetime.combine(transaction_date, transaction_time or time.min)
def _datetime_value(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, time.min)
text = _text(value)
if not text:
return None
normalized = text.replace("", "-").replace("", "-").replace("", " ")
normalized = " ".join(normalized.split())
for pattern in (
"%Y-%m-%d %H:%M:%S",
"%Y/%m/%d %H:%M:%S",
"%Y%m%d %H:%M:%S",
"%Y-%m-%d",
"%Y/%m/%d",
"%Y%m%d",
):
try:
return datetime.strptime(normalized, pattern)
except ValueError:
continue
return None
def _date_value(value: Any) -> date | None:
parsed = _datetime_value(value)
return parsed.date() if parsed else None
def _time_value(value: Any) -> time | None:
if isinstance(value, datetime):
return value.time()
if isinstance(value, time):
return value
text = _text(value)
if not text:
return None
for pattern in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(text, pattern).time()
except ValueError:
continue
return None
def _dates_in_text(value: str) -> list[date]:
date_strings = re.findall(
r"20\d{2}(?:年|-|/)?\d{1,2}(?:月|-|/)?\d{1,2}日?", value
)
return [parsed for item in date_strings if (parsed := _date_value(item))]
def _decimal(value: Any) -> Decimal:
if value is None:
return Decimal("0")
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value))
text = _text(value)
if not text:
return Decimal("0")
text = text.replace(",", "").replace("¥", "").replace("", "")
text = text.removesuffix("")
negative = text.startswith("(") and text.endswith(")")
if negative:
text = text[1:-1]
try:
amount = Decimal(text)
except InvalidOperation as exc:
raise StatementParseError(f"Invalid amount: {value!r}") from exc
return -amount if negative else amount
def _optional_decimal(value: Any) -> Decimal | None:
if value is None or _text(value) == "":
return None
return _decimal(value)
def _balance_warnings(
transactions: list[NormalizedTransaction],
) -> list[str]:
warnings: list[str] = []
if len(transactions) < 2:
return warnings
descending_source = transactions[0].transaction_at > transactions[-1].transaction_at
ordered = sorted(transactions, key=lambda item: item.transaction_at)
groups = [
list(items)
for _, items in groupby(ordered, key=lambda item: item.transaction_at)
]
def source_endpoint(group: list[NormalizedTransaction]) -> NormalizedTransaction:
key = lambda item: item.source_row
return min(group, key=key) if descending_source else max(group, key=key)
previous_balance = source_endpoint(groups[0]).balance
for group in groups[1:]:
if previous_balance is None:
previous_balance = source_endpoint(group).balance
continue
expected = previous_balance + sum(
(item.income - item.expense for item in group), Decimal("0")
)
matching_balance = next(
(
item.balance
for item in group
if item.balance is not None
and abs(expected - item.balance) <= Decimal("0.01")
),
None,
)
if matching_balance is not None:
previous_balance = matching_balance
continue
rows = ",".join(str(item.source_row) for item in group)
warnings.append(f"Rows {rows} do not reconcile by balance.")
previous_balance = source_endpoint(group).balance
return warnings
def _value(row: tuple[Any, ...], column: int | None) -> Any:
if column is None or column >= len(row):
return None
return row[column]
def _text(value: Any) -> str:
return "" if value is None else str(value).strip()
def _optional_text(value: Any) -> str | None:
text = _text(value)
return text or None
def _split_label(value: str) -> tuple[str, str]:
parts = re.split(r"[:]", value, maxsplit=1)
key = parts[0].strip()
return key, parts[1].strip() if len(parts) == 2 else ""
def _next_nonempty(row: tuple[Any, ...], start: int) -> Any:
for value in row[start:]:
if _text(value):
return value
return None
def _first_value(values: Iterable[str | None]) -> str | None:
return next((value for value in values if value), None)
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
class UnsupportedWorkbookError(RuntimeError):
pass
@dataclass(frozen=True)
class RawSheet:
name: str
rows: tuple[tuple[Any, ...], ...]
def read_workbook(path: Path) -> tuple[RawSheet, ...]:
suffix = path.suffix.lower()
if suffix == ".xlsx":
return _read_xlsx(path)
if suffix == ".xls":
return _read_xls(path)
raise UnsupportedWorkbookError(f"Unsupported workbook type: {suffix}")
def _read_xlsx(path: Path) -> tuple[RawSheet, ...]:
try:
from openpyxl import load_workbook
except ImportError as exc:
raise UnsupportedWorkbookError(
"Reading .xlsx files requires openpyxl. Install requirements.txt."
) from exc
workbook = load_workbook(path, read_only=True, data_only=True)
try:
return tuple(
RawSheet(
name=worksheet.title,
rows=tuple(tuple(row) for row in worksheet.iter_rows(values_only=True)),
)
for worksheet in workbook.worksheets
)
finally:
workbook.close()
def _read_xls(path: Path) -> tuple[RawSheet, ...]:
try:
import xlrd
except ImportError as exc:
raise UnsupportedWorkbookError(
"Reading .xls files requires xlrd. Install requirements.txt."
) from exc
workbook = xlrd.open_workbook(path, on_demand=True)
sheets: list[RawSheet] = []
try:
for sheet_name in workbook.sheet_names():
worksheet = workbook.sheet_by_name(sheet_name)
rows: list[tuple[Any, ...]] = []
for row_index in range(worksheet.nrows):
values: list[Any] = []
for column_index in range(worksheet.ncols):
cell = worksheet.cell(row_index, column_index)
value = cell.value
if cell.ctype == xlrd.XL_CELL_DATE:
value = xlrd.xldate_as_datetime(value, workbook.datemode)
values.append(value)
rows.append(tuple(values))
sheets.append(RawSheet(name=sheet_name, rows=tuple(rows)))
finally:
workbook.release_resources()
return tuple(sheets)
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from dataclasses import dataclass
import unicodedata
@dataclass(frozen=True)
class BankTemplate:
template_id: str
bank_name: str
columns: dict[str, tuple[str, ...]]
required: tuple[str, ...]
def normalize_header(value: object) -> str:
if value is None:
return ""
text = unicodedata.normalize("NFKC", str(value)).strip().casefold()
return "".join(text.split())
def _aliases(*values: str) -> tuple[str, ...]:
return tuple(normalize_header(value) for value in values)
TEMPLATES: tuple[BankTemplate, ...] = (
BankTemplate(
template_id="citic-account-detail-v1",
bank_name="中信银行",
columns={
"transaction_date": _aliases("交易日期"),
"transaction_time": _aliases("交易时间"),
"counterparty_account": _aliases("对方账号"),
"counterparty_name": _aliases("对方账户名称"),
"counterparty_bank": _aliases("对方账号开户网点名称"),
"expense": _aliases("借方发生额"),
"income": _aliases("贷方发生额"),
"balance": _aliases("账户余额"),
"summary": _aliases("摘要"),
"purpose": _aliases("附言"),
"currency": _aliases("币种"),
"own_account": _aliases("交易账号"),
"reference": _aliases("柜员交易号", "发起方流水号"),
},
required=(
"transaction_date",
"transaction_time",
"counterparty_account",
"expense",
"income",
"balance",
),
),
BankTemplate(
template_id="abc-account-detail-v1",
bank_name="中国农业银行",
columns={
"transaction_at": _aliases("交易时间"),
"income": _aliases("收入金额"),
"expense": _aliases("支出金额"),
"balance": _aliases("账户余额"),
"counterparty_account": _aliases("对方账号"),
"counterparty_name": _aliases("对方户名"),
"counterparty_bank": _aliases("对方开户行"),
"summary": _aliases("摘要"),
},
required=(
"transaction_at",
"income",
"expense",
"balance",
"counterparty_account",
"counterparty_name",
"summary",
),
),
BankTemplate(
template_id="icbc-history-detail-v1",
bank_name="中国工商银行",
columns={
"reference": _aliases("凭证号"),
"own_account": _aliases("本方账号"),
"counterparty_account": _aliases("对方账号"),
"transaction_at": _aliases("交易时间"),
"expense": _aliases("借方发生额"),
"income": _aliases("贷方发生额"),
"summary": _aliases("摘要"),
"purpose": _aliases("用途"),
"counterparty_name": _aliases("对方单位名称"),
"balance": _aliases("余额"),
},
required=(
"reference",
"own_account",
"transaction_at",
"expense",
"income",
"counterparty_name",
"balance",
),
),
BankTemplate(
template_id="ccb-account-detail-v1",
bank_name="中国建设银行",
columns={
"own_account": _aliases("客户账号"),
"own_name": _aliases("账户名称"),
"transaction_at": _aliases("交易时间"),
"expense": _aliases("借方发生额(支取)"),
"income": _aliases("贷方发生额(收入)"),
"balance": _aliases("余额"),
"currency": _aliases("币种"),
"counterparty_name": _aliases("对方户名"),
"counterparty_account": _aliases("对方账号"),
"counterparty_bank": _aliases("对方开户机构"),
"summary": _aliases("摘要"),
"purpose": _aliases("备注"),
},
required=(
"own_account",
"transaction_at",
"expense",
"income",
"balance",
"counterparty_account",
),
),
BankTemplate(
template_id="henan-rural-history-v1",
bank_name="河南农商银行",
columns={
"transaction_at": _aliases("交易时间"),
"reference": _aliases("交易流水号"),
"income": _aliases("收入"),
"expense": _aliases("支出"),
"balance": _aliases("账户余额"),
"summary": _aliases("交易类型"),
"counterparty_account": _aliases("对方帐号"),
"counterparty_name": _aliases("对方户名"),
"counterparty_bank": _aliases("交易网点"),
"purpose": _aliases("备注"),
},
required=(
"transaction_at",
"reference",
"income",
"expense",
"balance",
"counterparty_account",
"counterparty_name",
),
),
BankTemplate(
template_id="bank-of-zhengzhou-detail-v1",
bank_name="郑州银行",
columns={
"transaction_at": _aliases("交易时间"),
"income": _aliases("收入金额(元)"),
"expense": _aliases("支出金额(元)"),
"balance": _aliases("账户余额(元)"),
"counterparty_name": _aliases("对方户名"),
"counterparty_account": _aliases("对方账号"),
"counterparty_bank": _aliases("对方账户开户行"),
"purpose": _aliases("用途"),
"summary": _aliases("摘要"),
"currency": _aliases("币种"),
"reference": _aliases("交易流水号"),
},
required=(
"transaction_at",
"income",
"expense",
"balance",
"counterparty_account",
"reference",
),
),
)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
import unittest
from bank_importer.parser import UnknownTemplateError, detect_header, parse_directory
ROOT = Path(__file__).resolve().parents[1]
SAMPLES = ROOT / "流水模板"
class StatementParserTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.batches = parse_directory(SAMPLES)
cls.by_file = {batch.source_file.name: batch for batch in cls.batches}
def test_all_current_bank_samples_are_recognized(self) -> None:
expected = {
"中信银行账户流水.xlsx": ("中信银行", 16, 4),
"中国农业银行账户流水.xls": ("中国农业银行", 3, 4),
"中国工商银行账户流水.xlsx": ("中国工商银行", 2, 5),
"中国建设银行账户流水.xls": ("中国建设银行", 1, 1),
"河南农商银行账户流水.xlsx": ("河南农商银行", 11, 1),
"郑州银行账户流水.xls": ("郑州银行", 7, 5),
}
self.assertEqual(set(expected), set(self.by_file))
for filename, (bank, header_row, transaction_count) in expected.items():
with self.subTest(filename=filename):
batch = self.by_file[filename]
self.assertEqual(bank, batch.bank_name)
self.assertEqual(header_row, batch.header_row)
self.assertEqual(transaction_count, len(batch.transactions))
def test_known_statement_periods_are_extracted(self) -> None:
expected = {
"中信银行账户流水.xlsx": (date(2026, 4, 26), date(2026, 5, 23)),
"中国农业银行账户流水.xls": (date(2025, 11, 26), date(2025, 12, 27)),
"河南农商银行账户流水.xlsx": (date(2026, 2, 24), date(2026, 3, 28)),
"郑州银行账户流水.xls": (date(2026, 2, 24), date(2026, 3, 27)),
}
for filename, period in expected.items():
with self.subTest(filename=filename):
batch = self.by_file[filename]
self.assertEqual(period, (batch.period_start, batch.period_end))
def test_normalized_transactions_have_one_cash_direction(self) -> None:
for batch in self.batches:
for transaction in batch.transactions:
with self.subTest(
filename=batch.source_file.name, row=transaction.source_row
):
self.assertNotEqual(transaction.income > 0, transaction.expense > 0)
def test_current_samples_reconcile_by_running_balance(self) -> None:
for batch in self.batches:
with self.subTest(filename=batch.source_file.name):
self.assertEqual((), batch.warnings)
def test_column_order_does_not_affect_header_detection(self) -> None:
header = (
"余额",
"对方单位名称",
"贷方发生额",
"交易时间",
"本方账号",
"借方发生额",
"凭证号",
)
template, row_index, columns = detect_header((header,))
self.assertEqual("icbc-history-detail-v1", template.template_id)
self.assertEqual(0, row_index)
self.assertEqual(3, columns["transaction_at"])
def test_unknown_header_is_rejected(self) -> None:
with self.assertRaises(UnknownTemplateError) as raised:
detect_header((("日期", "金额", "备注"),))
message = str(raised.exception)
self.assertIn("未识别到受支持的银行表头", message)
self.assertIn("第 1 行", message)
self.assertIn("日期、金额、备注", message)
if __name__ == "__main__":
unittest.main()
+210
View File
@@ -0,0 +1,210 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="往来台账总账管理端" />
<title>总账管理端 · 往来台账</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body data-portal="admin">
<!--
THESIS: 总账端是一座可下钻的集团资金控制台,拒绝平铺报表式首页。
OWN-WORLD: 深黑工作场景、半透明石墨玻璃、荧光绿指令和琥珀红风险信号;大圆角与细光边建立空间层级。
STORY: 管理员先从四个关键指标定位风险,再进入审核、账期或公司借贷目录逐层追溯。
FIRST VIEWPORT: 浮动侧栏与窄顶栏围合工作区;四张统计卡领衔,计算口径和优先事项紧随其后,借贷目录承接下钻。
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
<div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>往来台账</strong><small>总账管理端</small></span></div>
<nav class="nav-list">
<button class="nav-item is-active" data-view="dashboard"><svg><use href="icons.svg#layout-dashboard"/></svg><span>管理总览</span></button>
<button class="nav-item" data-view="pair"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来查询</span></button>
<button class="nav-item" data-view="audit"><svg><use href="icons.svg#list-checks"/></svg><span>审核中心</span><b>6</b></button>
<button class="nav-item" data-view="flows"><svg><use href="icons.svg#file-spreadsheet"/></svg><span>流水管理</span></button>
<button class="nav-item" data-view="companies"><svg><use href="icons.svg#users"/></svg><span>公司与账号</span></button>
<button class="nav-item" data-view="settings"><svg><use href="icons.svg#calendar"/></svg><span>结账与期初</span></button>
<button class="nav-item" data-view="reminders"><svg><use href="icons.svg#bell"/></svg><span>提醒管理</span><b>4</b></button>
</nav>
<div class="sidebar-footer">
<a class="nav-item" href="index.html"><svg><use href="icons.svg#log-out"/></svg><span>退出登录</span></a>
<div class="user-block"><span class="avatar"></span><span><strong>系统管理员</strong><small>全部公司权限</small></span></div>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<button class="icon-button menu-button" id="menuButton" aria-label="打开导航" aria-expanded="false" aria-controls="sidebar" title="打开导航"><svg><use href="icons.svg#menu"/></svg></button>
<div class="workspace-name"><span>集团财务中心</span><strong id="currentViewName">管理总览</strong></div>
<span class="demo-badge">演示数据</span>
<div class="topbar-actions">
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">全局检索</span><input id="globalSearch" type="search" placeholder="检索公司、账号或流水" /></label>
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>截至 2026.07.31</span></button>
<button class="icon-button" data-view-link="reminders" aria-label="提醒管理,4 项未处理" title="提醒管理"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">4</i></button>
</div>
</header>
<main id="main-content">
<section class="app-view is-active" data-page="dashboard">
<header class="page-heading"><div><h1>管理总览</h1><p>集团账务口径与高优先级事项</p></div><button class="button secondary" data-view-link="settings"><svg><use href="icons.svg#settings"/></svg>账期设置</button></header>
<section class="metric-grid" aria-label="总账关键指标">
<article class="metric-card danger" data-metric-link="audit" tabindex="0" role="button" aria-label="查看 7 项待审核事项">
<div class="metric-copy"><span class="metric-label">待审核事项</span><strong class="metric-value">7<small></small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#list-checks"/></svg></span>
<p class="metric-foot"><strong>2 项高风险</strong> · 影响当前结账</p>
</article>
<article class="metric-card warning" data-metric-link="audit" tabindex="0" role="button" aria-label="查看 3 个流水异常账户">
<div class="metric-copy"><span class="metric-label">流水异常账户</span><strong class="metric-value">3<small></small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#circle-alert"/></svg></span>
<p class="metric-foot"><strong>1 个已逾期</strong> · 最长缺口 21 天</p>
</article>
<article class="metric-card" data-metric-link="reminders" tabindex="0" role="button" aria-label="查看 2 家未完成提交公司">
<div class="metric-copy"><span class="metric-label">未完成提交公司</span><strong class="metric-value">2<small>/ 6</small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#users"/></svg></span>
<p class="metric-foot"><strong>4 家已完成</strong> · 截止 08.05</p>
</article>
<article class="metric-card success" data-metric-link="settings" tabindex="0" role="button" aria-label="查看当前结账设置和状态">
<div class="metric-copy"><span class="metric-label">当前结账状态</span><strong class="metric-value">待处理</strong></div>
<span class="metric-icon"><svg><use href="icons.svg#calendar"/></svg></span>
<p class="metric-foot"><strong>2026 年 7 月</strong> · 3 项条件未通过</p>
</article>
</section>
<section class="period-ribbon" aria-label="当前计算口径">
<div><span>全局起算日</span><strong>2026.01.01</strong></div>
<div><span>当前统计截止</span><strong>2026.07.31</strong></div>
<div><span>每月结账日</span><strong>次月 5 日</strong></div>
<div class="period-warning"><svg><use href="icons.svg#circle-alert"/></svg><span><strong>2 家公司存在起算缺口</strong><small>最早缺口始于 2026.01.01</small></span><button class="text-button" data-view-link="audit">去审核</button></div>
</section>
<div class="admin-dashboard-grid">
<section class="panel priority-panel">
<div class="panel-heading"><div><h2>优先处理</h2><p>影响当前账期可靠性的事项</p></div><button class="text-button" data-view-link="audit">查看全部 7 项</button></div>
<div class="task-list compact-task-list">
<button data-view-link="audit"><span class="task-level danger"></span><span><strong>A公司 · 工行账户断档</strong><small>缺少 07.01—07.21,影响 7 月结账</small></span><b>已逾期 2 天</b><svg><use href="icons.svg#chevron-right"/></svg></button>
<button data-view-link="audit"><span class="task-level warning"></span><span><strong>B公司 ↔ D公司 · 单边待匹配</strong><small>6 笔,合计 312.00 万元</small></span><b>今日新增</b><svg><use href="icons.svg#chevron-right"/></svg></button>
<button data-view-link="audit"><span class="task-level warning"></span><span><strong>F公司 · 起算区间待校准</strong><small>01.01—01.16 无银行流水覆盖</small></span><b>待公司确认</b><svg><use href="icons.svg#chevron-right"/></svg></button>
</div>
</section>
<aside class="panel quick-pair-panel">
<div class="panel-heading"><div><h2>公司往来快查</h2><p>直接查询任意两个公司</p></div></div>
<form class="quick-pair-form" data-pair-form>
<label class="field"><span>本方公司</span><select name="from"><option>A公司</option><option>B公司</option><option>C公司</option><option>D公司</option><option>E公司</option><option>F公司</option></select></label>
<button class="swap-button" type="button" data-swap aria-label="交换公司" title="交换公司"><svg><use href="icons.svg#arrow-left-right"/></svg></button>
<label class="field"><span>对方公司</span><select name="to"><option>B公司</option><option>C公司</option><option>D公司</option><option>E公司</option><option>F公司</option><option>A公司</option></select></label>
<button class="button primary" type="submit"><svg><use href="icons.svg#search"/></svg>查询往来</button>
</form>
<div class="quick-result"><span>A公司对B公司</span><strong>应收 1,280.00<small>万元</small></strong><p>已确认 4 笔 · 待审核 1 笔</p></div>
</aside>
</div>
<section class="panel company-ledger-panel">
<div class="panel-heading"><div><h2>公司借贷目录</h2><p>统计区间 2026.01.01—2026.07.31 · 单位:万元</p></div><label class="inline-search"><svg><use href="icons.svg#search"/></svg><input data-filter-target="companyLedgers" placeholder="快速检索公司" /></label></div>
<div class="ledger-head"><span>公司</span><span>借方合计</span><span>贷方合计</span><span>期末方向</span><span>待审核</span><span></span></div>
<div id="companyLedgers" class="company-ledgers">
<details class="company-ledger" open>
<summary><span class="company-name"><i>A</i><b>A公司</b><small>4 个银行账户</small></span><strong class="amount debit">2,416.00</strong><strong class="amount credit">1,136.00</strong><span><em class="status success">应收</em><b class="amount">1,280.00</b></span><span class="status warning">1 笔</span><svg><use href="icons.svg#chevron-down"/></svg></summary>
<div class="ledger-breakdown">
<section><header><h3>借方明细</h3><strong>2,416.00</strong></header><button class="subject-row" data-pair-link="A公司|B公司"><span><b>B公司</b><small>应收 · 4 笔</small></span><strong>1,280.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button><button class="subject-row" data-pair-link="A公司|C公司"><span><b>C公司</b><small>其他应收 · 3 笔</small></span><strong>1,136.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section>
<section><header><h3>贷方明细</h3><strong>1,136.00</strong></header><button class="subject-row" data-pair-link="A公司|D公司"><span><b>D公司</b><small>应付 · 2 笔</small></span><strong>728.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button><button class="subject-row" data-pair-link="A公司|E公司"><span><b>E公司</b><small>其他应付 · 2 笔</small></span><strong>408.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section>
</div>
</details>
<details class="company-ledger"><summary><span class="company-name"><i>B</i><b>B公司</b><small>3 个银行账户</small></span><strong class="amount debit">3,020.00</strong><strong class="amount credit">1,740.00</strong><span><em class="status danger">应付</em><b class="amount">1,280.00</b></span><span class="status warning">3 笔</span><svg><use href="icons.svg#chevron-down"/></svg></summary><div class="ledger-breakdown"><section><header><h3>借方明细</h3><strong>3,020.00</strong></header><button class="subject-row" data-pair-link="B公司|D公司"><span><b>D公司</b><small>其他应收 · 6 笔</small></span><strong>2,150.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section><section><header><h3>贷方明细</h3><strong>1,740.00</strong></header><button class="subject-row" data-pair-link="B公司|A公司"><span><b>A公司</b><small>应付 · 4 笔</small></span><strong>1,280.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section></div></details>
<details class="company-ledger"><summary><span class="company-name"><i>C</i><b>C公司</b><small>2 个银行账户</small></span><strong class="amount debit">1,894.00</strong><strong class="amount credit">1,052.00</strong><span><em class="status success">应收</em><b class="amount">842.00</b></span><span class="status neutral">0 笔</span><svg><use href="icons.svg#chevron-down"/></svg></summary><div class="ledger-breakdown"><section><header><h3>借方明细</h3><strong>1,894.00</strong></header><button class="subject-row" data-pair-link="C公司|E公司"><span><b>E公司</b><small>应收 · 5 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section><section><header><h3>贷方明细</h3><strong>1,052.00</strong></header><button class="subject-row" data-pair-link="C公司|A公司"><span><b>A公司</b><small>其他应付 · 3 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section></div></details>
</div>
</section>
</section>
<section class="app-view" data-page="pair">
<header class="page-heading"><div><h1>往来查询</h1><p>按公司对查询双方口径、科目和逐笔凭证</p></div></header>
<section class="query-band">
<form class="pair-query-form" data-pair-form>
<label class="field"><span>本方公司</span><select name="from"><option>A公司</option><option>B公司</option><option>C公司</option><option>D公司</option><option>E公司</option><option>F公司</option></select></label>
<button class="swap-button" type="button" data-swap aria-label="交换公司" title="交换公司"><svg><use href="icons.svg#arrow-left-right"/></svg></button>
<label class="field"><span>对方公司</span><select name="to"><option>B公司</option><option>C公司</option><option>D公司</option><option>E公司</option><option>F公司</option><option>A公司</option></select></label>
<label class="field"><span>统计截止</span><input name="end" type="date" value="2026-07-31" /></label>
<button class="button primary" type="submit"><svg><use href="icons.svg#search"/></svg>查询</button>
</form>
</section>
<section class="pair-report" id="pairReport">
<header class="pair-report-heading"><div><h2><span data-pair-from>A公司</span><span data-pair-to>B公司</span></h2><p id="pairPeriod">统计口径 2026.01.01—2026.07.31</p></div><span class="status warning" id="pairReviewStatus">含 1 笔待审核</span></header>
<div class="pair-balance-line"><div><span>期初余额</span><strong id="pairOpening">0.00</strong></div><div><span>本期借方</span><strong id="pairDebit">2,416.00</strong></div><div><span>本期贷方</span><strong id="pairCredit">1,136.00</strong></div><div class="pair-final"><span>期末结果</span><strong id="pairFinal">应收 1,280.00<small>万元</small></strong></div></div>
<div class="subject-strip"><button class="is-active" data-subject-filter="all" aria-pressed="true"><span>全部往来</span><strong data-subject-total="all">5 笔</strong></button><button data-subject-filter="应收" aria-pressed="false"><span>应收</span><strong data-subject-total="应收">1,280.00</strong></button><button data-subject-filter="其他应收" aria-pressed="false"><span>其他应收</span><strong data-subject-total="其他应收">640.00</strong></button><button data-subject-filter="应付" aria-pressed="false"><span>应付</span><strong data-subject-total="应付">728.00</strong></button><button data-subject-filter="其他应付" aria-pressed="false"><span>其他应付</span><strong data-subject-total="其他应付">408.00</strong></button></div>
<div class="table-scroll"><table class="data-table" id="pairTransactions"><thead><tr><th>交易日期</th><th>方向</th><th>科目</th><th>本方账户</th><th>对方账户</th><th>摘要</th><th>匹配</th><th class="number">金额(万元)</th></tr></thead><tbody><tr data-subject="应收"><td>2026.07.18</td><td>转出</td><td>应收</td><td>中信 · 5316</td><td>工行 · 9481</td><td>往来款</td><td><span class="status success">双边匹配</span></td><td class="number">1,000.00</td></tr><tr data-subject="应收"><td>2026.07.06</td><td>转入</td><td>应收</td><td>中信 · 5316</td><td>工行 · 9481</td><td>归还往来款</td><td><span class="status success">双边匹配</span></td><td class="number">320.00</td></tr><tr data-subject="其他应收"><td>2026.06.27</td><td>转出</td><td>其他应收</td><td>建行 · 0845</td><td>工行 · 9481</td><td>资金调拨</td><td><span class="status warning">单边待核</span></td><td class="number">600.00</td></tr></tbody></table></div>
</section>
</section>
<section class="app-view" data-page="audit">
<header class="page-heading"><div><h1>审核中心</h1><p>统一处理核算异常、账户登记与公司手工记录</p></div></header>
<section class="filter-bar"><div class="segmented" role="group" aria-label="审核类型"><button class="is-active" data-audit-filter="all" data-label="全部" aria-pressed="true">全部 6</button><button data-audit-filter="断档" data-label="流水断档" aria-pressed="false">流水断档 1</button><button data-audit-filter="单边" data-label="单边匹配" aria-pressed="false">单边匹配 1</button><button data-audit-filter="科目" data-label="科目待确认" aria-pressed="false">科目待确认 1</button><button data-audit-filter="起算" data-label="起算校准" aria-pressed="false">起算校准 1</button><button data-audit-filter="账户" data-label="账户登记" aria-pressed="false">账户登记 1</button><button data-audit-filter="手工" data-label="手工记录" aria-pressed="false">手工记录 1</button></div><label class="field compact"><span>公司</span><select id="auditCompany"><option>全部公司</option><option>A公司</option><option>B公司</option><option>E公司</option><option>F公司</option></select></label></section>
<section class="panel"><div class="table-scroll"><table class="data-table audit-table"><thead><tr><th>风险</th><th>公司 / 事项</th><th>类型</th><th>涉及期间</th><th>影响</th><th>状态</th><th>操作</th></tr></thead><tbody id="auditRows"><tr data-audit-type="断档" data-company="A公司"><td><span class="task-level danger"></span></td><td><strong>A公司 · 工商银行 9481</strong><small>上期截止 06.30,下期始于 07.22</small></td><td>流水断档</td><td>07.01—07.21</td><td>7 月结账</td><td><span class="status danger">已逾期</span></td><td><button class="text-button" data-audit-action>审核</button></td></tr><tr data-audit-type="单边" data-company="B公司"><td><span class="task-level warning"></span></td><td><strong>B公司 ↔ D公司</strong><small>金额与日期存在 2 个候选</small></td><td>单边匹配</td><td>07.18</td><td>312.00 万元</td><td><span class="status warning">待审核</span></td><td><button class="text-button" data-audit-action>匹配</button></td></tr><tr data-audit-type="科目" data-company="E公司"><td><span class="task-level warning"></span></td><td><strong>E公司 ↔ F公司</strong><small>规则无法区分应收与其他应收</small></td><td>科目待确认</td><td>07.09</td><td>88.00 万元</td><td><span class="status warning">待确认</span></td><td><button class="text-button" data-audit-action>确认</button></td></tr><tr data-audit-type="起算" data-company="F公司"><td><span class="task-level warning"></span></td><td><strong>F公司 · 农业银行 3650</strong><small>公司已提交无业务说明</small></td><td>起算校准</td><td>01.01—01.16</td><td>期初口径</td><td><span class="status neutral">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="账户" data-company="A公司" data-record-kind="account" data-evidence="公司提交资料、开户行、账号、账户类型与启用日期"><td><span class="task-level warning"></span></td><td><strong>A公司 · 郑州银行 3028</strong><small>一般户 · 申请启用日 2026.08.01</small></td><td>账户登记</td><td>2026.08.01</td><td>账户识别与流水上传</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="手工" data-company="A公司" data-record-kind="manual" data-evidence="公司手工记录、关联银行流水号、证明附件与提交说明"><td><span class="task-level warning"></span></td><td><strong>A公司 · MR-DEMO-001</strong><small>付款 80,000.00 元 · B公司 · 其他应收</small></td><td>手工记录</td><td>2026.07.29</td><td>待确认往来 8.00 万元</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr></tbody></table></div></section>
</section>
<section class="app-view" data-page="flows">
<header class="page-heading"><div><h1>流水管理</h1><p>查询全部公司的银行原始流水与归集状态</p></div><button class="button secondary" id="exportFlows"><svg><use href="icons.svg#download"/></svg>导出当前结果</button></header>
<section class="filter-grid flow-filters"><label class="field"><span>公司</span><select id="flowCompany"><option>全部公司</option><option>A公司</option><option>B公司</option><option>C公司</option></select></label><label class="field"><span>银行</span><select id="flowBank"><option>全部银行</option><option>中信银行</option><option>工商银行</option><option>建设银行</option></select></label><label class="field"><span>账户</span><select id="flowAccount"><option>全部账户</option><option>5316</option><option>9481</option><option>0845</option><option>6120</option></select></label><label class="field"><span>开始日期</span><input id="flowStart" type="date" value="2026-07-01" /></label><label class="field"><span>结束日期</span><input id="flowEnd" type="date" value="2026-07-31" /></label><label class="field grow"><span>摘要 / 对方户名 / 银行流水号</span><input id="flowKeyword" placeholder="输入关键词" /></label><button class="button primary" id="applyFlowFilters"><svg><use href="icons.svg#search"/></svg>查询</button></section>
<section class="panel"><div class="table-summary"><span>当前结果 <strong id="flowCount">4</strong></span><span>导出保留银行、账户、流水号、批次与源行定位</span></div><div class="table-scroll"><table class="data-table" id="flowTable"><thead><tr><th>日期</th><th>公司 / 银行账户</th><th>方向</th><th>对方户名</th><th>摘要</th><th>银行流水号</th><th>归集状态</th><th class="number">金额</th></tr></thead><tbody><tr data-company="A公司" data-bank="中信银行"><td>2026.07.18</td><td><strong>A公司</strong><small>中信银行 · 5316</small></td><td>转出</td><td>B公司</td><td>往来款</td><td>CIT260718018</td><td><span class="status success">双边匹配</span></td><td class="number">10,000,000.00</td></tr><tr data-company="B公司" data-bank="工商银行"><td>2026.07.18</td><td><strong>B公司</strong><small>工商银行 · 9481</small></td><td>转入</td><td>A公司</td><td>往来款</td><td>ICB260718109</td><td><span class="status success">双边匹配</span></td><td class="number">10,000,000.00</td></tr><tr data-company="A公司" data-bank="建设银行"><td>2026.07.12</td><td><strong>A公司</strong><small>建设银行 · 0845</small></td><td>转出</td><td>A公司</td><td>同名账户调拨</td><td>CCB260712031</td><td><span class="status neutral">同公司调拨</span></td><td class="number">2,000,000.00</td></tr><tr data-company="C公司" data-bank="建设银行"><td>2026.07.09</td><td><strong>C公司</strong><small>建设银行 · 6120</small></td><td>转出</td><td>E公司</td><td>资金调拨</td><td>CCB260709088</td><td><span class="status warning">单边待核</span></td><td class="number">880,000.00</td></tr></tbody></table></div></section>
</section>
<section class="app-view" data-page="companies">
<header class="page-heading"><div><h1>公司与账号</h1><p>维护公司主档、银行账户和一对一公司账号</p></div><button class="button primary" id="openCompanyDialog"><svg><use href="icons.svg#plus"/></svg>新增公司</button></header>
<section class="panel"><div class="table-scroll"><table class="data-table" id="companyTable"><thead><tr><th>公司全称</th><th>统一社会信用代码</th><th>银行账户</th><th>公司账号</th><th>出纳人员</th><th>状态</th><th>操作</th></tr></thead><tbody><tr><td><strong>A公司</strong><small>COMP-001</small></td><td>91410100MA******</td><td>4 个</td><td>a-cashier</td><td>牛女士</td><td><span class="status success">正常</span></td><td><button class="text-button" data-toast="已打开 A 公司主档">管理</button></td></tr><tr><td><strong>B公司</strong><small>COMP-002</small></td><td>91410100MB******</td><td>3 个</td><td>b-cashier</td><td>王女士</td><td><span class="status success">正常</span></td><td><button class="text-button" data-toast="已打开 B 公司主档">管理</button></td></tr><tr><td><strong>筹备公司</strong><small>COMP-006</small></td><td>注册中</td><td>0 个</td><td>未创建</td><td>未指定</td><td><span class="status neutral">筹备中</span></td><td><button class="text-button" data-toast="已打开筹备公司主档">管理</button></td></tr></tbody></table></div></section>
</section>
<section class="app-view" data-page="settings">
<header class="page-heading"><div><h1>结账与期初</h1><p>统一管理计算窗口、月结规则和公司期初余额</p></div></header>
<section class="settings-layout">
<form class="panel settings-form" id="systemSettings"><div class="panel-heading"><div><h2>系统计算口径</h2><p>修改后影响全部公司</p></div></div><div class="form-body"><label class="field"><span>全局起算日</span><input name="startDate" type="date" value="2026-01-01" /><small>早于此日的流水保留但不参与计算</small></label><label class="field"><span>每月结账日</span><span class="input-suffix"><input name="closingDay" type="number" min="1" max="28" value="5" /><b>次月日</b></span></label><label class="check-field"><input type="checkbox" checked />结账前 3 天自动提醒未完成公司</label></div><footer><button class="button primary" type="submit">保存系统口径</button></footer></form>
<section class="panel opening-panel"><div class="panel-heading"><div><h2>公司期初余额</h2><p>按公司对录入,修改保留审计记录</p></div><button class="button secondary small" id="openOpeningDialog"><svg><use href="icons.svg#plus"/></svg>新增</button></div><div class="table-scroll"><table class="data-table"><thead><tr><th>本方</th><th>对方</th><th>科目</th><th>方向</th><th class="number">金额(万元)</th><th>生效日</th><th>状态</th></tr></thead><tbody id="openingRows"><tr><td>A公司</td><td>B公司</td><td>应收</td><td>借方</td><td class="number">0.00</td><td>2026.01.01</td><td><span class="status success">已确认</span></td></tr><tr><td>C公司</td><td>E公司</td><td>其他应收</td><td>借方</td><td class="number">120.00</td><td>2026.01.01</td><td><span class="status warning">待复核</span></td></tr></tbody></table></div></section>
<section class="panel closing-panel" id="closingPanel"><div class="panel-heading"><div><h2>月度结账</h2><p id="closingDescription">2026 年 7 月 · 当前未达到结账条件</p></div><span class="status danger" id="closingStatus">已阻断</span></div><div class="closing-checks"><article><svg><use href="icons.svg#circle-check"/></svg><span><strong>公司流水提交</strong><small>5 / 6 家已完成</small></span></article><article class="is-blocked"><svg><use href="icons.svg#circle-alert"/></svg><span><strong>账户连续性</strong><small>2 个账户存在断档</small></span></article><article class="is-blocked"><svg><use href="icons.svg#circle-alert"/></svg><span><strong>审核事项</strong><small>7 项尚未处理</small></span></article><article><svg><use href="icons.svg#circle-check"/></svg><span><strong>期初余额</strong><small>已锁定 2026.01.01</small></span></article></div><footer class="closing-footer"><span id="closingHistory">最近结账:2026 年 6 月 · 系统管理员 · 2026.07.05 18:10</span><button class="button secondary" id="runClosingCheck">重新检查</button><button class="button primary" id="executeClosing" disabled title="请先处理全部阻断事项">执行 7 月结账</button></footer></section>
</section>
</section>
<section class="app-view" data-page="reminders">
<header class="page-heading"><div><h1>提醒管理</h1><p>查看自动提醒,并向公司出纳发送站内提醒</p></div></header>
<div class="reminder-layout">
<form class="panel reminder-form" id="reminderForm"><div class="panel-heading"><div><h2>发送手动提醒</h2><p>当前仅支持系统内通知</p></div></div><div class="form-body"><label class="field"><span>接收公司</span><select name="company" required><option value="">请选择公司</option><option>A公司</option><option>B公司</option><option>C公司</option></select></label><label class="field"><span>处理期限</span><input name="dueDate" type="date" value="2026-08-08" required /></label><label class="field"><span>提醒内容</span><textarea name="message" rows="4" placeholder="请填写需要处理的事项" required></textarea></label></div><footer><button class="button primary" type="submit"><svg><use href="icons.svg#send"/></svg>发送提醒</button></footer></form>
<section class="panel"><div class="panel-heading"><div><h2>提醒记录</h2><p>自动与手动提醒统一留痕</p></div></div><div class="notification-list" id="adminReminderList"><article><span class="notification-icon danger"><svg><use href="icons.svg#circle-alert"/></svg></span><span><strong>A公司 7 月流水缺失</strong><small>自动提醒 · 截止 08.05 · 已逾期</small></span><em class="status danger">未处理</em></article><article><span class="notification-icon warning"><svg><use href="icons.svg#bell"/></svg></span><span><strong>B公司单边流水待核验</strong><small>手动提醒 · 管理员 · 今天 09:30</small></span><em class="status warning">处理中</em></article><article><span class="notification-icon success"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>C公司 7 月流水已确认</strong><small>自动提醒 · 昨天 16:42</small></span><em class="status success">已完成</em></article></div></section>
</div>
</section>
</main>
</div>
</div>
<dialog id="companyDialog" class="dialog">
<form method="dialog" id="companyForm">
<header><div><h2>新增公司</h2><p>公司主档与公司账号可一次创建</p></div><button class="icon-button" value="cancel" aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>初始密码将在创建后显示一次,首次登录必须修改。</p></div>
<footer><button class="button secondary" value="cancel">取消</button><button class="button primary" value="default">创建公司与账号</button></footer>
</form>
</dialog>
<dialog id="openingDialog" class="dialog">
<form id="openingForm">
<header><div><h2>新增期初余额</h2><p>按本方视角记录公司间期初方向</p></div><button type="button" class="icon-button" data-close-opening aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><div class="form-grid"><label class="field"><span>本方公司</span><select name="from" required><option>A公司</option><option>B公司</option><option>C公司</option></select></label><label class="field"><span>对方公司</span><select name="to" required><option>B公司</option><option>C公司</option><option>D公司</option></select></label><label class="field"><span>科目</span><select name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></label><label class="field"><span>方向</span><select name="direction" required><option>借方</option><option>贷方</option></select></label><label class="field"><span>金额(万元)</span><input name="amount" type="number" min="0" step="0.01" required /></label><label class="field"><span>生效日</span><input name="effectiveDate" type="date" value="2026-01-01" required /></label></div><p class="form-callout"><svg><use href="icons.svg#circle-alert"/></svg>提交后进入复核,不能直接修改已确认期初。</p></div>
<footer><button type="button" class="button secondary" data-close-opening>取消</button><button class="button primary" type="submit">提交复核</button></footer>
</form>
</dialog>
<dialog id="auditDialog" class="dialog">
<form id="auditForm">
<header><div><h2 id="auditDialogTitle">审核事项</h2><p id="auditDialogMeta">原始证据与处理决定将一并留痕</p></div><button type="button" class="icon-button" data-close-audit aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><div class="evidence-block" id="auditEvidence"></div><label class="field"><span>处理决定</span><select name="decision" required><option value="">请选择</option><option>确认并纳入计算</option><option>退回公司补充材料</option><option>转为异常待后续处理</option></select></label><label class="field"><span>处理依据</span><textarea name="reason" rows="3" required placeholder="填写核验账号、摘要、回单或说明"></textarea></label></div>
<footer><button type="button" class="button secondary" data-close-audit>取消</button><button class="button primary" type="submit">提交审核结果</button></footer>
</form>
</dialog>
<dialog id="closingDialog" class="dialog">
<form id="closingForm">
<header><div><h2>执行 2026 年 7 月结账</h2><p>结账后本期结果将锁定,补录流水需走重开流程</p></div><button type="button" class="icon-button" data-close-closing aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><div class="evidence-block"><strong>结账检查已通过</strong><small>公司提交、账户连续性、审核事项和期初余额均符合当前口径。</small></div><label class="check-field"><input type="checkbox" name="confirm" required />我已复核 2026 年 7 月结账结果</label></div>
<footer><button type="button" class="button secondary" data-close-closing>取消</button><button class="button primary" type="submit">确认执行结账</button></footer>
</form>
</dialog>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>
+1014
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="往来台账公司业务端" />
<title>A公司业务端 · 往来台账</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body data-portal="company">
<!--
THESIS: 公司端是面向出纳的当月处理驾驶舱,拒绝复制总账端的管理视角。
OWN-WORLD: 与集团一致的深黑玻璃系统,以荧光绿表示可执行动作,以琥珀红区分断档和待确认风险。
STORY: 出纳从四个本公司指标进入上传、匹配和复核,补齐证据后完成当月确认。
FIRST VIEWPORT: 浮动公司侧栏与账期顶栏围合工作区;四张待办统计卡领衔,阻断结账的异常和任务队列位于其下。
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="公司业务导航">
<div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>往来台账</strong><small>公司业务端</small></span></div>
<div class="company-context"><span>A</span><div><strong>A公司</strong><small>当前登录公司</small></div></div>
<nav class="nav-list">
<button class="nav-item is-active" data-view="workspace"><svg><use href="icons.svg#layout-dashboard"/></svg><span>工作台</span><b>4</b></button>
<button class="nav-item" data-view="upload"><svg><use href="icons.svg#upload"/></svg><span>流水导入</span></button>
<button class="nav-item" data-view="manual"><svg><use href="icons.svg#plus"/></svg><span>手工记录</span></button>
<button class="nav-item" data-view="flows"><svg><use href="icons.svg#file-spreadsheet"/></svg><span>流水管理</span></button>
<button class="nav-item" data-view="reconcile"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来确认</span><b>2</b></button>
<button class="nav-item" data-view="accounts"><svg><use href="icons.svg#landmark"/></svg><span>银行账户</span></button>
<button class="nav-item" data-view="notifications"><svg><use href="icons.svg#bell"/></svg><span>通知</span><b>2</b></button>
</nav>
<div class="sidebar-footer">
<a class="nav-item" href="index.html"><svg><use href="icons.svg#log-out"/></svg><span>退出登录</span></a>
<div class="user-block"><span class="avatar"></span><span><strong>牛女士</strong><small>A公司出纳</small></span></div>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<button class="icon-button menu-button" id="menuButton" aria-label="打开导航" aria-expanded="false" aria-controls="sidebar" title="打开导航"><svg><use href="icons.svg#menu"/></svg></button>
<div class="workspace-name"><span>A公司</span><strong id="currentViewName">工作台</strong></div>
<span class="demo-badge">演示数据</span>
<div class="topbar-actions">
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">搜索本公司数据</span><input id="globalSearch" type="search" placeholder="搜索本公司流水或对方" /></label>
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>2026 年 7 月</span></button>
<button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button>
</div>
</header>
<main id="main-content">
<section class="app-view is-active" data-page="workspace">
<header class="page-heading"><div><h1>本月待办</h1><p>A公司 · 距离集团结账日还有 3 天</p></div><span class="status warning" id="workspacePendingStatus">4 项待处理</span></header>
<section class="metric-grid" aria-label="本公司关键指标">
<article class="metric-card danger" data-metric-action="upload" tabindex="0" role="button" aria-label="上传 1 个待补流水账户">
<div class="metric-copy"><span class="metric-label">待上传账户</span><strong class="metric-value">1<small>/ 4</small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#upload"/></svg></span>
<p class="metric-foot"><strong>工商银行 9481</strong> · 缺少 21 天</p>
</article>
<article class="metric-card warning" data-metric-link="reconcile" tabindex="0" role="button" aria-label="确认 1 笔待匹配流水">
<div class="metric-copy"><span class="metric-label">待确认流水</span><strong class="metric-value">1<small></small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#arrow-left-right"/></svg></span>
<p class="metric-foot"><strong>280.00 万元</strong> · 有 2 个候选</p>
</article>
<article class="metric-card" data-metric-link="manual" tabindex="0" role="button" aria-label="查看 1 笔待复核手工记录">
<div class="metric-copy"><span class="metric-label">待复核记录</span><strong class="metric-value">1<small></small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#list-checks"/></svg></span>
<p class="metric-foot"><strong>已提交总账</strong> · 等待管理员处理</p>
</article>
<article class="metric-card success">
<div class="metric-copy"><span class="metric-label">本月完成进度</span><strong class="metric-value">75<small>%</small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#circle-check"/></svg></span>
<p class="metric-foot"><strong>3 / 4 个账户</strong> · 距结账还有 3 天</p>
</article>
</section>
<section class="company-alert"><svg><use href="icons.svg#circle-alert"/></svg><div><strong>7 月数据尚不能结账</strong><p>工商银行账户缺少 07.01—07.21 流水,补齐后系统将重新计算匹配结果。</p></div><button class="button primary" data-open-upload>补传流水</button></section>
<div class="company-dashboard-grid">
<section class="panel task-panel">
<div class="panel-heading"><div><h2>需要处理</h2><p>按结账影响和期限排序</p></div></div>
<div class="cashier-tasks">
<article data-task-type="upload"><span class="task-level danger">1</span><div><h3>补传工商银行流水</h3><p>账户尾号 9481 · 缺少 07.01—07.21</p><small>影响:7 月账户覆盖与双边匹配</small></div><button class="button secondary small" data-open-upload>上传</button></article>
<article data-task-type="match"><span class="task-level warning">2</span><div><h3>确认 1 笔单边流水</h3><p>对方:B公司 · 07.18 · 280.00 万元</p><small>系统找到 2 个候选,请核对摘要与账号</small></div><button class="button secondary small" data-view-link="reconcile">去匹配</button></article>
<article data-task-type="subject"><span class="task-level warning">3</span><div><h3>确认往来科目</h3><p>对方:C公司 · 06.27 · 600.00 万元</p><small>待确认:应收或其他应收</small></div><button class="button secondary small" data-view-link="reconcile">去确认</button></article>
<article><span class="task-level neutral">4</span><div><h3>阅读总账提醒</h3><p>请于 08.08 前完成 7 月银行流水确认</p><small>管理员 · 今天 09:30</small></div><button class="button secondary small" data-view-link="notifications">查看</button></article>
</div>
</section>
<aside class="panel coverage-panel">
<div class="panel-heading"><div><h2>账户覆盖</h2><p>起算日 2026.01.01</p></div><span class="status warning">3 / 4 连续</span></div>
<div class="coverage-list"><article><span><strong>中信银行 · 5316</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>建设银行 · 0845</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>农业银行 · 3650</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>工商银行 · 9481</strong><small>缺少 07.01—07.21</small></span><em class="status danger">断档</em></article></div>
<button class="text-button full-text-button" data-view-link="accounts">查看全部账户</button>
</aside>
</div>
<section class="work-progress" aria-label="本月处理进度"><div><span>1</span><p><strong>流水上传</strong><small>3 / 4 个账户完成</small></p></div><i></i><div><span>2</span><p><strong>异常处理</strong><small>2 项待确认</small></p></div><i></i><div class="pending"><span>3</span><p><strong>公司确认</strong><small>尚未提交</small></p></div><i></i><div class="pending"><span>4</span><p><strong>集团结账</strong><small>次月 5 日</small></p></div></section>
</section>
<section class="app-view" data-page="upload">
<header class="page-heading"><div><h1>流水导入</h1><p>上传 A公司银行账户流水,系统按表头识别银行模板</p></div><button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button></header>
<section class="panel"><div class="table-summary"><span>最近导入批次</span><span>原始文件与解析结果将永久保留</span></div><div class="table-scroll"><table class="data-table"><thead><tr><th>批次</th><th>银行账户</th><th>流水期间</th><th>明细数</th><th>覆盖状态</th><th>解析状态</th><th>上传时间</th></tr></thead><tbody id="importRows"><tr><td><strong>IMP-260806-018</strong></td><td>中信银行 · 5316</td><td>07.01—07.31</td><td>128</td><td><span class="status success">连续</span></td><td><span class="status success">已确认</span></td><td>今天 09:42</td></tr><tr><td><strong>IMP-260731-012</strong></td><td>工商银行 · 9481</td><td>06.01—06.30</td><td>96</td><td><span class="status danger">后续断档</span></td><td><span class="status success">已确认</span></td><td>07.31 16:18</td></tr></tbody></table></div></section>
</section>
<section class="app-view" data-page="manual">
<header class="page-heading"><div><h1>手工记录</h1><p>登记个人过账或银行流水之外的往来事实</p></div><span class="status warning" id="manualPendingStatus">1 笔待总账复核</span></header>
<div class="manual-layout">
<form class="panel manual-form" id="manualEntryForm">
<div class="panel-heading"><div><h2>新增记录</h2><p>A公司 · 提交人:牛女士</p></div></div>
<div class="form-body">
<div class="form-grid"><label class="field"><span>交易日期</span><input name="transactionDate" type="date" value="2026-08-06" required /></label><label class="field"><span>收付方向</span><select name="direction" required><option>付款</option><option>收款</option></select></label></div>
<div class="form-grid"><label class="field"><span>金额(元)</span><input name="amount" type="number" min="0.01" step="0.01" required /></label><label class="field"><span>资金来源</span><select name="sourceAccount" required><option value="">请选择</option><option>中信银行 · 5316</option><option>工商银行 · 9481</option><option>建设银行 · 0845</option><option>农业银行 · 3650</option><option>个人过账</option></select></label></div>
<div class="form-grid"><label class="field"><span>对方类型</span><select name="counterpartyType" required><option>集团内部公司</option><option>个人过账方</option><option>外部单位</option></select></label><label class="field"><span>对方名称</span><input name="counterparty" maxlength="100" placeholder="公司全称或个人姓名" required /></label></div>
<div class="form-grid"><label class="field"><span>对方账号</span><input name="counterpartyAccount" maxlength="64" placeholder="可选" /></label><label class="field"><span>往来科目</span><select name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></label></div>
<label class="field"><span>业务摘要</span><input name="summary" maxlength="120" placeholder="例如:个人代付后转回" required /></label>
<label class="field"><span>补充说明</span><textarea name="remark" rows="3" maxlength="500" placeholder="说明形成原因和核对依据" required></textarea></label>
<div class="form-grid"><label class="field"><span>关联银行流水号</span><input name="bankReference" maxlength="80" placeholder="可选" /></label><label class="field"><span>证明附件</span><input name="evidence" type="file" accept=".pdf,.png,.jpg,.jpeg,.xls,.xlsx" /></label></div>
<p class="form-callout"><svg><use href="icons.svg#circle-alert"/></svg>手工记录与银行原始流水分开留痕;总账复核通过后才纳入往来计算。</p>
</div>
<footer><button class="button primary" type="submit"><svg><use href="icons.svg#send"/></svg>提交总账复核</button></footer>
</form>
<section class="panel manual-records-panel">
<div class="panel-heading"><div><h2>本公司记录</h2><p>按提交时间倒序</p></div></div>
<div class="table-scroll"><table class="data-table manual-records-table"><thead><tr><th>编号 / 日期</th><th>方向 / 科目</th><th>资金来源</th><th>对方</th><th>摘要</th><th class="number">金额(元)</th><th>状态</th></tr></thead><tbody id="manualRecordRows"><tr data-demo-record><td><strong>MR-DEMO-001</strong><small>2026.07.29</small></td><td><strong>付款</strong><small>其他应收</small></td><td>个人过账</td><td><strong>B公司</strong><small>集团内部公司</small></td><td>个人代付后转回</td><td class="number">80,000.00</td><td><span class="status warning">待总账复核</span></td></tr></tbody></table></div>
</section>
</div>
</section>
<section class="app-view" data-page="flows">
<header class="page-heading"><div><h1>流水管理</h1><p>仅查询和导出 A公司的银行流水</p></div><button class="button secondary" id="exportFlows"><svg><use href="icons.svg#download"/></svg>导出当前结果</button></header>
<section class="filter-grid flow-filters"><label class="field"><span>银行</span><select id="flowBank"><option>全部银行</option><option>中信银行</option><option>工商银行</option><option>建设银行</option></select></label><label class="field"><span>账户</span><select id="flowAccount"><option>全部账户</option><option>5316</option><option>9481</option><option>0845</option></select></label><label class="field"><span>开始日期</span><input id="flowStart" type="date" value="2026-07-01" /></label><label class="field"><span>结束日期</span><input id="flowEnd" type="date" value="2026-07-31" /></label><label class="field grow"><span>摘要 / 对方户名 / 流水号</span><input id="flowKeyword" placeholder="输入关键词" /></label><button class="button primary" id="applyFlowFilters"><svg><use href="icons.svg#search"/></svg>查询</button></section>
<section class="panel"><div class="table-summary"><span>当前结果 <strong id="flowCount">3</strong></span><span>金额单位:元</span></div><div class="table-scroll"><table class="data-table" id="flowTable"><thead><tr><th>日期</th><th>银行账户</th><th>方向</th><th>对方户名 / 账号</th><th>摘要</th><th>银行流水号</th><th>归集状态</th><th class="number">金额</th></tr></thead><tbody><tr data-bank="中信银行"><td>2026.07.18</td><td>中信银行 · 5316</td><td>转出</td><td><strong>B公司</strong><small>尾号 9481</small></td><td>往来款</td><td>CIT260718018</td><td><span class="status success">双边匹配</span></td><td class="number">10,000,000.00</td></tr><tr data-bank="建设银行"><td>2026.07.12</td><td>建设银行 · 0845</td><td>转出</td><td><strong>A公司</strong><small>尾号 5316</small></td><td>同名账户调拨</td><td>CCB260712031</td><td><span class="status neutral">同公司调拨</span></td><td class="number">2,000,000.00</td></tr><tr data-bank="中信银行"><td>2026.07.06</td><td>中信银行 · 5316</td><td>转入</td><td><strong>B公司</strong><small>尾号 9481</small></td><td>归还往来款</td><td>CIT260706041</td><td><span class="status success">双边匹配</span></td><td class="number">3,200,000.00</td></tr></tbody></table></div></section>
</section>
<section class="app-view" data-page="reconcile">
<header class="page-heading"><div><h1>往来确认</h1><p>系统计算为主,只处理无法确定的匹配与科目</p></div></header>
<section class="reconcile-summary"><div><span>自动确认</span><strong>18 笔</strong><small>无需人工处理</small></div><div><span>单边待匹配</span><strong id="matchPendingCount">1 笔</strong><small>需选择对方证据</small></div><div><span>科目待确认</span><strong id="subjectPendingCount">1 笔</strong><small>需选择会计科目</small></div></section>
<section class="panel reconcile-panel"><div class="panel-heading"><div><h2>待确认事项</h2><p>每次确认都会记录操作人、时间和依据</p></div></div><div class="review-list" id="reviewList"><article data-review-type="match"><div class="review-main"><span class="task-level warning">匹配</span><div><h3>2026.07.18 · 转出 280.00 万元</h3><p>中信银行 5316 → B公司 · 摘要“往来款”</p></div></div><fieldset class="candidate-options"><legend>选择对方银行证据</legend><label><input type="radio" name="matchCandidate" value="B公司工商银行 9481" checked /><span><strong>B公司 · 工商银行 9481</strong><small>同金额 · 同日 · 对方账号一致 · 推荐</small></span></label><label><input type="radio" name="matchCandidate" value="B公司建设银行 2046" /><span><strong>B公司 · 建设银行 2046</strong><small>同金额 · 相差 1 日 · 摘要相近</small></span></label></fieldset><div class="review-actions"><button class="button secondary small" data-reject>无法匹配,转异常</button><button class="button primary small" data-resolve="match">确认所选匹配</button></div></article><article data-review-type="subject"><div class="review-main"><span class="task-level warning">科目</span><div><h3>2026.06.27 · 转出 600.00 万元</h3><p>建设银行 0845 → C公司 · 摘要“资金调拨”</p></div></div><div class="candidate"><label class="field compact"><span>确认科目</span><select><option>应收</option><option>其他应收</option></select></label><button class="button primary small" data-resolve="subject">确认科目</button></div></article></div><div class="review-history" id="reviewHistory" hidden><h3>本次处理记录</h3></div></section>
</section>
<section class="app-view" data-page="accounts">
<header class="page-heading"><div><h1>银行账户</h1><p>A公司登记账户与流水覆盖区间</p></div><button class="button secondary" id="openAccountDialog"><svg><use href="icons.svg#plus"/></svg>登记账户</button></header>
<section class="account-directory" id="accountDirectory"><article><header><span class="bank-mark"></span><div><strong>中信银行</strong><small>基本户 · 尾号 5316</small></div><em class="status success">连续</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>01.01—07.31</dd></div><div><dt>最近导入</dt><dd>今天 09:42</dd></div></dl></article><article><header><span class="bank-mark"></span><div><strong>工商银行</strong><small>一般户 · 尾号 9481</small></div><em class="status danger">断档</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>缺 07.01—07.21</dd></div><div><dt>最近导入</dt><dd>07.31 16:18</dd></div></dl></article><article><header><span class="bank-mark"></span><div><strong>建设银行</strong><small>一般户 · 尾号 0845</small></div><em class="status success">连续</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>01.01—07.31</dd></div><div><dt>最近导入</dt><dd>08.01 10:03</dd></div></dl></article></section>
</section>
<section class="app-view" data-page="notifications">
<header class="page-heading"><div><h1>通知</h1><p>系统提醒与总账管理员通知</p></div><button class="button secondary" id="markAllRead"><svg><use href="icons.svg#circle-check"/></svg>全部标为已读</button></header>
<section class="panel notification-page"><div class="notification-list" id="companyNotifications"><article class="is-unread"><span class="notification-icon danger"><svg><use href="icons.svg#circle-alert"/></svg></span><span><strong>7 月工商银行流水尚未上传</strong><small>系统自动提醒 · 今天 08:00 · 截止 08.05</small><p>账户尾号 9481 缺少 07.01—07.21 流水。</p></span><em class="status danger">未读</em></article><article class="is-unread"><span class="notification-icon warning"><svg><use href="icons.svg#bell"/></svg></span><span><strong>请完成 7 月银行流水确认</strong><small>系统管理员 · 今天 09:30 · 截止 08.08</small><p>请先处理断档和两项待确认往来,再提交公司确认。</p></span><em class="status warning">未读</em></article><article><span class="notification-icon success"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>6 月账期已完成集团结账</strong><small>系统通知 · 07.05 18:10</small></span><em class="status neutral">已读</em></article></div></section>
</section>
</main>
</div>
</div>
<dialog id="uploadDialog" class="dialog upload-dialog">
<form id="uploadForm">
<header><div><h2>上传银行流水</h2><p>A公司 · 系统将识别表头与银行模板</p></div><button type="button" class="icon-button" data-close-upload aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option><option>中信银行 · 5316</option><option>工商银行 · 9481</option><option>建设银行 · 0845</option><option>农业银行 · 3650</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>文件解析完成</strong><p id="parseSummary"></p></span></div></div>
<footer><button type="button" class="button secondary" data-close-upload>取消</button><button class="button primary" id="parseButton" type="submit" disabled><span>开始解析</span></button></footer>
</form>
</dialog>
<dialog id="accountDialog" class="dialog">
<form id="accountForm">
<header><div><h2>登记银行账户</h2><p>账户提交后由总账管理员复核</p></div><button type="button" class="icon-button" data-close-account aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><div class="form-grid"><label class="field"><span>开户银行</span><input name="bank" placeholder="例如:郑州银行" required /></label><label class="field"><span>账户类型</span><select name="type"><option>基本户</option><option>一般户</option><option>专用户</option></select></label></div><label class="field"><span>银行账号</span><input name="accountNumber" inputmode="numeric" required /></label><label class="field"><span>启用日期</span><input name="startDate" type="date" value="2026-08-06" required /></label></div>
<footer><button type="button" class="button secondary" data-close-account>取消</button><button class="button primary" type="submit">提交登记</button></footer>
</form>
</dialog>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="layout-dashboard" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></symbol>
<symbol id="search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
<symbol id="shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-8 9-4.5-1.5-8-4-8-9V5l8-3 8 3z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="landmark" viewBox="0 0 24 24"><path d="m3 10 9-7 9 7"/><path d="M5 10v9M9 10v9M15 10v9M19 10v9M3 21h18"/></symbol>
<symbol id="building" viewBox="0 0 24 24"><rect width="16" height="20" x="4" y="2" rx="2"/><path d="M9 22v-4h6v4M8 6h.01M12 6h.01M16 6h.01M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01"/></symbol>
<symbol id="settings" viewBox="0 0 24 24"><path d="M12 2v3M12 19v3M4.93 4.93l2.12 2.12M16.95 16.95l2.12 2.12M2 12h3M19 12h3M4.93 19.07l2.12-2.12M16.95 7.05l2.12-2.12"/><circle cx="12" cy="12" r="4"/></symbol>
<symbol id="bell" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/></symbol>
<symbol id="file-spreadsheet" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8M12 11v8"/></symbol>
<symbol id="upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></symbol>
<symbol id="download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></symbol>
<symbol id="users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
<symbol id="arrow-left-right" viewBox="0 0 24 24"><path d="M8 3 4 7l4 4M4 7h16M16 21l4-4-4-4M20 17H4"/></symbol>
<symbol id="list-checks" viewBox="0 0 24 24"><path d="m3 7 2 2 4-4M3 17l2 2 4-4M13 6h8M13 12h8M13 18h8"/></symbol>
<symbol id="calendar" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></symbol>
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></symbol>
<symbol id="circle-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></symbol>
<symbol id="circle-check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m8 12 3 3 5-6"/></symbol>
<symbol id="log-out" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
<symbol id="key-round" viewBox="0 0 24 24"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6M15 7l2 2M18 4l2 2"/></symbol>
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12"/><circle cx="12" cy="12" r="3"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="集团内部银行流水与往来账务管理" />
<title>登录 · 往来台账</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body class="entry-page">
<!--
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
STORY: 用户确认工作身份后进入拥有独立导航、功能侧重和数据边界的对应端口。
FIRST VIEWPORT: 左侧用大标题和三个系统事实建立产品身份,右侧玻璃表单首先选择角色再登录。
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<main class="entry-shell">
<section class="entry-context" aria-labelledby="product-name">
<div class="entry-brand"><span class="brand-mark"></span><span><strong id="product-name">往来台账</strong><small>集团资金往来管理系统</small></span></div>
<div class="entry-statement">
<h1>一笔往来,追溯到双方银行凭证。</h1>
<p>当前演示环境 · 数据均为模拟</p>
</div>
<dl class="entry-facts">
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
<div><dt>银行模板</dt><dd>已支持 6 家</dd></div>
</dl>
</section>
<section class="entry-form-wrap" aria-labelledby="login-title">
<form class="entry-form" id="loginForm">
<header><h2 id="login-title">登录往来台账</h2><p>请选择与账号一致的工作端口</p></header>
<div class="role-switch" role="radiogroup" aria-label="工作端口">
<label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small>集团管理员</small></span></label>
<label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small>公司出纳</small></span></label>
</div>
<label class="field"><span>账号</span><input name="username" autocomplete="username" value="group-admin" required /></label>
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" value="demo123456" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label>
<label class="check-field"><input type="checkbox" checked />记住本次登录</label>
<button class="button primary wide" type="submit"><span id="loginAction">进入总账管理端</span><svg><use href="icons.svg#chevron-right"/></svg></button>
<p class="entry-note">演示账号不会验证密码,正式系统将由服务端校验账号与公司绑定关系。</p>
</form>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+526
View File
@@ -0,0 +1,526 @@
:root {
color-scheme: dark;
--font-ui: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
--font-data: "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
--color-bg: #050706;
--color-bg-soft: #090d0b;
--color-nav: #090c0b;
--color-surface: rgba(20, 26, 23, 0.82);
--color-surface-solid: #131916;
--color-surface-raised: #18201c;
--color-surface-muted: rgba(255, 255, 255, 0.035);
--color-line: rgba(255, 255, 255, 0.09);
--color-line-strong: rgba(255, 255, 255, 0.16);
--color-ink: #f2f7f4;
--color-ink-soft: #a6b0aa;
--color-ink-muted: #89938d;
--color-primary: #37eb89;
--color-primary-strong: #70ffae;
--color-primary-dark: #08160e;
--color-primary-wash: rgba(55, 235, 137, 0.11);
--color-positive: #37eb89;
--color-positive-wash: rgba(55, 235, 137, 0.1);
--color-warning: #ffbc52;
--color-warning-wash: rgba(255, 188, 82, 0.11);
--color-danger: #ff626d;
--color-danger-wash: rgba(255, 98, 109, 0.11);
--color-info: #66a8ff;
--color-info-wash: rgba(102, 168, 255, 0.11);
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 22px;
--radius-xl: 28px;
--shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px rgba(255, 255, 255, 0.035);
--shadow-low: 0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.025);
--shadow-glow: 0 0 28px rgba(55, 235, 137, 0.12);
--duration-fast: 150ms;
--duration-standard: 260ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--color-bg); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--color-bg); color: var(--color-ink); font: 13px/1.55 var(--font-ui); -webkit-font-smoothing: antialiased; }
button, input, select, textarea { font: inherit; }
button, a, summary { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, p, dl, dd { margin: 0; }
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
::selection { background: rgba(55, 235, 137, 0.26); color: #fff; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 10px; background: rgba(255, 255, 255, 0.15); background-clip: padding-box; }
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.skip-link { position: fixed; top: 8px; left: 50%; z-index: 500; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--color-primary); color: var(--color-primary-dark); transform: translate(-50%, -150%); }
.skip-link:focus { transform: translate(-50%, 0); }
:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 3px; }
[hidden] { display: none !important; }
/* Application frame */
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 252px minmax(0, 1fr); }
.sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 234px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 16, 14, 0.9); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); }
.brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 15px; border-bottom: 1px solid var(--color-line); }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(112, 255, 174, 0.32); border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary-strong); box-shadow: var(--shadow-glow); font-size: 18px; font-weight: 800; }
.brand-copy { display: flex; flex-direction: column; min-width: 0; }
.brand-copy strong { font-size: 15px; }
.brand-copy small, .user-block small, .company-context small { color: var(--color-ink-muted); font-size: 10px; }
.company-context { min-height: 64px; display: flex; align-items: center; gap: 10px; margin: 12px 4px 2px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.company-context > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary); color: var(--color-primary-dark); font-weight: 800; }
.company-context div { display: flex; flex-direction: column; }
.nav-list { display: grid; gap: 5px; padding: 14px 2px; overflow-y: auto; }
.nav-item { position: relative; width: 100%; min-height: 43px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 14px; background: transparent; color: var(--color-ink-soft); text-align: left; cursor: pointer; transition: color var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-fast); }
.nav-item:hover { color: var(--color-ink); background: rgba(255, 255, 255, 0.045); transform: translateX(2px); }
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.075); color: var(--color-ink); box-shadow: inset 0 1px rgba(255, 255, 255, 0.045), 0 8px 22px rgba(0, 0, 0, 0.2); }
.nav-item.is-active svg { color: var(--color-primary); filter: drop-shadow(0 0 8px rgba(55, 235, 137, 0.42)); }
.nav-item svg { width: 17px; flex: 0 0 auto; }
.nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-item b { min-width: 19px; height: 19px; display: grid; place-items: center; margin-left: auto; border-radius: 7px; background: var(--color-primary-wash); color: var(--color-primary); font-size: 10px; }
.sidebar-footer { display: grid; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--color-line); }
.user-block { min-height: 51px; display: flex; align-items: center; gap: 10px; padding: 7px 9px; }
.user-block > span:last-child { display: flex; flex-direction: column; }
.avatar { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #242c28; color: var(--color-primary-strong); font-weight: 700; }
.workspace { min-width: 0; grid-column: 2; }
.topbar { position: sticky; top: 0; z-index: 90; height: 78px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 7, 6, 0.82); backdrop-filter: blur(22px); }
.workspace-name { display: flex; flex-direction: column; min-width: 150px; }
.workspace-name span { color: var(--color-ink-muted); font-size: 10px; }
.workspace-name strong { font-size: 14px; }
.demo-badge { padding: 3px 8px; border: 1px solid rgba(255, 188, 82, 0.22); border-radius: 8px; background: var(--color-warning-wash); color: var(--color-warning); font-size: 10px; }
.topbar-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.search-box { width: clamp(230px, 26vw, 390px); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 13px; border: 1px solid var(--color-line); border-radius: 14px; background: rgba(255, 255, 255, 0.035); color: var(--color-ink-muted); transition: border-color var(--duration-fast), background var(--duration-fast), box-shadow var(--duration-fast); }
.search-box:focus-within { border-color: rgba(55, 235, 137, 0.4); background: rgba(255, 255, 255, 0.055); box-shadow: var(--shadow-glow); }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
.search-box input::placeholder, input::placeholder, textarea::placeholder { color: #626b66; }
.period-button, .icon-button, .inside-icon, .swap-button { display: inline-grid; place-items: center; border: 1px solid var(--color-line); background: var(--color-surface-muted); color: var(--color-ink-soft); cursor: pointer; transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
.period-button { min-height: 40px; grid-auto-flow: column; gap: 8px; padding: 0 13px; border-radius: 14px; }
.icon-button { position: relative; width: 40px; height: 40px; border-radius: 13px; }
.inside-icon { width: 36px; height: 36px; border: 0; background: transparent; }
.period-button:hover, .icon-button:hover, .inside-icon:hover, .swap-button:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.07); color: var(--color-ink); transform: translateY(-1px); }
.notification-dot { position: absolute; top: -5px; right: -5px; min-width: 17px; height: 17px; display: grid; place-items: center; border: 2px solid var(--color-bg); border-radius: 9px; background: var(--color-danger); color: #fff; font: 9px var(--font-data); }
.menu-button { display: none; }
main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.app-view { display: none; }
.app-view.is-active { display: block; }
.page-heading { min-height: 70px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
.page-heading h1 { font-size: clamp(26px, 3vw, 35px); line-height: 1.15; letter-spacing: 0; }
.page-heading p { margin-top: 7px; color: var(--color-ink-muted); }
/* Commands and fields */
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border: 1px solid transparent; border-radius: 13px; font-weight: 700; cursor: pointer; transition: transform var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast); }
.button:hover { transform: translateY(-1px); }
.button:active { transform: translateY(0) scale(0.985); }
.button.primary { background: var(--color-primary); color: var(--color-primary-dark); box-shadow: 0 8px 24px rgba(55, 235, 137, 0.18); }
.button.primary:hover { background: var(--color-primary-strong); box-shadow: 0 10px 30px rgba(55, 235, 137, 0.25); }
.button.secondary { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink); }
.button.secondary:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.08); }
.button.small { min-height: 34px; padding-inline: 11px; border-radius: 11px; font-size: 11px; }
.button.wide { width: 100%; }
.button:disabled { opacity: 0.42; cursor: not-allowed; transform: none; }
.text-button { padding: 3px 0; border: 0; background: none; color: var(--color-primary); cursor: pointer; }
.text-button:hover { color: var(--color-primary-strong); }
.full-text-button { width: 100%; padding: 13px; border-top: 1px solid var(--color-line); }
.field { min-width: 0; display: flex; flex-direction: column; gap: 6px; color: var(--color-ink-soft); }
.field > span { color: var(--color-ink-muted); font-size: 11px; }
.field input, .field select, .field textarea { width: 100%; min-height: 40px; padding: 9px 11px; border: 1px solid var(--color-line); border-radius: 11px; outline: 0; background: rgba(2, 5, 3, 0.48); color: var(--color-ink); transition: border-color var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast); }
.field textarea { resize: vertical; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: rgba(55, 235, 137, 0.5); background: rgba(2, 5, 3, 0.7); box-shadow: 0 0 0 3px rgba(55, 235, 137, 0.08); }
.field select { color-scheme: dark; }
.field.compact { min-width: 150px; }
.password-field { display: grid; grid-template-columns: 1fr 38px; border: 1px solid var(--color-line); border-radius: 12px; background: rgba(2, 5, 3, 0.48); }
.password-field input { border: 0; background: transparent; }
.check-field { display: flex; align-items: center; gap: 8px; color: var(--color-ink-soft); }
.check-field input { accent-color: var(--color-primary); }
.swap-button { width: 38px; height: 38px; border-radius: 12px; align-self: end; }
/* Glass surfaces */
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); }
.panel { min-width: 0; overflow: hidden; }
.panel-heading { min-height: 67px; display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 14px 17px; border-bottom: 1px solid var(--color-line); }
.panel-heading h2 { font-size: 15px; }
.panel-heading p { margin-top: 3px; color: var(--color-ink-muted); font-size: 11px; }
.status { display: inline-flex; align-items: center; justify-content: center; gap: 5px; width: max-content; min-height: 23px; padding: 2px 8px; border: 1px solid transparent; border-radius: 8px; font-size: 10px; font-style: normal; white-space: nowrap; }
.status.success { border-color: rgba(55, 235, 137, 0.18); background: var(--color-positive-wash); color: var(--color-positive); }
.status.warning { border-color: rgba(255, 188, 82, 0.18); background: var(--color-warning-wash); color: var(--color-warning); }
.status.danger { border-color: rgba(255, 98, 109, 0.18); background: var(--color-danger-wash); color: var(--color-danger); }
.status.neutral { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); }
.task-level { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 10px; font-size: 10px; font-style: normal; font-weight: 800; }
.task-level.danger { background: var(--color-danger-wash); color: var(--color-danger); }
.task-level.warning { background: var(--color-warning-wash); color: var(--color-warning); }
.task-level.neutral { background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
/* Dashboard */
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin: 0 0 18px; }
.metric-card { position: relative; min-height: 142px; display: grid; grid-template-columns: minmax(0, 1fr) 46px; align-content: space-between; gap: 14px; padding: 20px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); transition: transform var(--duration-standard) var(--ease-out), border-color var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; }
.metric-card:hover { transform: translateY(-4px); border-color: rgba(255, 255, 255, 0.17); box-shadow: 0 24px 54px rgba(0, 0, 0, 0.3); }
.metric-copy { align-self: start; }
.metric-label { display: block; color: var(--color-ink-soft); font-size: 12px; }
.metric-value { display: block; margin-top: 7px; font: 700 31px/1 var(--font-data); letter-spacing: 0; }
.metric-value small { margin-left: 5px; color: var(--color-ink-muted); font: 11px var(--font-ui); }
.metric-icon { width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid var(--color-line); border-radius: 15px; background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
.metric-foot { grid-column: 1 / -1; color: var(--color-ink-muted); font-size: 11px; }
.metric-foot strong { color: var(--color-positive); }
.metric-card.warning { color: var(--color-warning); }
.metric-card.danger { color: var(--color-danger); }
.metric-card.success { color: var(--color-positive); }
.metric-card .metric-copy { color: var(--color-ink); }
.metric-card .metric-icon { color: var(--color-ink-soft); }
.metric-card .metric-foot { color: var(--color-ink-muted); }
.metric-card[data-metric-link], .metric-card[data-metric-action] { cursor: pointer; }
.metric-card.warning .metric-icon, .metric-card.warning .metric-foot strong { color: var(--color-warning); }
.metric-card.danger .metric-icon, .metric-card.danger .metric-foot strong { color: var(--color-danger); }
.metric-card.success .metric-icon { color: var(--color-positive); }
.period-ribbon { display: grid; grid-template-columns: 1fr 1fr 1fr minmax(310px, 1.4fr); margin-bottom: 18px; overflow: hidden; }
.period-ribbon > div { min-height: 72px; display: flex; flex-direction: column; justify-content: center; padding: 13px 17px; border-left: 1px solid var(--color-line); }
.period-ribbon > div:first-child { border-left: 0; }
.period-ribbon span, .period-ribbon small { color: var(--color-ink-muted); }
.period-ribbon strong { margin-top: 3px; }
.period-ribbon > .period-warning { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.period-warning > svg { color: var(--color-warning); }
.period-warning > span { display: flex; flex-direction: column; }
.period-warning > span strong { color: var(--color-ink); }
.admin-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.42fr) minmax(310px, 0.58fr); gap: 18px; margin-bottom: 18px; }
.task-list button { width: 100%; min-height: 73px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto 18px; align-items: center; gap: 11px; padding: 12px 16px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; transition: background var(--duration-fast); }
.task-list button:first-child { border-top: 0; }
.task-list button:hover { background: rgba(255, 255, 255, 0.035); }
.task-list button > span:nth-child(2) { display: flex; flex-direction: column; }
.task-list small, .task-list button > b { color: var(--color-ink-muted); font-size: 11px; }
.task-list button > svg { color: var(--color-ink-muted); }
.quick-pair-form { display: grid; grid-template-columns: 1fr 38px 1fr; gap: 10px; padding: 16px; }
.quick-pair-form .button { grid-column: 1 / -1; }
.quick-result { margin: 0 16px 16px; padding: 14px; border: 1px solid rgba(55, 235, 137, 0.16); border-radius: var(--radius-md); background: var(--color-primary-wash); }
.quick-result span, .quick-result p { color: var(--color-ink-muted); }
.quick-result strong { display: block; margin: 4px 0; color: var(--color-primary-strong); font: 700 22px var(--font-data); }
.quick-result small { margin-left: 4px; font: 10px var(--font-ui); }
/* Ledger and queries */
.inline-search { width: min(240px, 38vw); height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 11px; border: 1px solid var(--color-line); border-radius: 11px; background: rgba(0, 0, 0, 0.18); color: var(--color-ink-muted); }
.inline-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
.ledger-head, .company-ledger summary { display: grid; grid-template-columns: minmax(210px, 1.4fr) repeat(2, minmax(100px, 0.75fr)) minmax(150px, 0.9fr) 86px 24px; align-items: center; gap: 10px; }
.ledger-head { min-height: 42px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 10px; }
.company-ledger { border-top: 1px solid var(--color-line); }
.company-ledger:first-child { border-top: 0; }
.company-ledger summary { min-height: 72px; padding: 10px 16px; list-style: none; cursor: pointer; transition: background var(--duration-fast); }
.company-ledger summary::-webkit-details-marker { display: none; }
.company-ledger summary:hover { background: rgba(255, 255, 255, 0.035); }
.company-ledger summary > svg { transition: transform var(--duration-standard) var(--ease-out); }
.company-ledger[open] summary > svg { transform: rotate(180deg); }
.company-name { display: grid; grid-template-columns: 38px minmax(0, 1fr); align-items: center; column-gap: 10px; }
.company-name i { width: 36px; height: 36px; grid-row: 1 / 3; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-style: normal; font-weight: 800; }
.company-name small { color: var(--color-ink-muted); }
.amount { font: 600 13px var(--font-data); }
.amount.debit { color: var(--color-positive); }
.amount.credit { color: var(--color-warning); }
.ledger-breakdown { display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--color-line); background: rgba(0, 0, 0, 0.13); }
.ledger-breakdown section { padding: 14px 16px; }
.ledger-breakdown section + section { border-left: 1px solid var(--color-line); }
.ledger-breakdown header { display: flex; justify-content: space-between; margin-bottom: 8px; color: var(--color-ink-soft); }
.subject-row { width: 100%; min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 10px; padding: 8px 10px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; }
.subject-row:hover { background: rgba(255, 255, 255, 0.035); }
.subject-row span { display: flex; flex-direction: column; }
.subject-row small { color: var(--color-ink-muted); }
.query-band, .filter-grid { padding: 17px; margin-bottom: 18px; }
.pair-query-form { display: grid; grid-template-columns: minmax(150px, 1fr) 38px minmax(150px, 1fr) minmax(150px, 0.8fr) auto; align-items: end; gap: 11px; }
.pair-report { overflow: hidden; }
.pair-report-heading { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
.pair-report-heading p { color: var(--color-ink-muted); }
.pair-result { text-align: right; }
.pair-result strong { display: block; color: var(--color-primary); font: 700 22px var(--font-data); }
.pair-balance-line { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
.pair-balance-line div { min-height: 76px; display: flex; flex-direction: column; justify-content: center; padding: 12px 16px; border-left: 1px solid var(--color-line); }
.pair-balance-line div:first-child { border-left: 0; }
.pair-balance-line span, .pair-balance-line small { color: var(--color-ink-muted); }
.pair-balance-line strong { margin-top: 3px; font: 600 15px var(--font-data); }
.pair-final strong { color: var(--color-primary); font-size: 19px; }
.subject-strip { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
.subject-strip button { min-height: 62px; display: flex; flex-direction: column; justify-content: center; padding: 9px 14px; border: 0; border-left: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.018); text-align: left; cursor: pointer; }
.subject-strip button:first-child { border-left: 0; }
.subject-strip button.is-active { background: var(--color-primary-wash); color: var(--color-primary); box-shadow: inset 0 -2px var(--color-primary); }
.subject-strip span { color: var(--color-ink-muted); font-size: 10px; }
/* Filters and tables */
.filter-bar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px; margin-bottom: 18px; }
.segmented { display: flex; gap: 4px; padding: 4px; overflow-x: auto; border: 1px solid var(--color-line); border-radius: 13px; background: rgba(0, 0, 0, 0.18); }
.segmented button { min-height: 32px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--color-ink-muted); white-space: nowrap; cursor: pointer; }
.segmented button.is-active { background: rgba(255, 255, 255, 0.09); color: var(--color-ink); }
.flow-filters { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)) minmax(200px, 1.4fr) auto; align-items: end; gap: 11px; }
.flow-filters .grow { min-width: 0; }
.table-summary { min-height: 51px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 11px; }
.table-scroll { max-width: 100%; overflow: auto; }
.data-table { width: 100%; min-width: 860px; border-collapse: collapse; }
.data-table th { height: 42px; padding: 8px 13px; border-bottom: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.025); color: var(--color-ink-muted); font-size: 10px; font-weight: 500; text-align: left; white-space: nowrap; }
.data-table td { height: 55px; padding: 9px 13px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); vertical-align: middle; }
.data-table tbody tr { transition: background var(--duration-fast); }
.data-table tbody tr:hover { background: rgba(55, 235, 137, 0.035); }
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table td strong, .data-table td small { display: block; }
.data-table td strong { color: var(--color-ink); }
.data-table td small { color: var(--color-ink-muted); }
.data-table .number { color: var(--color-ink); text-align: right; font-family: var(--font-data); font-variant-numeric: tabular-nums; }
/* Settings, reminders and company work */
.settings-layout { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 18px; }
.form-body { display: grid; gap: 15px; padding: 17px; }
.panel form footer, form.panel footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 17px; border-top: 1px solid var(--color-line); }
.closing-panel { grid-column: 1 / -1; }
.closing-checks { display: grid; grid-template-columns: repeat(4, 1fr); }
.closing-checks article { min-height: 75px; display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-left: 1px solid var(--color-line); }
.closing-checks article:first-child { border-left: 0; }
.closing-checks svg { color: var(--color-positive); }
.closing-checks .is-blocked svg { color: var(--color-danger); }
.closing-checks span { display: flex; flex-direction: column; }
.closing-checks small { color: var(--color-ink-muted); }
.closing-footer { min-height: 62px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
.closing-footer > span { margin-right: auto; color: var(--color-ink-muted); }
.reminder-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 18px; }
.notification-list article { min-height: 76px; display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--color-line); }
.notification-list article:first-child { border-top: 0; }
.notification-list article.is-unread { background: rgba(102, 168, 255, 0.045); }
.notification-list article > span:nth-child(2) { display: flex; flex-direction: column; }
.notification-list small, .notification-list p { color: var(--color-ink-muted); }
.notification-list p { margin-top: 4px; }
.notification-icon { width: 35px; height: 35px; display: grid; place-items: center; border-radius: 12px; background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
.notification-icon.danger { background: var(--color-danger-wash); color: var(--color-danger); }
.notification-icon.warning { background: var(--color-warning-wash); color: var(--color-warning); }
.notification-icon.success { background: var(--color-positive-wash); color: var(--color-positive); }
.company-alert { min-height: 78px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 18px; padding: 14px 16px; border: 1px solid rgba(255, 98, 109, 0.22); border-radius: var(--radius-lg); background: var(--color-danger-wash); box-shadow: var(--shadow-low); }
.company-alert > svg { color: var(--color-danger); }
.company-alert p { color: #c18e92; }
.company-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr); gap: 18px; }
.cashier-tasks article { min-height: 92px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px 16px; border-top: 1px solid var(--color-line); }
.cashier-tasks article:first-child { border-top: 0; }
.cashier-tasks p, .cashier-tasks small { color: var(--color-ink-muted); }
.coverage-list article { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
.coverage-list article:first-child { border-top: 0; }
.coverage-list article > span { display: flex; flex-direction: column; }
.coverage-list small { color: var(--color-ink-muted); }
.work-progress { display: grid; grid-template-columns: minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr); align-items: center; margin-top: 18px; padding: 18px; }
.work-progress div { display: flex; align-items: center; gap: 10px; }
.work-progress div > span { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 11px; background: var(--color-positive); color: var(--color-primary-dark); font-weight: 800; }
.work-progress p { display: flex; flex-direction: column; }
.work-progress small { color: var(--color-ink-muted); }
.work-progress i { height: 1px; background: var(--color-line-strong); }
.work-progress .pending > span { background: rgba(255, 255, 255, 0.065); color: var(--color-ink-muted); }
.reconcile-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 18px; overflow: hidden; }
.reconcile-summary div { min-height: 86px; display: flex; flex-direction: column; justify-content: center; padding: 14px 18px; border-left: 1px solid var(--color-line); }
.reconcile-summary div:first-child { border-left: 0; }
.reconcile-summary span, .reconcile-summary small { color: var(--color-ink-muted); }
.reconcile-summary strong { font: 600 19px var(--font-data); }
.review-list > article { padding: 16px; border-top: 1px solid var(--color-line); }
.review-list > article:first-child { border-top: 0; }
.review-main { display: grid; grid-template-columns: 38px 1fr; gap: 10px; }
.review-main p { color: var(--color-ink-muted); }
.candidate { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); }
.candidate-options { display: grid; gap: 8px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
.candidate-options legend { padding: 0 5px; color: var(--color-ink-muted); }
.candidate-options label { display: flex; align-items: center; gap: 9px; padding: 9px; border-radius: 11px; background: rgba(255, 255, 255, 0.035); }
.candidate-options input { accent-color: var(--color-primary); }
.candidate-options label span { display: flex; flex-direction: column; }
.candidate-options small { color: var(--color-ink-muted); }
.review-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 10px; }
.review-history { padding: 14px 16px; border-top: 1px solid var(--color-line); background: var(--color-positive-wash); }
.evidence-block { padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.16); }
.evidence-block strong, .evidence-block small { display: block; }
.evidence-block small { color: var(--color-ink-muted); }
.account-directory { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 16px; }
.account-directory article { overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); }
.account-directory header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 15px; border-bottom: 1px solid var(--color-line); }
.account-directory header div { display: flex; flex-direction: column; }
.account-directory header small { color: var(--color-ink-muted); }
.bank-mark { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; }
.account-directory dl { display: grid; grid-template-columns: repeat(3, 1fr); }
.account-directory dl div { padding: 12px; border-left: 1px solid var(--color-line); }
.account-directory dl div:first-child { border-left: 0; }
.account-directory dt { color: var(--color-ink-muted); font-size: 10px; }
.account-directory dd { margin-top: 3px; font-size: 11px; }
.manual-layout { display: grid; grid-template-columns: minmax(340px, 390px) minmax(0, 1fr); align-items: start; gap: 18px; }
.manual-records-table { min-width: 900px; }
/* Dialogs and feedback */
.dialog { width: min(580px, calc(100% - 28px)); padding: 0; overflow: hidden; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: rgba(17, 23, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); }
.dialog::backdrop { background: rgba(0, 0, 0, 0.74); backdrop-filter: blur(6px); }
.dialog > form > header { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--color-line); }
.dialog header p { color: var(--color-ink-muted); }
.dialog-body { display: grid; gap: 15px; padding: 18px; }
.dialog > form > footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 18px; border-top: 1px solid var(--color-line); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-callout { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid rgba(102, 168, 255, 0.22); border-radius: 12px; background: var(--color-info-wash); color: #9ac6ff; }
.dropzone { min-height: 168px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; border: 1px dashed rgba(255, 255, 255, 0.24); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); color: var(--color-ink-soft); text-align: center; transition: border-color var(--duration-fast), background var(--duration-fast); }
.dropzone:hover, .dropzone.is-dragging { border-color: var(--color-primary); background: var(--color-primary-wash); }
.dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.dropzone > svg { width: 30px; height: 30px; color: var(--color-primary); }
.file-preview { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
.file-preview > span:nth-child(2) { display: flex; flex-direction: column; min-width: 0; }
.file-preview strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-preview small { color: var(--color-ink-muted); }
.file-type { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); }
.parse-result { display: grid; grid-template-columns: 38px 1fr; align-items: center; gap: 10px; padding: 11px; border: 1px solid rgba(55, 235, 137, 0.22); border-radius: var(--radius-md); background: var(--color-positive-wash); }
.parse-result p { color: #9bd7b6; }
.parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); }
.parse-result.is-exception p { color: #e7bd78; }
.toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 200; display: grid; gap: 8px; }
.toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 26, 23, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
.toast strong, .toast small { display: block; }
.toast small { color: var(--color-ink-muted); }
/* Login */
.entry-page { overflow-x: hidden; }
.entry-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); }
.entry-context { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--color-line); background: #070a08; }
.entry-context::after { content: ""; position: absolute; inset: 12% 8% 12% auto; width: 1px; background: rgba(55, 235, 137, 0.32); box-shadow: 0 0 38px rgba(55, 235, 137, 0.45); }
.entry-brand { display: flex; align-items: center; gap: 12px; }
.entry-brand > span:last-child { display: flex; flex-direction: column; }
.entry-brand small { color: var(--color-ink-muted); }
.entry-statement { max-width: 680px; margin: 80px 0; }
.entry-statement h1 { max-width: 640px; font-size: clamp(38px, 5vw, 70px); line-height: 1.12; letter-spacing: 0; }
.entry-statement p { margin-top: 20px; color: var(--color-ink-muted); }
.entry-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 670px; }
.entry-facts div { padding: 15px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.entry-facts dt { color: var(--color-ink-muted); }
.entry-facts dd { margin-top: 4px; color: var(--color-primary); }
.entry-form-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px; background: var(--color-bg); }
.entry-form { width: min(420px, 100%); display: grid; gap: 18px; padding: 28px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: var(--color-surface); box-shadow: var(--shadow-panel); backdrop-filter: blur(24px); }
.entry-form header h2 { font-size: 25px; }
.entry-form header p, .entry-note { color: var(--color-ink-muted); }
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.role-switch label { cursor: pointer; }
.role-switch input { position: absolute; opacity: 0; }
.role-switch span { min-height: 83px; display: grid; grid-template-columns: 30px 1fr; align-content: center; gap: 1px 9px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.14); transition: border-color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
.role-switch svg { grid-row: 1 / 3; align-self: center; color: var(--color-ink-muted); }
.role-switch small { color: var(--color-ink-muted); }
.role-switch input:checked + span { border-color: rgba(55, 235, 137, 0.4); background: var(--color-primary-wash); transform: translateY(-1px); }
.role-switch input:checked + span svg { color: var(--color-primary); }
.entry-note { font-size: 10px; }
@media (max-width: 1180px) {
.app-shell { grid-template-columns: 88px minmax(0, 1fr); }
.sidebar { width: 70px; padding-inline: 9px; }
.workspace { grid-column: 2; }
.brand { justify-content: center; padding-inline: 0; }
.brand-copy, .nav-item span, .nav-item b, .user-block > span:last-child, .company-context div { display: none; }
.nav-item { justify-content: center; padding: 0; }
.user-block, .company-context { justify-content: center; padding-inline: 0; }
.metric-grid { grid-template-columns: repeat(2, 1fr); }
.period-ribbon { grid-template-columns: repeat(3, 1fr); }
.period-ribbon > .period-warning { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
.admin-dashboard-grid, .company-dashboard-grid { grid-template-columns: 1fr; }
.flow-filters { grid-template-columns: repeat(3, 1fr); }
.flow-filters .button { min-height: 40px; }
.manual-layout { grid-template-columns: 1fr; }
.account-directory { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 900px) {
main { padding-inline: 20px; }
.search-box { display: none; }
.settings-layout, .reminder-layout { grid-template-columns: 1fr; }
.pair-query-form { grid-template-columns: 1fr 40px 1fr; }
.pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; }
.pair-query-form .button { grid-column: 3; }
.ledger-head { display: none; }
.company-ledger summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
.company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; }
.ledger-breakdown { grid-template-columns: 1fr; }
.ledger-breakdown section + section { border-top: 1px solid var(--color-line); border-left: 0; }
.pair-balance-line { grid-template-columns: repeat(3, 1fr); }
.pair-balance-line .pair-final { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
.subject-strip { grid-template-columns: repeat(3, 1fr); }
.work-progress { grid-template-columns: 1fr 24px 1fr; row-gap: 16px; }
.work-progress i:nth-of-type(2) { display: none; }
.entry-shell { grid-template-columns: 1fr; }
.entry-context { min-height: 58vh; border-right: 0; border-bottom: 1px solid var(--color-line); }
.entry-form-wrap { min-height: auto; }
}
@media (max-width: 720px) {
.app-shell { display: block; }
.sidebar { inset: 10px auto 10px 10px; width: 242px; transform: translateX(calc(-100% - 20px)); transition: transform var(--duration-standard) var(--ease-out); }
.sidebar.is-open { transform: none; }
.sidebar.is-open .brand-copy, .sidebar.is-open .nav-item span, .sidebar.is-open .nav-item b, .sidebar.is-open .user-block > span:last-child, .sidebar.is-open .company-context div { display: flex; }
.sidebar.is-open .nav-item { justify-content: flex-start; padding: 0 12px; }
.sidebar.is-open .company-context { justify-content: flex-start; padding-inline: 10px; }
.workspace { display: block; }
.topbar { height: 64px; padding: 0 13px; gap: 9px; }
.menu-button { display: inline-grid; }
.workspace-name { min-width: 0; }
.workspace-name span { display: none; }
.demo-badge { padding: 2px 6px; }
.period-button { display: none; }
.topbar-actions .button { width: 40px; padding: 0; font-size: 0; }
main { padding: 22px 13px 42px; }
.page-heading { min-height: auto; flex-direction: column; margin-bottom: 18px; }
.page-heading h1 { font-size: 27px; }
.page-heading > .button { align-self: stretch; }
.metric-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { min-height: 128px; grid-template-columns: minmax(0, 1fr) 38px; padding: 15px; }
.metric-icon { width: 38px; height: 38px; border-radius: 12px; }
.metric-value { font-size: 25px; }
.period-ribbon { grid-template-columns: 1fr 1fr; }
.period-ribbon > div { min-height: 64px; }
.period-ribbon > div:nth-child(3) { border-top: 1px solid var(--color-line); border-left: 0; }
.period-warning { grid-column: 1 / -1; }
.panel-heading { min-height: 60px; padding: 11px 13px; }
.inline-search { display: none; }
.task-list button { grid-template-columns: 30px minmax(0, 1fr) 18px; padding-inline: 12px; }
.task-list button > b { display: none; }
.quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; }
.quick-result { margin-inline: 12px; }
.company-ledger summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
.company-ledger summary > strong:nth-of-type(2), .company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; }
.company-name { grid-template-columns: 30px minmax(0, 1fr); }
.company-name i { width: 28px; height: 28px; }
.filter-bar { align-items: stretch; flex-direction: column; }
.segmented { width: 100%; }
.flow-filters { grid-template-columns: 1fr 1fr; }
.flow-filters .grow, .flow-filters .button { grid-column: 1 / -1; }
.pair-query-form { grid-template-columns: 1fr 38px 1fr; }
.pair-query-form .field:nth-of-type(3), .pair-query-form .button { grid-column: 1 / -1; }
.pair-report-heading { align-items: flex-start; flex-direction: column; }
.pair-balance-line { grid-template-columns: 1fr 1fr; }
.pair-balance-line .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
.subject-strip { grid-template-columns: 1fr 1fr; }
.company-alert { grid-template-columns: 28px 1fr; }
.company-alert .button { grid-column: 1 / -1; }
.cashier-tasks article { grid-template-columns: 30px minmax(0, 1fr); }
.cashier-tasks .button { grid-column: 2; justify-self: start; }
.work-progress { grid-template-columns: 1fr; }
.work-progress i { display: none; }
.reconcile-summary { grid-template-columns: 1fr; }
.reconcile-summary div { min-height: 70px; border-top: 1px solid var(--color-line); border-left: 0; }
.reconcile-summary div:first-child { border-top: 0; }
.candidate, .candidate-options { align-items: stretch; flex-direction: column; margin-left: 0; }
.account-directory { grid-template-columns: 1fr; }
.account-directory dl { grid-template-columns: 1fr 1fr; }
.closing-checks { grid-template-columns: 1fr; }
.closing-checks article { border-top: 1px solid var(--color-line); border-left: 0; }
.closing-footer { align-items: stretch; flex-direction: column; }
.notification-list article { grid-template-columns: 34px minmax(0, 1fr); }
.notification-list article > em { grid-column: 2; justify-self: start; }
.table-summary { align-items: flex-start; flex-direction: column; justify-content: center; padding-block: 9px; }
.dialog { width: 100%; max-width: none; height: 100%; max-height: none; border-radius: 0; }
.dialog > form { min-height: 100%; display: flex; flex-direction: column; }
.dialog-body { flex: 1; }
.form-grid { grid-template-columns: 1fr; }
.toast-region { right: 12px; bottom: 12px; left: 12px; }
.toast { min-width: 0; max-width: none; }
.entry-context { min-height: auto; padding: 30px 22px; }
.entry-statement { margin: 54px 0 42px; }
.entry-statement h1 { font-size: 39px; }
.entry-facts { grid-template-columns: 1fr; }
.entry-form-wrap { padding: 32px 16px 48px; }
.entry-form { padding: 22px; }
}
@media (max-width: 460px) {
.metric-card { min-height: 150px; padding: 13px; }
.metric-copy { min-height: 52px; }
.metric-label { font-size: 11px; }
.metric-value { font-size: 23px; }
.role-switch { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.