first ruggedy draft
This commit is contained in:
1
apps/web/.env.development
Normal file
1
apps/web/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://127.0.0.1:8001
|
||||
5
apps/web/.env.example
Normal file
5
apps/web/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
VITE_API_URL=http://127.0.0.1:8001
|
||||
|
||||
# Optional. Leave unset to use the embedded API route at ${VITE_API_URL}/agent.
|
||||
# Set this only if you run `mousehold-agent serve` as a separate process.
|
||||
VITE_LOCAL_AGENT_URL=http://127.0.0.1:8765
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mousehold</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
apps/web/package.json
Normal file
25
apps/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@mousehold/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "tsc --noEmit",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
16
apps/web/playwright.config.ts
Normal file
16
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 30_000,
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_WEB_URL ?? "http://127.0.0.1:5173",
|
||||
trace: "retain-on-failure"
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] }
|
||||
}
|
||||
]
|
||||
});
|
||||
4071
apps/web/src/App.tsx
Normal file
4071
apps/web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
38
apps/web/src/api.ts
Normal file
38
apps/web/src/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "http://127.0.0.1:8000";
|
||||
const LOCAL_AGENT_BASE = import.meta.env.VITE_LOCAL_AGENT_URL ?? `${API_BASE}/agent`;
|
||||
|
||||
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || response.statusText);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function localAgentApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${LOCAL_AGENT_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || response.statusText);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
return `${API_BASE}${path}`;
|
||||
}
|
||||
10
apps/web/src/main.tsx
Normal file
10
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
890
apps/web/src/styles.css
Normal file
890
apps/web/src/styles.css
Normal file
@@ -0,0 +1,890 @@
|
||||
:root {
|
||||
color: #1f2528;
|
||||
background: #f5f7f8;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
min-height: 38px;
|
||||
border: 1px solid #cfd7dc;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #1f2528;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 72px;
|
||||
padding-top: 9px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
background: #1f6f68;
|
||||
border-color: #1f6f68;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
background: #ffffff;
|
||||
color: #1f2528;
|
||||
border-color: #aab6bc;
|
||||
}
|
||||
|
||||
button.inline-button {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
button.icon-button {
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #9a321f;
|
||||
border-color: #9a321f;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid #e0e5e8;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #607079;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td span {
|
||||
display: block;
|
||||
color: #68767d;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid #dce3e6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.topbar span {
|
||||
color: #607079;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-actions select {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.page-rail {
|
||||
position: sticky;
|
||||
top: 75px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 10px 24px;
|
||||
border-bottom: 1px solid #dce3e6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.page-rail button {
|
||||
min-height: 36px;
|
||||
background: #ffffff;
|
||||
border-color: transparent;
|
||||
color: #3d484d;
|
||||
}
|
||||
|
||||
.page-rail button.active {
|
||||
background: #1f6f68;
|
||||
border-color: #1f6f68;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 18px 24px 32px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 18px 24px 32px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.wide-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.panel,
|
||||
aside.panel > section {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
aside.panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-grid .wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
color: #3d484d;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.topbar-field {
|
||||
grid-template-columns: auto minmax(220px, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-select-field {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.management-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.connector-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.connector-meta {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.management-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(120px, 180px) repeat(3, 38px);
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
padding: 8px;
|
||||
border: 1px solid #e0e5e8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.management-row.account-row {
|
||||
grid-template-columns:
|
||||
minmax(140px, 1fr) minmax(120px, 1fr) minmax(120px, 160px)
|
||||
minmax(120px, 150px) minmax(120px, 150px) auto 38px 38px;
|
||||
}
|
||||
|
||||
.management-row.connector-row {
|
||||
grid-template-columns:
|
||||
minmax(140px, 1fr) minmax(120px, 150px) minmax(120px, 150px)
|
||||
minmax(110px, 140px) minmax(140px, 1fr) minmax(120px, 1fr)
|
||||
minmax(180px, 1.2fr) auto 38px 38px;
|
||||
}
|
||||
|
||||
.management-row.recurring-row {
|
||||
grid-template-columns:
|
||||
minmax(120px, 1fr) minmax(120px, 1fr) minmax(100px, 120px)
|
||||
minmax(110px, 130px) minmax(130px, 150px) minmax(120px, 160px)
|
||||
minmax(120px, 160px) minmax(120px, 160px) minmax(120px, 140px)
|
||||
minmax(130px, 150px) minmax(110px, 130px) 38px 38px;
|
||||
}
|
||||
|
||||
.management-row.taxonomy-row {
|
||||
grid-template-columns: minmax(130px, 1fr) minmax(140px, 1fr) minmax(150px, 1fr) auto 38px 38px;
|
||||
}
|
||||
|
||||
.management-row.tag-row {
|
||||
grid-template-columns: minmax(140px, 1fr) 38px 38px;
|
||||
}
|
||||
|
||||
.compact-check {
|
||||
min-height: 38px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-grid > button,
|
||||
.management-row > button,
|
||||
.form-grid > .checkbox,
|
||||
.management-row > .checkbox {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.fints-grid {
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.mail-grid {
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.statement-grid {
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
color: #3d484d;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.summary-list span {
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
color: #54646c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-list span {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wide-metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 74px;
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: #607079;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 58px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.row-card span {
|
||||
display: block;
|
||||
max-width: 42ch;
|
||||
overflow: hidden;
|
||||
color: #607079;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconcile-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(300px, 0.75fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: 1px solid #dce3e6;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.review-main {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.review-select {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.review-main span,
|
||||
.linked-event span,
|
||||
.linked-event small,
|
||||
.muted-note {
|
||||
display: block;
|
||||
color: #607079;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.linked-event {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e0e5e8;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.review-edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e0e5e8;
|
||||
}
|
||||
|
||||
.review-edit-grid .wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.review-edit-grid > button {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.review-link-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.review-filters {
|
||||
grid-template-columns: auto minmax(180px, 240px) auto;
|
||||
}
|
||||
|
||||
.review-batch-actions {
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-run-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #dce3e6;
|
||||
}
|
||||
|
||||
.reconcile-card {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.reconcile-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reconcile-event {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reconcile-event span,
|
||||
.reconcile-actions span,
|
||||
.reconcile-event small {
|
||||
display: block;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
color: #68767d;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.reconcile-event strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reconcile-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
border-radius: 999px;
|
||||
padding: 0 9px;
|
||||
background: #edf1f2;
|
||||
color: #425158;
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status.confirmed,
|
||||
.status.booked,
|
||||
.status.settled,
|
||||
.status.candidate_created {
|
||||
background: #e3f4ec;
|
||||
color: #176342;
|
||||
}
|
||||
|
||||
.status.outstanding,
|
||||
.status.announced {
|
||||
background: #fff2d8;
|
||||
color: #7a4d00;
|
||||
}
|
||||
|
||||
.status.duplicate_source,
|
||||
.status.error {
|
||||
background: #ffe7e3;
|
||||
color: #9a321f;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.selected-row td {
|
||||
background: #eef6f5;
|
||||
}
|
||||
|
||||
.compact-table {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title select {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.table-filters {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(160px, 260px) minmax(150px, 190px);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #dce3e6;
|
||||
}
|
||||
|
||||
.event-edit-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-detail .section-title span {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #607079;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inspector-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inspector-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-grid section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-card span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.fints-prompt {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, 260px) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #cfd7dc;
|
||||
border-radius: 8px;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
.challenge strong,
|
||||
.challenge span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.challenge span {
|
||||
color: #3d484d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.challenge img {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #cfd7dc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 16px 24px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f0b5aa;
|
||||
border-radius: 8px;
|
||||
background: #fff0ed;
|
||||
color: #8d2f1e;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.layout,
|
||||
.grid.two,
|
||||
.grid.three,
|
||||
.setup-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wide-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.management-row,
|
||||
.management-row.account-row,
|
||||
.management-row.connector-row,
|
||||
.management-row.recurring-row,
|
||||
.management-row.taxonomy-row,
|
||||
.management-row.tag-row,
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.review-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.review-edit-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.topbar {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar-actions select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-rail {
|
||||
position: static;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.page,
|
||||
.layout {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.fints-grid,
|
||||
.mail-grid,
|
||||
.statement-grid,
|
||||
.metric-row,
|
||||
.wide-metrics,
|
||||
.management-row,
|
||||
.management-row.account-row,
|
||||
.management-row.connector-row,
|
||||
.management-row.recurring-row,
|
||||
.management-row.taxonomy-row,
|
||||
.management-row.tag-row,
|
||||
.table-filters,
|
||||
.reconcile-main,
|
||||
.reconcile-actions,
|
||||
.review-card,
|
||||
.review-actions,
|
||||
.review-edit-grid,
|
||||
.review-filters,
|
||||
.detail-grid,
|
||||
.compact-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fints-prompt {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid .wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.review-edit-grid .wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
}
|
||||
405
apps/web/src/types.ts
Normal file
405
apps/web/src/types.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
export type Household = {
|
||||
id: string;
|
||||
name: string;
|
||||
default_currency: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
};
|
||||
|
||||
export type Account = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
owner_user_id: string | null;
|
||||
name: string;
|
||||
account_type: string;
|
||||
institution_name: string | null;
|
||||
currency: string;
|
||||
visibility_mode: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type Observation = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
source_type: string;
|
||||
raw_hash: string;
|
||||
status: string;
|
||||
source_reference: string | null;
|
||||
owner_user_id?: string | null;
|
||||
source_account_id?: string | null;
|
||||
observed_at?: string | null;
|
||||
received_at?: string;
|
||||
merchant?: string | null;
|
||||
parsed_json: Record<string, string | null> | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SplitLine = {
|
||||
id: string;
|
||||
event_id: string;
|
||||
user_id: string;
|
||||
share_amount: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type Event = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
source_account_id: string | null;
|
||||
payment_account_id: string | null;
|
||||
payer_user_id: string | null;
|
||||
event_type: string;
|
||||
status: string;
|
||||
event_date: string;
|
||||
booking_date: string | null;
|
||||
due_date: string | null;
|
||||
amount: string;
|
||||
currency: string;
|
||||
merchant: string | null;
|
||||
counterparty: string | null;
|
||||
description: string | null;
|
||||
category_id: string | null;
|
||||
project_id: string | null;
|
||||
split_lines: SplitLine[];
|
||||
tag_ids: string[];
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
path: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type EventRelation = {
|
||||
id: string;
|
||||
from_event_id: string;
|
||||
to_event_id: string;
|
||||
relation_type: string;
|
||||
confidence: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type EventObservationLink = {
|
||||
id: string;
|
||||
event_id: string;
|
||||
observation_id: string;
|
||||
relation: string;
|
||||
confidence: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type EventDetail = {
|
||||
event: Event;
|
||||
category: Category | null;
|
||||
tags: Tag[];
|
||||
observation_links: EventObservationLink[];
|
||||
observations: Observation[];
|
||||
relations: EventRelation[];
|
||||
related_events: Event[];
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
session: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
household_id: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
expires_at: string;
|
||||
};
|
||||
user: User;
|
||||
household: Household;
|
||||
};
|
||||
|
||||
export type ObservationReviewItem = {
|
||||
observation: Observation;
|
||||
events: Event[];
|
||||
action_required: boolean;
|
||||
suggested_action: string;
|
||||
};
|
||||
|
||||
export type SettlementSummary = {
|
||||
household_id: string;
|
||||
project_id: string | null;
|
||||
currency: string;
|
||||
included_event_ids: string[];
|
||||
excluded_event_ids: string[];
|
||||
balances: Array<{ user_id: string; paid: string; share: string; net: string }>;
|
||||
suggested_transfers: Array<{ from_user_id: string; to_user_id: string; amount: string; currency: string }>;
|
||||
explanation: string[];
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type ProjectSummary = {
|
||||
project_id: string;
|
||||
currency: string;
|
||||
budgeted_total: string;
|
||||
committed_total: string;
|
||||
paid_total: string;
|
||||
reconciled_total: string;
|
||||
settled_total: string;
|
||||
due_later_total: string;
|
||||
remaining_uncommitted_budget: string;
|
||||
open_actions: string[];
|
||||
};
|
||||
|
||||
export type Connection = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
owner_user_id: string;
|
||||
connection_type: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
last_sync_at: string | null;
|
||||
last_error: string | null;
|
||||
secret_storage_ref: string | null;
|
||||
runs_locally: boolean;
|
||||
connector_config_json: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ImportRun = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
connection_id: string | null;
|
||||
actor_user_id: string | null;
|
||||
source_type: string | null;
|
||||
status: string;
|
||||
imported_count: number;
|
||||
duplicate_count: number;
|
||||
created_event_count: number;
|
||||
skipped_count: number;
|
||||
error: string | null;
|
||||
summary_json: Record<string, unknown> | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
};
|
||||
|
||||
export type ImportRunDetail = {
|
||||
run: ImportRun;
|
||||
observations: Observation[];
|
||||
events: Event[];
|
||||
};
|
||||
|
||||
export type ObservationBatchResponse = {
|
||||
updated_count: number;
|
||||
items: ObservationReviewItem[];
|
||||
};
|
||||
|
||||
export type FinTSAccount = {
|
||||
index: number;
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
accountnumber: string | null;
|
||||
blz: string | null;
|
||||
balance?: {
|
||||
amount: string;
|
||||
currency: string | null;
|
||||
date: string;
|
||||
status: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type FinTSJob = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: "running" | "needs_tan" | "needs_choice" | "done" | "error";
|
||||
error: string | null;
|
||||
result: {
|
||||
accounts?: FinTSAccount[];
|
||||
observations?: Array<Record<string, unknown>>;
|
||||
count?: number;
|
||||
} | null;
|
||||
challenge: {
|
||||
challenge: string;
|
||||
challenge_html: string;
|
||||
challenge_hhduc: string | null;
|
||||
challenge_matrix: { mime_type: string; data_base64: string } | null;
|
||||
decoupled: boolean;
|
||||
} | null;
|
||||
choice_kind: string | null;
|
||||
choice_options: Array<{ value: string; label: string; description?: string }>;
|
||||
};
|
||||
|
||||
export type IMAPFolder = {
|
||||
name: string;
|
||||
delimiter: string | null;
|
||||
attributes: string[];
|
||||
};
|
||||
|
||||
export type IMAPPreviewMessage = {
|
||||
message_id: string | null;
|
||||
source_reference: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
folder: string;
|
||||
source_type: string;
|
||||
event_type: string;
|
||||
status: string;
|
||||
amount: string | null;
|
||||
currency: string | null;
|
||||
merchant: string | null;
|
||||
due_date: string | null;
|
||||
references: Record<string, string>;
|
||||
importable: boolean;
|
||||
skip_reason: string | null;
|
||||
};
|
||||
|
||||
export type IMAPScanResult = {
|
||||
folder: string;
|
||||
scanned_count: number;
|
||||
filtered_count: number;
|
||||
matched_count: number;
|
||||
importable_count: number;
|
||||
messages: IMAPPreviewMessage[];
|
||||
observations: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type StatementPreviewRow = {
|
||||
row: number;
|
||||
source_type: string;
|
||||
source_reference: string | null;
|
||||
event_date: string | null;
|
||||
amount: string | null;
|
||||
currency: string;
|
||||
merchant: string | null;
|
||||
description: string | null;
|
||||
raw_type: string;
|
||||
event_type: string;
|
||||
status: string;
|
||||
importable: boolean;
|
||||
skip_reason: string | null;
|
||||
};
|
||||
|
||||
export type StatementParseResult = {
|
||||
source: "paypal" | "klarna";
|
||||
filename: string | null;
|
||||
row_count: number;
|
||||
importable_count: number;
|
||||
skipped_count: number;
|
||||
rows: StatementPreviewRow[];
|
||||
observations: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type ReconciliationEventSummary = {
|
||||
id: string;
|
||||
source_type: string | null;
|
||||
event_type: string;
|
||||
status: string;
|
||||
event_date: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
merchant: string | null;
|
||||
counterparty: string | null;
|
||||
description: string | null;
|
||||
source_reference: string | null;
|
||||
provider_transaction_id: string | null;
|
||||
observation_id: string | null;
|
||||
};
|
||||
|
||||
export type ReconciliationSuggestion = {
|
||||
id: string;
|
||||
kind: string;
|
||||
relation_type: string;
|
||||
primary_event: ReconciliationEventSummary;
|
||||
confirming_event: ReconciliationEventSummary;
|
||||
confidence: string;
|
||||
reasons: string[];
|
||||
action: string;
|
||||
};
|
||||
|
||||
export type ReconciliationAcceptResponse = {
|
||||
accepted: ReconciliationSuggestion;
|
||||
};
|
||||
|
||||
export type ReconciliationDecision = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
suggestion_id: string;
|
||||
decision: string;
|
||||
kind: string;
|
||||
relation_type: string;
|
||||
primary_event_id: string;
|
||||
confirming_event_id: string;
|
||||
confidence: string;
|
||||
actor_user_id: string | null;
|
||||
reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ReconciliationIgnoreResponse = {
|
||||
ignored: ReconciliationSuggestion;
|
||||
decision: ReconciliationDecision;
|
||||
};
|
||||
|
||||
export type SyncObservationsResponse = {
|
||||
imported_observation_ids: string[];
|
||||
created_event_ids: string[];
|
||||
duplicate_observation_ids: string[];
|
||||
connection_id: string | null;
|
||||
imported_count: number;
|
||||
duplicate_count: number;
|
||||
created_event_count: number;
|
||||
run: ImportRun | null;
|
||||
};
|
||||
|
||||
export type BackupResult = {
|
||||
file_path: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type HouseholdExport = {
|
||||
household: Record<string, unknown>;
|
||||
exported_at: string;
|
||||
counts: Record<string, number>;
|
||||
data: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
export type RecurringCommitment = {
|
||||
id: string;
|
||||
household_id: string;
|
||||
provider: string;
|
||||
description: string | null;
|
||||
amount_expected: string;
|
||||
amount_min: string | null;
|
||||
amount_max: string | null;
|
||||
currency: string;
|
||||
interval: string;
|
||||
next_due_date: string;
|
||||
payer_account_id: string | null;
|
||||
category_id: string | null;
|
||||
project_id: string | null;
|
||||
notice_period: string | null;
|
||||
renewal_date: string | null;
|
||||
status: string;
|
||||
matching_rule_json: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
1
apps/web/src/vite-env.d.ts
vendored
Normal file
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
102
apps/web/tests/mvp.spec.ts
Normal file
102
apps/web/tests/mvp.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { expect, test, type APIRequestContext, type APIResponse } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function envFileValue(key: string): string | undefined {
|
||||
try {
|
||||
const lines = readFileSync(".env.development", "utf8").split(/\r?\n/);
|
||||
const prefix = `${key}=`;
|
||||
const line = lines.find((item) => item.startsWith(prefix));
|
||||
return line?.slice(prefix.length).trim().replace(/^["']|["']$/g, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const apiBase =
|
||||
process.env.PLAYWRIGHT_API_URL ??
|
||||
process.env.VITE_API_URL ??
|
||||
envFileValue("VITE_API_URL") ??
|
||||
"http://127.0.0.1:8000";
|
||||
|
||||
async function expectOk(response: APIResponse): Promise<void> {
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function postJson<T>(
|
||||
request: APIRequestContext,
|
||||
path: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const response = await request.post(`${apiBase}${path}`, { data });
|
||||
await expectOk(response);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
test("local MVP ledger flow", async ({ page, request }) => {
|
||||
test.setTimeout(60_000);
|
||||
const suffix = Date.now().toString(36);
|
||||
const household = await postJson<{ id: string; name: string }>(request, "/households", {
|
||||
name: `Smoke household ${suffix}`,
|
||||
default_currency: "EUR"
|
||||
});
|
||||
const userA = await postJson<{ id: string; display_name: string }>(request, "/users", {
|
||||
household_id: household.id,
|
||||
display_name: `Alice ${suffix}`,
|
||||
email: `alice-${suffix}@example.test`
|
||||
});
|
||||
await postJson<{ id: string }>(request, "/users", {
|
||||
household_id: household.id,
|
||||
display_name: `Bob ${suffix}`,
|
||||
email: `bob-${suffix}@example.test`
|
||||
});
|
||||
const account = await postJson<{ id: string; name: string }>(request, "/accounts", {
|
||||
household_id: household.id,
|
||||
owner_user_id: userA.id,
|
||||
name: `Alice checking ${suffix}`,
|
||||
account_type: "checking",
|
||||
institution_name: "Smoke Bank",
|
||||
currency: "EUR",
|
||||
visibility_mode: "shared_full",
|
||||
is_active: true
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
const householdSelect = page.getByLabel("Household");
|
||||
await expect(householdSelect).toContainText(household.name);
|
||||
await householdSelect.selectOption({ value: household.id });
|
||||
const sessionSelect = page.getByLabel("Signed in");
|
||||
await expect(sessionSelect).toBeEnabled();
|
||||
await expect(sessionSelect).toContainText(userA.display_name);
|
||||
await sessionSelect.selectOption({ value: userA.id });
|
||||
await expect(page.getByRole("button", { name: "Logout" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Entry" }).click();
|
||||
const manualEntry = page.locator("section.panel").filter({ has: page.getByRole("heading", { name: "Entry" }) });
|
||||
await manualEntry.getByLabel("Amount", { exact: true }).fill("42.50");
|
||||
await manualEntry.getByLabel("Merchant", { exact: true }).fill(`Smoke Shop ${suffix}`);
|
||||
await manualEntry.getByLabel("Description", { exact: true }).fill("Smoke groceries");
|
||||
await manualEntry.locator('select[name="payer_user_id"]').selectOption({ label: userA.display_name });
|
||||
await manualEntry.locator('select[name="source_account_id"]').selectOption({ label: account.name });
|
||||
await manualEntry.getByRole("button", { name: "Observation" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Inbox" }).click();
|
||||
const reviewCard = page.locator(".review-card").filter({ hasText: `Smoke Shop ${suffix}` }).first();
|
||||
await expect(reviewCard).toBeVisible();
|
||||
await reviewCard.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Ledger" }).click();
|
||||
const ledgerRow = page.locator("tbody tr").filter({ hasText: `Smoke Shop ${suffix}` }).first();
|
||||
await expect(ledgerRow).toBeVisible();
|
||||
await expect(ledgerRow.getByText("confirmed")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Setup" }).click();
|
||||
await expect(page.getByRole("button", { name: "Export JSON" })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "SQLite backup" })).toBeEnabled();
|
||||
} finally {
|
||||
const cleanup = await request.delete(`${apiBase}/households/${household.id}`);
|
||||
if (!cleanup.ok() && cleanup.status() !== 404) {
|
||||
console.warn(await cleanup.text());
|
||||
}
|
||||
}
|
||||
});
|
||||
21
apps/web/tsconfig.json
Normal file
21
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
9
apps/web/tsconfig.node.json
Normal file
9
apps/web/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
apps/web/vite.config.ts
Normal file
10
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: false
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user